mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Compare commits
135 Commits
v0.4.0
...
clean-them
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4907a610e | ||
|
|
c7312af3c8 | ||
|
|
f3dbc996d4 | ||
|
|
760f254f78 | ||
|
|
91bff6c572 | ||
|
|
ecf34b178e | ||
|
|
3cd6e8c13b | ||
|
|
ca8341bf39 | ||
|
|
160bd8bfc7 | ||
|
|
0d1138dfcf | ||
|
|
b112747073 | ||
|
|
cffdec91f1 | ||
|
|
d9c87f8116 | ||
|
|
acfb8d2255 | ||
|
|
fe6106e807 | ||
|
|
2af43d7c2d | ||
|
|
a7a6ff2e6c | ||
|
|
8f598d7ce3 | ||
|
|
7f8092264a | ||
|
|
e0471fef09 | ||
|
|
043edde63b | ||
|
|
4b9a211c49 | ||
|
|
b59bbd93ba | ||
|
|
2a782392bc | ||
|
|
fe56ba133e | ||
|
|
40a3046a3b | ||
|
|
1aa8b5b56b | ||
|
|
266e0c54b9 | ||
|
|
31550ab4e7 | ||
|
|
cce8d1569a | ||
|
|
ad051eb8f0 | ||
|
|
01098545cf | ||
|
|
d58bd782ef | ||
|
|
c11cb6a6cd | ||
|
|
64549f824c | ||
|
|
83fab5321e | ||
|
|
bb47f22d6c | ||
|
|
4996a63bcc | ||
|
|
f302234b0e | ||
|
|
58d6ae02a5 | ||
|
|
f9ec7222f2 | ||
|
|
360b7ebe08 | ||
|
|
ebc99d06eb | ||
|
|
f2600821d6 | ||
|
|
6e08126875 | ||
|
|
176eeca096 | ||
|
|
f6a9ae6335 | ||
|
|
30db67f89c | ||
|
|
da90bafde8 | ||
|
|
04d263b1ed | ||
|
|
e8e79d8446 | ||
|
|
8c24381759 | ||
|
|
8482cdab87 | ||
|
|
064a395c4c | ||
|
|
d103d0aa45 | ||
|
|
9509c8ea00 | ||
|
|
26b73fdaa9 | ||
|
|
a3c9c9cee5 | ||
|
|
0d088962a0 | ||
|
|
2ce4e02ada | ||
|
|
fad3ed1aae | ||
|
|
81444d5603 | ||
|
|
e8b65dbd0b | ||
|
|
f86bb3eab8 | ||
|
|
54a102f029 | ||
|
|
2ee4ec65b4 | ||
|
|
166679cd36 | ||
|
|
b95c54a539 | ||
|
|
e71be53459 | ||
|
|
8c5759d74e | ||
|
|
bd1c4e0690 | ||
|
|
9a428eefe0 | ||
|
|
8774d28bc4 | ||
|
|
9eb9c070cd | ||
|
|
7110a690e1 | ||
|
|
1194e7d51e | ||
|
|
1641f9da5e | ||
|
|
ff4887773e | ||
|
|
15a580ece9 | ||
|
|
b37d258698 | ||
|
|
e0e7bb9190 | ||
|
|
7131c70186 | ||
|
|
be98a59023 | ||
|
|
7e517101a0 | ||
|
|
92f60cceb5 | ||
|
|
b1dcb8a9d7 | ||
|
|
ec6ec7d569 | ||
|
|
31bb069e75 | ||
|
|
363be54303 | ||
|
|
ca0f6661d3 | ||
|
|
cd803cd9bc | ||
|
|
cbdc88c5d0 | ||
|
|
44b548c5c8 | ||
|
|
cc2ac3542d | ||
|
|
25044d40b9 | ||
|
|
676169b189 | ||
|
|
c8c05efb8d | ||
|
|
23cef5fd82 | ||
|
|
c0b0b30541 | ||
|
|
7eeba5f17c | ||
|
|
fcb2457e17 | ||
|
|
02e378905e | ||
|
|
87c0ab6daa | ||
|
|
60f9da9208 | ||
|
|
ff318d6ef5 | ||
|
|
93d9e08de1 | ||
|
|
7edca6b823 | ||
|
|
73eebe7c9e | ||
|
|
049f9a9e37 | ||
|
|
658cbb8bd6 | ||
|
|
19f1c32805 | ||
|
|
ece8ff8cbc | ||
|
|
a3a648aef1 | ||
|
|
3bc2b74d30 | ||
|
|
9d17cd7d9c | ||
|
|
091c6b2737 | ||
|
|
2880314931 | ||
|
|
0102719067 | ||
|
|
123b471b68 | ||
|
|
b66d228460 | ||
|
|
770d67d8c4 | ||
|
|
d42857ec26 | ||
|
|
54b977ee1b | ||
|
|
e8999ba908 | ||
|
|
96c4383b29 | ||
|
|
93d1d2c41a | ||
|
|
b075af5bc9 | ||
|
|
07ca7fccb8 | ||
|
|
797643ffdc | ||
|
|
7d4052be95 | ||
|
|
1036719f2a | ||
|
|
1ab520eda3 | ||
|
|
658f7d816e | ||
|
|
a5c61b0546 | ||
|
|
480589510e |
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(dir \"C:\\Users\\Ben\\Desktop\\appdev\\git\\automaker\\apps\\app\\public\")"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
7
.github/workflows/e2e-tests.yml
vendored
7
.github/workflows/e2e-tests.yml
vendored
@@ -21,10 +21,15 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
- name: Configure Git for HTTPS
|
||||||
|
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
|
||||||
|
# This is needed because SSH authentication isn't available in CI
|
||||||
|
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||||
|
|||||||
10
.github/workflows/pr-check.yml
vendored
10
.github/workflows/pr-check.yml
vendored
@@ -20,10 +20,18 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
- name: Check for SSH URLs in lockfile
|
||||||
|
run: npm run lint:lockfile
|
||||||
|
|
||||||
|
- name: Configure Git for HTTPS
|
||||||
|
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
|
||||||
|
# This is needed because SSH authentication isn't available in CI
|
||||||
|
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||||
|
|||||||
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -39,10 +39,15 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
- name: Configure Git for HTTPS
|
||||||
|
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
|
||||||
|
# This is needed because SSH authentication isn't available in CI
|
||||||
|
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||||
|
|||||||
7
.github/workflows/test.yml
vendored
7
.github/workflows/test.yml
vendored
@@ -20,10 +20,15 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
- name: Configure Git for HTTPS
|
||||||
|
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
|
||||||
|
# This is needed because SSH authentication isn't available in CI
|
||||||
|
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||||
|
|||||||
67
.gitignore
vendored
67
.gitignore
vendored
@@ -6,10 +6,75 @@ node_modules/
|
|||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
dist/
|
dist/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
.next/
|
.next/
|
||||||
|
.turbo/
|
||||||
|
|
||||||
|
# Automaker
|
||||||
.automaker/images/
|
.automaker/images/
|
||||||
.automaker/
|
.automaker/
|
||||||
/.automaker/*
|
/.automaker/*
|
||||||
/.automaker/
|
/.automaker/
|
||||||
|
|
||||||
/logs
|
.worktrees/
|
||||||
|
|
||||||
|
/logs
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# OS-specific files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# IDE/Editor configs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.sublime-workspace
|
||||||
|
*.sublime-project
|
||||||
|
|
||||||
|
# Editor backup/temp files
|
||||||
|
*~
|
||||||
|
*.bak
|
||||||
|
*.backup
|
||||||
|
*.orig
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Local settings (user-specific)
|
||||||
|
*.local.json
|
||||||
|
|
||||||
|
# Application state/backup
|
||||||
|
backup.json
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
test-results/
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
*.lcov
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
|
||||||
|
# Environment files (keep .example)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
!.env.local.example
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.pem
|
||||||
|
|||||||
67
README.md
67
README.md
@@ -1,3 +1,7 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="apps/app/public/readme_logo.png" alt="Automaker Logo" height="80" />
|
||||||
|
</p>
|
||||||
|
|
||||||
> **[!TIP]**
|
> **[!TIP]**
|
||||||
>
|
>
|
||||||
> **Learn more about Agentic Coding!**
|
> **Learn more about Agentic Coding!**
|
||||||
@@ -10,8 +14,39 @@
|
|||||||
|
|
||||||
**Stop typing code. Start directing AI agents.**
|
**Stop typing code. Start directing AI agents.**
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><h2>Table of Contents</h2></summary>
|
||||||
|
|
||||||
|
- [What Makes Automaker Different?](#what-makes-automaker-different)
|
||||||
|
- [The Workflow](#the-workflow)
|
||||||
|
- [Powered by Claude Code](#powered-by-claude-code)
|
||||||
|
- [Why This Matters](#why-this-matters)
|
||||||
|
- [Security Disclaimer](#security-disclaimer)
|
||||||
|
- [Community & Support](#community--support)
|
||||||
|
- [Getting Started](#getting-started)
|
||||||
|
- [Prerequisites](#prerequisites)
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [How to Run](#how-to-run)
|
||||||
|
- [Development Mode](#development-mode)
|
||||||
|
- [Electron Desktop App (Recommended)](#electron-desktop-app-recommended)
|
||||||
|
- [Web Browser Mode](#web-browser-mode)
|
||||||
|
- [Building for Production](#building-for-production)
|
||||||
|
- [Running Production Build](#running-production-build)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [Linting](#linting)
|
||||||
|
- [Authentication Options](#authentication-options)
|
||||||
|
- [Persistent Setup (Optional)](#persistent-setup-optional)
|
||||||
|
- [Features](#features)
|
||||||
|
- [Tech Stack](#tech-stack)
|
||||||
|
- [Learn More](#learn-more)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Code automatically implement them.
|
Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Code automatically implement them.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## What Makes Automaker Different?
|
## What Makes Automaker Different?
|
||||||
|
|
||||||
Traditional development tools help you write code. Automaker helps you **orchestrate AI agents** to build entire features autonomously. Think of it as having a team of AI developers working for you—you define what needs to be built, and Automaker handles the implementation.
|
Traditional development tools help you write code. Automaker helps you **orchestrate AI agents** to build entire features autonomously. Think of it as having a team of AI developers working for you—you define what needs to be built, and Automaker handles the implementation.
|
||||||
@@ -48,6 +83,22 @@ The future of software development is **agentic coding**—where developers beco
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Community & Support
|
||||||
|
|
||||||
|
Join the **Agentic Jumpstart** to connect with other builders exploring **agentic coding** and autonomous development workflows.
|
||||||
|
|
||||||
|
In the Discord, you can:
|
||||||
|
- 💬 Discuss agentic coding patterns and best practices
|
||||||
|
- 🧠 Share ideas for AI-driven development workflows
|
||||||
|
- 🛠️ Get help setting up or extending Automaker
|
||||||
|
- 🚀 Show off projects built with AI agents
|
||||||
|
- 🤝 Collaborate with other developers and contributors
|
||||||
|
|
||||||
|
👉 **Join the Discord:**
|
||||||
|
https://discord.gg/jjem7aEDKU
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -60,7 +111,7 @@ The future of software development is **agentic coding**—where developers beco
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone the repo
|
# 1. Clone the repo
|
||||||
git clone git@github.com:AutoMaker-Org/automaker.git
|
git clone https://github.com/AutoMaker-Org/automaker.git
|
||||||
cd automaker
|
cd automaker
|
||||||
|
|
||||||
# 2. Install dependencies
|
# 2. Install dependencies
|
||||||
@@ -144,21 +195,17 @@ npm run lint
|
|||||||
|
|
||||||
Automaker supports multiple authentication methods (in order of priority):
|
Automaker supports multiple authentication methods (in order of priority):
|
||||||
|
|
||||||
| Method | Environment Variable | Description |
|
| Method | Environment Variable | Description |
|
||||||
| -------------------- | ------------------------- | --------------------------------------------------------- |
|
| ---------------- | -------------------- | ------------------------------- |
|
||||||
| OAuth Token (env) | `CLAUDE_CODE_OAUTH_TOKEN` | From `claude setup-token` - uses your Claude subscription |
|
| API Key (env) | `ANTHROPIC_API_KEY` | Anthropic API key |
|
||||||
| OAuth Token (stored) | — | Stored in app credentials file |
|
| API Key (stored) | — | Anthropic API key stored in app |
|
||||||
| API Key (stored) | — | Anthropic API key stored in app |
|
|
||||||
| API Key (env) | `ANTHROPIC_API_KEY` | Pay-per-use API key |
|
|
||||||
|
|
||||||
**Recommended:** Use `CLAUDE_CODE_OAUTH_TOKEN` if you have a Claude subscription.
|
|
||||||
|
|
||||||
### Persistent Setup (Optional)
|
### Persistent Setup (Optional)
|
||||||
|
|
||||||
Add to your `~/.bashrc` or `~/.zshrc`:
|
Add to your `~/.bashrc` or `~/.zshrc`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export CLAUDE_CODE_OAUTH_TOKEN="YOUR_TOKEN_HERE"
|
export ANTHROPIC_API_KEY="YOUR_API_KEY_HERE"
|
||||||
```
|
```
|
||||||
|
|
||||||
Then restart your terminal or run `source ~/.bashrc`.
|
Then restart your terminal or run `source ~/.bashrc`.
|
||||||
|
|||||||
310
REFACTORING_CANDIDATES.md
Normal file
310
REFACTORING_CANDIDATES.md
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
# Large Files - Refactoring Candidates
|
||||||
|
|
||||||
|
This document tracks files in the AutoMaker codebase that exceed 3000 lines or are significantly large (1000+ lines) and should be considered for refactoring into smaller, more maintainable components.
|
||||||
|
|
||||||
|
**Last Updated:** 2025-12-15
|
||||||
|
**Total Large Files:** 8
|
||||||
|
**Combined Size:** 15,027 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 CRITICAL - Over 3000 Lines
|
||||||
|
|
||||||
|
### 1. board-view.tsx - 3,325 lines
|
||||||
|
**Path:** `apps/app/src/components/views/board-view.tsx`
|
||||||
|
**Type:** React Component (TSX)
|
||||||
|
**Priority:** VERY HIGH
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Main Kanban board view component that serves as the centerpiece of the application.
|
||||||
|
|
||||||
|
**Current Responsibilities:**
|
||||||
|
- Feature/task card management and drag-and-drop operations using @dnd-kit
|
||||||
|
- Adding, editing, and deleting features
|
||||||
|
- Running autonomous agents to implement features
|
||||||
|
- Displaying feature status across multiple columns (Backlog, In Progress, Waiting Approval, Verified)
|
||||||
|
- Model/AI profile selection for feature implementation
|
||||||
|
- Advanced options configuration (thinking level, model selection, skip tests)
|
||||||
|
- Search/filtering functionality for cards
|
||||||
|
- Output modal for viewing agent results
|
||||||
|
- Feature suggestions dialog
|
||||||
|
- Board background customization
|
||||||
|
- Integration with Electron APIs for IPC communication
|
||||||
|
- Keyboard shortcuts support
|
||||||
|
- 40+ state variables for managing UI state
|
||||||
|
|
||||||
|
**Refactoring Recommendations:**
|
||||||
|
Extract into smaller components:
|
||||||
|
- `AddFeatureDialog.tsx` - Feature creation dialog with image upload
|
||||||
|
- `EditFeatureDialog.tsx` - Feature editing dialog
|
||||||
|
- `AgentOutputModal.tsx` - Already exists, verify separation
|
||||||
|
- `FeatureSuggestionsDialog.tsx` - Already exists, verify separation
|
||||||
|
- `BoardHeader.tsx` - Header with controls and search
|
||||||
|
- `BoardSearchBar.tsx` - Search and filter functionality
|
||||||
|
- `ConcurrencyControl.tsx` - Concurrency slider component
|
||||||
|
- `BoardActions.tsx` - Action buttons (add feature, auto mode, etc.)
|
||||||
|
- `DragDropContext.tsx` - Wrap drag-and-drop logic
|
||||||
|
- Custom hooks:
|
||||||
|
- `useBoardFeatures.ts` - Feature loading and management
|
||||||
|
- `useBoardDragDrop.ts` - Drag and drop handlers
|
||||||
|
- `useBoardActions.ts` - Feature action handlers (run, verify, delete, etc.)
|
||||||
|
- `useBoardKeyboardShortcuts.ts` - Keyboard shortcut logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 HIGH PRIORITY - 2000+ Lines
|
||||||
|
|
||||||
|
### 2. sidebar.tsx - 2,396 lines
|
||||||
|
**Path:** `apps/app/src/components/layout/sidebar.tsx`
|
||||||
|
**Type:** React Component (TSX)
|
||||||
|
**Priority:** HIGH
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Main navigation sidebar with comprehensive project management.
|
||||||
|
|
||||||
|
**Current Responsibilities:**
|
||||||
|
- Project folder navigation and selection
|
||||||
|
- View mode switching (Board, Agent, Settings, etc.)
|
||||||
|
- Project operations (create, delete, rename)
|
||||||
|
- Theme and appearance controls
|
||||||
|
- Terminal, Wiki, and other view launchers
|
||||||
|
- Drag-and-drop project reordering
|
||||||
|
- Settings and configuration access
|
||||||
|
|
||||||
|
**Refactoring Recommendations:**
|
||||||
|
Split into focused components:
|
||||||
|
- `ProjectSelector.tsx` - Project list and selection
|
||||||
|
- `NavigationTabs.tsx` - View mode tabs
|
||||||
|
- `ProjectActions.tsx` - Create, delete, rename operations
|
||||||
|
- `SettingsMenu.tsx` - Settings dropdown
|
||||||
|
- `ThemeSelector.tsx` - Theme controls
|
||||||
|
- `ViewLaunchers.tsx` - Terminal, Wiki launchers
|
||||||
|
- Custom hooks:
|
||||||
|
- `useProjectManagement.ts` - Project CRUD operations
|
||||||
|
- `useSidebarState.ts` - Sidebar state management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. electron.ts - 2,356 lines
|
||||||
|
**Path:** `apps/app/src/lib/electron.ts`
|
||||||
|
**Type:** TypeScript Utility/API Bridge
|
||||||
|
**Priority:** HIGH
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Electron IPC bridge and type definitions for frontend-backend communication.
|
||||||
|
|
||||||
|
**Current Responsibilities:**
|
||||||
|
- File system operations (read, write, directory listing)
|
||||||
|
- Project management APIs
|
||||||
|
- Feature management APIs
|
||||||
|
- Terminal/shell execution
|
||||||
|
- Auto mode and agent execution APIs
|
||||||
|
- Worktree management
|
||||||
|
- Provider status APIs
|
||||||
|
- Event handling and subscriptions
|
||||||
|
|
||||||
|
**Refactoring Recommendations:**
|
||||||
|
Modularize into domain-specific API modules:
|
||||||
|
- `api/file-system-api.ts` - File operations
|
||||||
|
- `api/project-api.ts` - Project CRUD
|
||||||
|
- `api/feature-api.ts` - Feature management
|
||||||
|
- `api/execution-api.ts` - Auto mode and agent execution
|
||||||
|
- `api/provider-api.ts` - Provider status and management
|
||||||
|
- `api/worktree-api.ts` - Git worktree operations
|
||||||
|
- `api/terminal-api.ts` - Terminal/shell APIs
|
||||||
|
- `types/electron-types.ts` - Shared type definitions
|
||||||
|
- `electron.ts` - Main export aggregator
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. app-store.ts - 2,174 lines
|
||||||
|
**Path:** `apps/app/src/store/app-store.ts`
|
||||||
|
**Type:** TypeScript State Management (Zustand Store)
|
||||||
|
**Priority:** HIGH
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Centralized application state store using Zustand.
|
||||||
|
|
||||||
|
**Current Responsibilities:**
|
||||||
|
- Global app state types and interfaces
|
||||||
|
- Project and feature management state
|
||||||
|
- Theme and appearance settings
|
||||||
|
- API keys configuration
|
||||||
|
- Keyboard shortcuts configuration
|
||||||
|
- Terminal themes configuration
|
||||||
|
- Auto mode settings
|
||||||
|
- All store mutations and selectors
|
||||||
|
|
||||||
|
**Refactoring Recommendations:**
|
||||||
|
Split into domain-specific stores:
|
||||||
|
- `stores/projects-store.ts` - Project state and actions
|
||||||
|
- `stores/features-store.ts` - Feature state and actions
|
||||||
|
- `stores/ui-store.ts` - UI state (theme, sidebar, modals)
|
||||||
|
- `stores/settings-store.ts` - User settings and preferences
|
||||||
|
- `stores/execution-store.ts` - Auto mode and running tasks
|
||||||
|
- `stores/provider-store.ts` - Provider configuration
|
||||||
|
- `types/store-types.ts` - Shared type definitions
|
||||||
|
- `app-store.ts` - Main store aggregator with combined selectors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 MEDIUM PRIORITY - 1000-2000 Lines
|
||||||
|
|
||||||
|
### 5. auto-mode-service.ts - 1,232 lines
|
||||||
|
**Path:** `apps/server/src/services/auto-mode-service.ts`
|
||||||
|
**Type:** TypeScript Service (Backend)
|
||||||
|
**Priority:** MEDIUM-HIGH
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Core autonomous feature implementation service.
|
||||||
|
|
||||||
|
**Current Responsibilities:**
|
||||||
|
- Worktree creation and management
|
||||||
|
- Feature execution with Claude Agent SDK
|
||||||
|
- Concurrent execution with concurrency limits
|
||||||
|
- Progress streaming via events
|
||||||
|
- Verification and merge workflows
|
||||||
|
- Provider management
|
||||||
|
- Error handling and classification
|
||||||
|
|
||||||
|
**Refactoring Recommendations:**
|
||||||
|
Extract into service modules:
|
||||||
|
- `services/worktree-manager.ts` - Worktree operations
|
||||||
|
- `services/feature-executor.ts` - Feature execution logic
|
||||||
|
- `services/concurrency-manager.ts` - Concurrency control
|
||||||
|
- `services/verification-service.ts` - Verification workflows
|
||||||
|
- `utils/error-classifier.ts` - Error handling utilities
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. spec-view.tsx - 1,230 lines
|
||||||
|
**Path:** `apps/app/src/components/views/spec-view.tsx`
|
||||||
|
**Type:** React Component (TSX)
|
||||||
|
**Priority:** MEDIUM
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Specification editor view component for feature specification management.
|
||||||
|
|
||||||
|
**Refactoring Recommendations:**
|
||||||
|
Extract editor components and hooks:
|
||||||
|
- `SpecEditor.tsx` - Main editor component
|
||||||
|
- `SpecToolbar.tsx` - Editor toolbar
|
||||||
|
- `SpecSidebar.tsx` - Spec navigation sidebar
|
||||||
|
- `useSpecEditor.ts` - Editor state management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. kanban-card.tsx - 1,180 lines
|
||||||
|
**Path:** `apps/app/src/components/views/kanban-card.tsx`
|
||||||
|
**Type:** React Component (TSX)
|
||||||
|
**Priority:** MEDIUM
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Individual Kanban card component with rich feature display and interaction.
|
||||||
|
|
||||||
|
**Refactoring Recommendations:**
|
||||||
|
Split into smaller card components:
|
||||||
|
- `KanbanCardHeader.tsx` - Card title and metadata
|
||||||
|
- `KanbanCardBody.tsx` - Card content
|
||||||
|
- `KanbanCardActions.tsx` - Action buttons
|
||||||
|
- `KanbanCardStatus.tsx` - Status indicators
|
||||||
|
- `useKanbanCard.ts` - Card interaction logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. analysis-view.tsx - 1,134 lines
|
||||||
|
**Path:** `apps/app/src/components/views/analysis-view.tsx`
|
||||||
|
**Type:** React Component (TSX)
|
||||||
|
**Priority:** MEDIUM
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Analysis view component for displaying and managing feature analysis data.
|
||||||
|
|
||||||
|
**Refactoring Recommendations:**
|
||||||
|
Extract visualization and data components:
|
||||||
|
- `AnalysisChart.tsx` - Chart/graph components
|
||||||
|
- `AnalysisTable.tsx` - Data table
|
||||||
|
- `AnalysisFilters.tsx` - Filter controls
|
||||||
|
- `useAnalysisData.ts` - Data fetching and processing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refactoring Strategy
|
||||||
|
|
||||||
|
### Phase 1: Critical (Immediate)
|
||||||
|
1. **board-view.tsx** - Break into dialogs, header, and custom hooks
|
||||||
|
- Extract all dialogs first (AddFeature, EditFeature)
|
||||||
|
- Move to custom hooks for business logic
|
||||||
|
- Split remaining UI into smaller components
|
||||||
|
|
||||||
|
### Phase 2: High Priority (Next Sprint)
|
||||||
|
2. **sidebar.tsx** - Componentize navigation and project management
|
||||||
|
3. **electron.ts** - Modularize into API domains
|
||||||
|
4. **app-store.ts** - Split into domain stores
|
||||||
|
|
||||||
|
### Phase 3: Medium Priority (Future)
|
||||||
|
5. **auto-mode-service.ts** - Extract service modules
|
||||||
|
6. **spec-view.tsx** - Break into editor components
|
||||||
|
7. **kanban-card.tsx** - Split card into sub-components
|
||||||
|
8. **analysis-view.tsx** - Extract visualization components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## General Refactoring Guidelines
|
||||||
|
|
||||||
|
### When Refactoring Large Components:
|
||||||
|
|
||||||
|
1. **Extract Dialogs/Modals First**
|
||||||
|
- Move dialog components to separate files
|
||||||
|
- Keep dialog state management in parent initially
|
||||||
|
- Later extract to custom hooks if complex
|
||||||
|
|
||||||
|
2. **Create Custom Hooks for Business Logic**
|
||||||
|
- Move data fetching to `useFetch*` hooks
|
||||||
|
- Move complex state logic to `use*State` hooks
|
||||||
|
- Move side effects to `use*Effect` hooks
|
||||||
|
|
||||||
|
3. **Split UI into Presentational Components**
|
||||||
|
- Header/toolbar components
|
||||||
|
- Content area components
|
||||||
|
- Footer/action components
|
||||||
|
|
||||||
|
4. **Move Utils and Helpers**
|
||||||
|
- Extract pure functions to utility files
|
||||||
|
- Move constants to separate constant files
|
||||||
|
- Create type files for shared interfaces
|
||||||
|
|
||||||
|
### When Refactoring Large Files:
|
||||||
|
|
||||||
|
1. **Identify Domains/Concerns**
|
||||||
|
- Group related functionality
|
||||||
|
- Find natural boundaries
|
||||||
|
|
||||||
|
2. **Extract Gradually**
|
||||||
|
- Start with least coupled code
|
||||||
|
- Work towards core functionality
|
||||||
|
- Test after each extraction
|
||||||
|
|
||||||
|
3. **Maintain Type Safety**
|
||||||
|
- Export types from extracted modules
|
||||||
|
- Use shared type files for common interfaces
|
||||||
|
- Ensure no type errors after refactoring
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progress Tracking
|
||||||
|
|
||||||
|
- [ ] board-view.tsx (3,325 lines)
|
||||||
|
- [ ] sidebar.tsx (2,396 lines)
|
||||||
|
- [ ] electron.ts (2,356 lines)
|
||||||
|
- [ ] app-store.ts (2,174 lines)
|
||||||
|
- [ ] auto-mode-service.ts (1,232 lines)
|
||||||
|
- [ ] spec-view.tsx (1,230 lines)
|
||||||
|
- [ ] kanban-card.tsx (1,180 lines)
|
||||||
|
- [ ] analysis-view.tsx (1,134 lines)
|
||||||
|
|
||||||
|
**Target:** All files under 500 lines, most under 300 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated: 2025-12-15*
|
||||||
BIN
apps/.DS_Store
vendored
BIN
apps/.DS_Store
vendored
Binary file not shown.
@@ -1,123 +0,0 @@
|
|||||||
# Automaker
|
|
||||||
|
|
||||||
Automaker is an autonomous AI development studio that helps you build software faster using AI-powered agents. It provides a visual Kanban board interface to manage features, automatically assigns AI agents to implement them, and tracks progress through an intuitive workflow from backlog to verified completion.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> **[!CAUTION]**
|
|
||||||
>
|
|
||||||
> ## Security Disclaimer
|
|
||||||
>
|
|
||||||
> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.**
|
|
||||||
>
|
|
||||||
> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it.
|
|
||||||
>
|
|
||||||
> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine.
|
|
||||||
>
|
|
||||||
> **[Read the full disclaimer](../DISCLAIMER.md)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
**Step 1:** Clone this repository:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone git@github.com:AutoMaker-Org/automaker.git
|
|
||||||
cd automaker
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2:** Install dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Windows notes (in-app Claude auth)
|
|
||||||
|
|
||||||
- Node.js 22.x
|
|
||||||
- Prebuilt PTY is bundled; Visual Studio build tools are not required for Claude auth.
|
|
||||||
- If you prefer the external terminal flow, set `CLAUDE_AUTH_DISABLE_PTY=1`.
|
|
||||||
- If you later add native modules beyond the prebuilt PTY, you may still need VS Build Tools + Python to rebuild those.
|
|
||||||
|
|
||||||
**Step 3:** Run the Claude Code setup token command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
claude setup-token
|
|
||||||
```
|
|
||||||
|
|
||||||
> **⚠️ Warning:** This command will print your token to your terminal. Be careful if you're streaming or sharing your screen, as the token will be visible to anyone watching.
|
|
||||||
|
|
||||||
**Step 4:** Export the Claude Code OAuth token in your shell:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export CLAUDE_CODE_OAUTH_TOKEN="your-token-here"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 5:** Start the development server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev:electron
|
|
||||||
```
|
|
||||||
|
|
||||||
This will start both the Next.js development server and the Electron application.
|
|
||||||
|
|
||||||
### Auth smoke test (Windows)
|
|
||||||
|
|
||||||
1. Ensure dependencies are installed (prebuilt pty is included).
|
|
||||||
2. Run `npm run dev:electron` and open the Setup modal.
|
|
||||||
3. Click Start on Claude auth; watch the embedded terminal stream logs.
|
|
||||||
4. Successful runs show “Token captured automatically.”; otherwise copy/paste the token from the log.
|
|
||||||
5. Optional: `node --test tests/claude-cli-detector.test.js` to verify token parsing.
|
|
||||||
|
|
||||||
**Step 6:** MOST IMPORTANT: Run the Following after all is setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo "W"
|
|
||||||
echo "W"
|
|
||||||
echo "W"
|
|
||||||
echo "W"
|
|
||||||
echo "W"
|
|
||||||
echo "W"
|
|
||||||
echo "W"
|
|
||||||
echo "W"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages
|
|
||||||
- 🤖 **AI Agent Integration** - Automatic AI agent assignment to implement features when moved to "In Progress"
|
|
||||||
- 🧠 **Multi-Model Support** - Choose from multiple AI models including Claude Opus, Sonnet, and more
|
|
||||||
- 💭 **Extended Thinking** - Enable extended thinking modes for complex problem-solving
|
|
||||||
- 📡 **Real-time Agent Output** - View live agent output, logs, and file diffs as features are being implemented
|
|
||||||
- 🔍 **Project Analysis** - AI-powered project structure analysis to understand your codebase
|
|
||||||
- 📁 **Context Management** - Add context files to help AI agents understand your project better
|
|
||||||
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on your project
|
|
||||||
- 🖼️ **Image Support** - Attach images and screenshots to feature descriptions
|
|
||||||
- ⚡ **Concurrent Processing** - Configure concurrency to process multiple features simultaneously
|
|
||||||
- 🧪 **Test Integration** - Automatic test running and verification for implemented features
|
|
||||||
- 🔀 **Git Integration** - View git diffs and track changes made by AI agents
|
|
||||||
- 👤 **AI Profiles** - Create and manage different AI agent profiles for various tasks
|
|
||||||
- 💬 **Chat History** - Keep track of conversations and interactions with AI agents
|
|
||||||
- ⌨️ **Keyboard Shortcuts** - Efficient navigation and actions via keyboard shortcuts
|
|
||||||
- 🎨 **Dark/Light Theme** - Beautiful UI with theme support
|
|
||||||
- 🖥️ **Cross-Platform** - Desktop application built with Electron for Windows, macOS, and Linux
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- [Next.js](https://nextjs.org) - React framework
|
|
||||||
- [Electron](https://www.electronjs.org/) - Desktop application framework
|
|
||||||
- [Tailwind CSS](https://tailwindcss.com/) - Styling
|
|
||||||
- [Zustand](https://zustand-demo.pmnd.rs/) - State management
|
|
||||||
- [dnd-kit](https://dndkit.com/) - Drag and drop functionality
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
See [LICENSE](../LICENSE) for details.
|
|
||||||
@@ -2,9 +2,6 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "export",
|
output: "export",
|
||||||
env: {
|
|
||||||
CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN || "",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
const port = process.env.TEST_PORT || 3007;
|
const port = process.env.TEST_PORT || 3007;
|
||||||
|
const serverPort = process.env.TEST_SERVER_PORT || 3008;
|
||||||
const reuseServer = process.env.TEST_REUSE_SERVER === "true";
|
const reuseServer = process.env.TEST_REUSE_SERVER === "true";
|
||||||
|
const mockAgent = process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests",
|
testDir: "./tests",
|
||||||
@@ -25,15 +27,33 @@ export default defineConfig({
|
|||||||
...(reuseServer
|
...(reuseServer
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
webServer: {
|
webServer: [
|
||||||
command: `npx next dev -p ${port}`,
|
// Backend server - runs with mock agent enabled in CI
|
||||||
url: `http://localhost:${port}`,
|
{
|
||||||
reuseExistingServer: !process.env.CI,
|
command: `cd ../server && npm run dev`,
|
||||||
timeout: 120000,
|
url: `http://localhost:${serverPort}/api/health`,
|
||||||
env: {
|
reuseExistingServer: true,
|
||||||
...process.env,
|
timeout: 60000,
|
||||||
NEXT_PUBLIC_SKIP_SETUP: "true",
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PORT: String(serverPort),
|
||||||
|
// Enable mock agent in CI to avoid real API calls
|
||||||
|
AUTOMAKER_MOCK_AGENT: mockAgent ? "true" : "false",
|
||||||
|
// Allow access to test directories and common project paths
|
||||||
|
ALLOWED_PROJECT_DIRS: "/Users,/home,/tmp,/var/folders",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
// Frontend Next.js server
|
||||||
|
{
|
||||||
|
command: `npx next dev -p ${port}`,
|
||||||
|
url: `http://localhost:${port}`,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 120000,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
NEXT_PUBLIC_SKIP_SETUP: "true",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
|
||||||
|
|
||||||
const port = process.env.TEST_PORT || 3007;
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: "./tests",
|
|
||||||
fullyParallel: true,
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
reporter: "html",
|
|
||||||
timeout: 10000,
|
|
||||||
use: {
|
|
||||||
baseURL: `http://localhost:${port}`,
|
|
||||||
trace: "on-first-retry",
|
|
||||||
screenshot: "only-on-failure",
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: "chromium",
|
|
||||||
use: { ...devices["Desktop Chrome"] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
webServer: {
|
|
||||||
command: `npx next dev -p ${port}`,
|
|
||||||
url: `http://localhost:${port}`,
|
|
||||||
reuseExistingServer: true,
|
|
||||||
timeout: 60000,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
BIN
apps/app/public/readme_logo.png
Normal file
BIN
apps/app/public/readme_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
Binary file not shown.
@@ -11,7 +11,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const { apiKey } = await request.json();
|
const { apiKey } = await request.json();
|
||||||
|
|
||||||
// Use provided API key or fall back to environment variable
|
// Use provided API key or fall back to environment variable
|
||||||
const effectiveApiKey = apiKey || process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
const effectiveApiKey = apiKey || process.env.ANTHROPIC_API_KEY;
|
||||||
|
|
||||||
if (!effectiveApiKey) {
|
if (!effectiveApiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,24 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { GeistSans } from "geist/font/sans";
|
import { GeistSans } from "geist/font/sans";
|
||||||
import { GeistMono } from "geist/font/mono";
|
import { GeistMono } from "geist/font/mono";
|
||||||
|
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
|
// Inter font for clean theme
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-inter",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
// JetBrains Mono for clean theme
|
||||||
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-jetbrains-mono",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Automaker - Autonomous AI Development Studio",
|
title: "Automaker - Autonomous AI Development Studio",
|
||||||
description: "Build software autonomously with intelligent orchestration",
|
description: "Build software autonomously with intelligent orchestration",
|
||||||
@@ -16,7 +32,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${GeistSans.variable} ${GeistMono.variable} antialiased`}
|
className={`${GeistSans.variable} ${GeistMono.variable} ${inter.variable} ${jetbrainsMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<Toaster richColors position="bottom-right" />
|
<Toaster richColors position="bottom-right" />
|
||||||
|
|||||||
@@ -133,10 +133,10 @@ function HomeContent() {
|
|||||||
// Apply theme class to document (uses effective theme - preview, project-specific, or global)
|
// Apply theme class to document (uses effective theme - preview, project-specific, or global)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.remove(
|
const themeClasses = [
|
||||||
"dark",
|
"dark",
|
||||||
"retro",
|
|
||||||
"light",
|
"light",
|
||||||
|
"retro",
|
||||||
"dracula",
|
"dracula",
|
||||||
"nord",
|
"nord",
|
||||||
"monokai",
|
"monokai",
|
||||||
@@ -146,43 +146,23 @@ function HomeContent() {
|
|||||||
"catppuccin",
|
"catppuccin",
|
||||||
"onedark",
|
"onedark",
|
||||||
"synthwave",
|
"synthwave",
|
||||||
"red"
|
"red",
|
||||||
);
|
"cream",
|
||||||
|
"sunset",
|
||||||
|
"gray",
|
||||||
|
"clean",
|
||||||
|
];
|
||||||
|
|
||||||
if (effectiveTheme === "dark") {
|
// Remove all theme classes
|
||||||
root.classList.add("dark");
|
root.classList.remove(...themeClasses);
|
||||||
} else if (effectiveTheme === "retro") {
|
|
||||||
root.classList.add("retro");
|
// Apply the effective theme
|
||||||
} else if (effectiveTheme === "dracula") {
|
if (themeClasses.includes(effectiveTheme)) {
|
||||||
root.classList.add("dracula");
|
root.classList.add(effectiveTheme);
|
||||||
} else if (effectiveTheme === "nord") {
|
|
||||||
root.classList.add("nord");
|
|
||||||
} else if (effectiveTheme === "monokai") {
|
|
||||||
root.classList.add("monokai");
|
|
||||||
} else if (effectiveTheme === "tokyonight") {
|
|
||||||
root.classList.add("tokyonight");
|
|
||||||
} else if (effectiveTheme === "solarized") {
|
|
||||||
root.classList.add("solarized");
|
|
||||||
} else if (effectiveTheme === "gruvbox") {
|
|
||||||
root.classList.add("gruvbox");
|
|
||||||
} else if (effectiveTheme === "catppuccin") {
|
|
||||||
root.classList.add("catppuccin");
|
|
||||||
} else if (effectiveTheme === "onedark") {
|
|
||||||
root.classList.add("onedark");
|
|
||||||
} else if (effectiveTheme === "synthwave") {
|
|
||||||
root.classList.add("synthwave");
|
|
||||||
} else if (effectiveTheme === "red") {
|
|
||||||
root.classList.add("red");
|
|
||||||
} else if (effectiveTheme === "light") {
|
|
||||||
root.classList.add("light");
|
|
||||||
} else if (effectiveTheme === "system") {
|
} else if (effectiveTheme === "system") {
|
||||||
// System theme
|
// System theme - detect OS preference
|
||||||
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
if (isDark) {
|
root.classList.add(isDark ? "dark" : "light");
|
||||||
root.classList.add("dark");
|
|
||||||
} else {
|
|
||||||
root.classList.add("light");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [effectiveTheme, previewTheme, currentProject, theme]);
|
}, [effectiveTheme, previewTheme, currentProject, theme]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface DeleteAllArchivedSessionsDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
archivedCount: number;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteAllArchivedSessionsDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
archivedCount,
|
||||||
|
onConfirm,
|
||||||
|
}: DeleteAllArchivedSessionsDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent data-testid="delete-all-archived-sessions-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete All Archived Sessions</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete all archived sessions? This action
|
||||||
|
cannot be undone.
|
||||||
|
{archivedCount > 0 && (
|
||||||
|
<span className="block mt-2 text-yellow-500">
|
||||||
|
{archivedCount} session(s) will be deleted.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={onConfirm}
|
||||||
|
data-testid="confirm-delete-all-archived-sessions"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete All
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
CornerDownLeft,
|
CornerDownLeft,
|
||||||
|
Clock,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -45,6 +47,44 @@ interface FileBrowserDialogProps {
|
|||||||
initialPath?: string;
|
initialPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RECENT_FOLDERS_KEY = "file-browser-recent-folders";
|
||||||
|
const MAX_RECENT_FOLDERS = 5;
|
||||||
|
|
||||||
|
function getRecentFolders(): string[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(RECENT_FOLDERS_KEY);
|
||||||
|
return stored ? JSON.parse(stored) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRecentFolder(path: string): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
const recent = getRecentFolders();
|
||||||
|
// Remove if already exists, then add to front
|
||||||
|
const filtered = recent.filter((p) => p !== path);
|
||||||
|
const updated = [path, ...filtered].slice(0, MAX_RECENT_FOLDERS);
|
||||||
|
localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(updated));
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRecentFolder(path: string): string[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const recent = getRecentFolders();
|
||||||
|
const updated = recent.filter((p) => p !== path);
|
||||||
|
localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(updated));
|
||||||
|
return updated;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function FileBrowserDialog({
|
export function FileBrowserDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -61,8 +101,26 @@ export function FileBrowserDialog({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [warning, setWarning] = useState("");
|
const [warning, setWarning] = useState("");
|
||||||
|
const [recentFolders, setRecentFolders] = useState<string[]>([]);
|
||||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Load recent folders when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setRecentFolders(getRecentFolders());
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleRemoveRecent = useCallback((e: React.MouseEvent, path: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const updated = removeRecentFolder(path);
|
||||||
|
setRecentFolders(updated);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectRecent = useCallback((path: string) => {
|
||||||
|
browseDirectory(path);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const browseDirectory = async (dirPath?: string) => {
|
const browseDirectory = async (dirPath?: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -153,27 +211,34 @@ export function FileBrowserDialog({
|
|||||||
|
|
||||||
const handleSelect = () => {
|
const handleSelect = () => {
|
||||||
if (currentPath) {
|
if (currentPath) {
|
||||||
|
addRecentFolder(currentPath);
|
||||||
onSelect(currentPath);
|
onSelect(currentPath);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to get folder name from path
|
||||||
|
const getFolderName = (path: string) => {
|
||||||
|
const parts = path.split(/[/\\]/).filter(Boolean);
|
||||||
|
return parts[parts.length - 1] || path;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
<DialogContent className="bg-popover border-border max-w-3xl max-h-[85vh] overflow-hidden flex flex-col p-4">
|
||||||
<DialogHeader className="pb-2">
|
<DialogHeader className="pb-1">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2 text-base">
|
||||||
<FolderOpen className="w-5 h-5 text-brand-500" />
|
<FolderOpen className="w-4 h-4 text-brand-500" />
|
||||||
{title}
|
{title}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-muted-foreground">
|
<DialogDescription className="text-muted-foreground text-xs">
|
||||||
{description}
|
{description}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 min-h-[400px] flex-1 overflow-hidden py-2">
|
<div className="flex flex-col gap-2 min-h-[350px] flex-1 overflow-hidden py-1">
|
||||||
{/* Direct path input */}
|
{/* Direct path input */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5">
|
||||||
<Input
|
<Input
|
||||||
ref={pathInputRef}
|
ref={pathInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -181,7 +246,7 @@ export function FileBrowserDialog({
|
|||||||
value={pathInput}
|
value={pathInput}
|
||||||
onChange={(e) => setPathInput(e.target.value)}
|
onChange={(e) => setPathInput(e.target.value)}
|
||||||
onKeyDown={handlePathInputKeyDown}
|
onKeyDown={handlePathInputKeyDown}
|
||||||
className="flex-1 font-mono text-sm"
|
className="flex-1 font-mono text-xs h-8"
|
||||||
data-testid="path-input"
|
data-testid="path-input"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
@@ -191,16 +256,46 @@ export function FileBrowserDialog({
|
|||||||
onClick={handleGoToPath}
|
onClick={handleGoToPath}
|
||||||
disabled={loading || !pathInput.trim()}
|
disabled={loading || !pathInput.trim()}
|
||||||
data-testid="go-to-path-button"
|
data-testid="go-to-path-button"
|
||||||
|
className="h-8 px-2"
|
||||||
>
|
>
|
||||||
<CornerDownLeft className="w-4 h-4 mr-1" />
|
<CornerDownLeft className="w-3.5 h-3.5 mr-1" />
|
||||||
Go
|
Go
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Recent folders */}
|
||||||
|
{recentFolders.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span>Recent:</span>
|
||||||
|
</div>
|
||||||
|
{recentFolders.map((folder) => (
|
||||||
|
<button
|
||||||
|
key={folder}
|
||||||
|
onClick={() => handleSelectRecent(folder)}
|
||||||
|
className="group flex items-center gap-1 h-6 px-2 text-xs bg-sidebar-accent/20 hover:bg-sidebar-accent/40 rounded border border-sidebar-border transition-colors"
|
||||||
|
disabled={loading}
|
||||||
|
title={folder}
|
||||||
|
>
|
||||||
|
<Folder className="w-3 h-3 text-brand-500 shrink-0" />
|
||||||
|
<span className="truncate max-w-[120px]">{getFolderName(folder)}</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleRemoveRecent(e, folder)}
|
||||||
|
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
|
||||||
|
title="Remove from recent"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Drives selector (Windows only) */}
|
{/* Drives selector (Windows only) */}
|
||||||
{drives.length > 0 && (
|
{drives.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
<div className="flex flex-wrap gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-2">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-1">
|
||||||
<HardDrive className="w-3 h-3" />
|
<HardDrive className="w-3 h-3" />
|
||||||
<span>Drives:</span>
|
<span>Drives:</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,7 +307,7 @@ export function FileBrowserDialog({
|
|||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleSelectDrive(drive)}
|
onClick={() => handleSelectDrive(drive)}
|
||||||
className="h-7 px-3 text-xs"
|
className="h-6 px-2 text-xs"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{drive.replace("\\", "")}
|
{drive.replace("\\", "")}
|
||||||
@@ -222,57 +317,57 @@ export function FileBrowserDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Current path breadcrumb */}
|
{/* Current path breadcrumb */}
|
||||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
<div className="flex items-center gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleGoHome}
|
onClick={handleGoHome}
|
||||||
className="h-7 px-2"
|
className="h-6 px-1.5"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<Home className="w-4 h-4" />
|
<Home className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
{parentPath && (
|
{parentPath && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleGoToParent}
|
onClick={handleGoToParent}
|
||||||
className="h-7 px-2"
|
className="h-6 px-1.5"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 font-mono text-sm truncate text-muted-foreground">
|
<div className="flex-1 font-mono text-xs truncate text-muted-foreground">
|
||||||
{currentPath || "Loading..."}
|
{currentPath || "Loading..."}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Directory list */}
|
{/* Directory list */}
|
||||||
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-lg">
|
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center h-full p-8">
|
<div className="flex items-center justify-center h-full p-4">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Loading directories...
|
Loading directories...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center justify-center h-full p-8">
|
<div className="flex items-center justify-center h-full p-4">
|
||||||
<div className="text-sm text-destructive">{error}</div>
|
<div className="text-xs text-destructive">{error}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{warning && (
|
{warning && (
|
||||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg mb-2">
|
<div className="p-2 bg-yellow-500/10 border border-yellow-500/30 rounded-md mb-1">
|
||||||
<div className="text-sm text-yellow-500">{warning}</div>
|
<div className="text-xs text-yellow-500">{warning}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && !warning && directories.length === 0 && (
|
{!loading && !error && !warning && directories.length === 0 && (
|
||||||
<div className="flex items-center justify-center h-full p-8">
|
<div className="flex items-center justify-center h-full p-4">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
No subdirectories found
|
No subdirectories found
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,29 +379,29 @@ export function FileBrowserDialog({
|
|||||||
<button
|
<button
|
||||||
key={dir.path}
|
key={dir.path}
|
||||||
onClick={() => handleSelectDirectory(dir)}
|
onClick={() => handleSelectDirectory(dir)}
|
||||||
className="w-full flex items-center gap-3 p-3 hover:bg-sidebar-accent/10 transition-colors text-left group"
|
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-sidebar-accent/10 transition-colors text-left group"
|
||||||
>
|
>
|
||||||
<Folder className="w-5 h-5 text-brand-500 shrink-0" />
|
<Folder className="w-4 h-4 text-brand-500 shrink-0" />
|
||||||
<span className="flex-1 truncate text-sm">{dir.name}</span>
|
<span className="flex-1 truncate text-xs">{dir.name}</span>
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-[10px] text-muted-foreground">
|
||||||
Paste a full path above, or click on folders to navigate. Press
|
Paste a full path above, or click on folders to navigate. Press
|
||||||
Enter or click Go to jump to a path.
|
Enter or click Go to jump to a path.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="border-t border-border pt-4 gap-2">
|
<DialogFooter className="border-t border-border pt-3 gap-2 mt-1">
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSelect} disabled={!currentPath || loading}>
|
<Button size="sm" onClick={handleSelect} disabled={!currentPath || loading}>
|
||||||
<FolderOpen className="w-4 h-4 mr-2" />
|
<FolderOpen className="w-3.5 h-3.5 mr-1.5" />
|
||||||
Select Current Folder
|
Select Current Folder
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -195,6 +195,33 @@ const PROJECT_THEME_OPTIONS = [
|
|||||||
})),
|
})),
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// Reusable Bug Report Button Component
|
||||||
|
const BugReportButton = ({
|
||||||
|
sidebarExpanded,
|
||||||
|
onClick
|
||||||
|
}: {
|
||||||
|
sidebarExpanded: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"titlebar-no-drag px-3 py-2.5 rounded-xl",
|
||||||
|
"text-muted-foreground hover:text-foreground hover:bg-accent/80",
|
||||||
|
"border border-transparent hover:border-border/40",
|
||||||
|
"transition-all duration-200 ease-out",
|
||||||
|
"hover:scale-[1.02] active:scale-[0.97]",
|
||||||
|
sidebarExpanded && "absolute right-3"
|
||||||
|
)}
|
||||||
|
title="Report Bug / Feature Request"
|
||||||
|
data-testid={sidebarExpanded ? "bug-report-link" : "bug-report-link-collapsed"}
|
||||||
|
>
|
||||||
|
<Bug className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const {
|
const {
|
||||||
projects,
|
projects,
|
||||||
@@ -836,6 +863,12 @@ export function Sidebar() {
|
|||||||
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
|
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle bug report button click
|
||||||
|
const handleBugReportClick = useCallback(() => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
api.openExternalLink("https://github.com/AutoMaker-Org/automaker/issues");
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the system folder selection dialog and initializes the selected project.
|
* Opens the system folder selection dialog and initializes the selected project.
|
||||||
* Used by both the 'O' keyboard shortcut and the folder icon button.
|
* Used by both the 'O' keyboard shortcut and the folder icon button.
|
||||||
@@ -1184,6 +1217,8 @@ export function Sidebar() {
|
|||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-shrink-0 flex flex-col z-30 relative",
|
"flex-shrink-0 flex flex-col z-30 relative",
|
||||||
|
// Clean theme sidebar-glass class
|
||||||
|
"sidebar-glass",
|
||||||
// Glass morphism background with gradient
|
// Glass morphism background with gradient
|
||||||
"bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl",
|
"bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl",
|
||||||
// Premium border with subtle glow
|
// Premium border with subtle glow
|
||||||
@@ -1394,30 +1429,20 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Bug Report Button */}
|
{/* Bug Report Button - Inside logo container when expanded */}
|
||||||
<button
|
{sidebarOpen && <BugReportButton sidebarExpanded onClick={handleBugReportClick} />}
|
||||||
onClick={() => {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
api.openExternalLink(
|
|
||||||
"https://github.com/AutoMaker-Org/automaker/issues"
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"titlebar-no-drag p-1.5 rounded-lg absolute right-3",
|
|
||||||
"text-muted-foreground hover:text-foreground hover:bg-accent/80",
|
|
||||||
"transition-all duration-200 ease-out",
|
|
||||||
"hover:scale-105 active:scale-95"
|
|
||||||
)}
|
|
||||||
title="Report Bug / Feature Request"
|
|
||||||
data-testid="bug-report-link"
|
|
||||||
>
|
|
||||||
<Bug className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bug Report Button - Collapsed sidebar version */}
|
||||||
|
{!sidebarOpen && (
|
||||||
|
<div className="px-3 mt-1.5 flex justify-center">
|
||||||
|
<BugReportButton sidebarExpanded={false} onClick={handleBugReportClick} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Project Actions - Moved above project selector */}
|
{/* Project Actions - Moved above project selector */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div className="flex items-center gap-2.5 titlebar-no-drag px-3 mt-4">
|
<div className="flex items-center gap-2.5 titlebar-no-drag px-3 mt-5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNewProjectModal(true)}
|
onClick={() => setShowNewProjectModal(true)}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -1789,7 +1814,7 @@ export function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Nav Items - Scrollable */}
|
{/* Nav Items - Scrollable */}
|
||||||
<nav className="flex-1 overflow-y-auto px-3 mt-5 pb-2">
|
<nav className={cn("flex-1 overflow-y-auto px-3 pb-2", sidebarOpen ? "mt-5" : "mt1")}>
|
||||||
{!currentProject && sidebarOpen ? (
|
{!currentProject && sidebarOpen ? (
|
||||||
// Placeholder when no project is selected (only in expanded state)
|
// Placeholder when no project is selected (only in expanded state)
|
||||||
<div className="flex items-center justify-center h-full px-4">
|
<div className="flex items-center justify-center h-full px-4">
|
||||||
@@ -1802,7 +1827,7 @@ export function Sidebar() {
|
|||||||
) : currentProject ? (
|
) : currentProject ? (
|
||||||
// Navigation sections when project is selected
|
// Navigation sections when project is selected
|
||||||
navSections.map((section, sectionIdx) => (
|
navSections.map((section, sectionIdx) => (
|
||||||
<div key={sectionIdx} className={sectionIdx > 0 ? "mt-6" : ""}>
|
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? "mt-6" : ""}>
|
||||||
{/* Section Label */}
|
{/* Section Label */}
|
||||||
{section.label && sidebarOpen && (
|
{section.label && sidebarOpen && (
|
||||||
<div className="hidden lg:block px-3 mb-2">
|
<div className="hidden lg:block px-3 mb-2">
|
||||||
@@ -1812,7 +1837,7 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{section.label && !sidebarOpen && (
|
{section.label && !sidebarOpen && (
|
||||||
<div className="h-px bg-border/30 mx-2 mb-3"></div>
|
<div className="h-px bg-border/30 mx-2 my-1.5"></div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Nav Items */}
|
{/* Nav Items */}
|
||||||
@@ -1831,6 +1856,8 @@ export function Sidebar() {
|
|||||||
isActive
|
isActive
|
||||||
? [
|
? [
|
||||||
// Active: Premium gradient with glow
|
// Active: Premium gradient with glow
|
||||||
|
// Clean theme nav-active class
|
||||||
|
"nav-active",
|
||||||
"bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10",
|
"bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10",
|
||||||
"text-foreground font-medium",
|
"text-foreground font-medium",
|
||||||
"border border-brand-500/30",
|
"border border-brand-500/30",
|
||||||
@@ -1871,6 +1898,8 @@ export function Sidebar() {
|
|||||||
{item.shortcut && sidebarOpen && (
|
{item.shortcut && sidebarOpen && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
// Clean theme shortcut-badge class
|
||||||
|
"shortcut-badge",
|
||||||
"hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200",
|
"hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200",
|
||||||
isActive
|
isActive
|
||||||
? "bg-brand-500/20 text-brand-400"
|
? "bg-brand-500/20 text-brand-400"
|
||||||
@@ -1896,7 +1925,7 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
{item.shortcut && (
|
{item.shortcut && (
|
||||||
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
<span className="shortcut-badge ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
||||||
{formatShortcut(item.shortcut, true)}
|
{formatShortcut(item.shortcut, true)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -2030,6 +2059,8 @@ export function Sidebar() {
|
|||||||
{!sidebarOpen && runningAgentsCount > 0 && (
|
{!sidebarOpen && runningAgentsCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
// Clean theme running-agents-badge class
|
||||||
|
"running-agents-badge",
|
||||||
"absolute -top-1.5 -right-1.5 flex items-center justify-center",
|
"absolute -top-1.5 -right-1.5 flex items-center justify-center",
|
||||||
"min-w-4 h-4 px-1 text-[9px] font-bold rounded-full",
|
"min-w-4 h-4 px-1 text-[9px] font-bold rounded-full",
|
||||||
"bg-brand-500 text-white shadow-sm",
|
"bg-brand-500 text-white shadow-sm",
|
||||||
@@ -2053,6 +2084,8 @@ export function Sidebar() {
|
|||||||
{sidebarOpen && runningAgentsCount > 0 && (
|
{sidebarOpen && runningAgentsCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
// Clean theme running-agents-badge class
|
||||||
|
"running-agents-badge",
|
||||||
"hidden lg:flex items-center justify-center",
|
"hidden lg:flex items-center justify-center",
|
||||||
"min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full",
|
"min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full",
|
||||||
"bg-brand-500 text-white shadow-sm",
|
"bg-brand-500 text-white shadow-sm",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type { SessionListItem } from "@/types/electron";
|
|||||||
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
import { DeleteSessionDialog } from "@/components/delete-session-dialog";
|
import { DeleteSessionDialog } from "@/components/delete-session-dialog";
|
||||||
|
import { DeleteAllArchivedSessionsDialog } from "@/components/delete-all-archived-sessions-dialog";
|
||||||
|
|
||||||
// Random session name generator
|
// Random session name generator
|
||||||
const adjectives = [
|
const adjectives = [
|
||||||
@@ -116,6 +117,7 @@ export function SessionManager({
|
|||||||
);
|
);
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
|
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
|
||||||
|
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false);
|
||||||
|
|
||||||
// Check running state for all sessions
|
// Check running state for all sessions
|
||||||
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
||||||
@@ -314,6 +316,20 @@ export function SessionManager({
|
|||||||
setSessionToDelete(null);
|
setSessionToDelete(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Delete all archived sessions
|
||||||
|
const handleDeleteAllArchivedSessions = async () => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.sessions) return;
|
||||||
|
|
||||||
|
// Delete each archived session
|
||||||
|
for (const session of archivedSessions) {
|
||||||
|
await api.sessions.delete(session.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadSessions();
|
||||||
|
setIsDeleteAllArchivedDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const activeSessions = sessions.filter((s) => !s.isArchived);
|
const activeSessions = sessions.filter((s) => !s.isArchived);
|
||||||
const archivedSessions = sessions.filter((s) => s.isArchived);
|
const archivedSessions = sessions.filter((s) => s.isArchived);
|
||||||
const displayedSessions =
|
const displayedSessions =
|
||||||
@@ -402,6 +418,22 @@ export function SessionManager({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete All Archived button - shown at the top of archived sessions */}
|
||||||
|
{activeTab === "archived" && archivedSessions.length > 0 && (
|
||||||
|
<div className="pb-2 border-b mb-2">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setIsDeleteAllArchivedDialogOpen(true)}
|
||||||
|
data-testid="delete-all-archived-sessions-button"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete All Archived Sessions
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Session list */}
|
{/* Session list */}
|
||||||
{displayedSessions.map((session) => (
|
{displayedSessions.map((session) => (
|
||||||
<div
|
<div
|
||||||
@@ -574,6 +606,14 @@ export function SessionManager({
|
|||||||
session={sessionToDelete}
|
session={sessionToDelete}
|
||||||
onConfirm={confirmDeleteSession}
|
onConfirm={confirmDeleteSession}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Delete All Archived Sessions Confirmation Dialog */}
|
||||||
|
<DeleteAllArchivedSessionsDialog
|
||||||
|
open={isDeleteAllArchivedDialogOpen}
|
||||||
|
onOpenChange={setIsDeleteAllArchivedDialogOpen}
|
||||||
|
archivedCount={archivedSessions.length}
|
||||||
|
onConfirm={handleDeleteAllArchivedSessions}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
243
apps/app/src/components/ui/accordion.tsx
Normal file
243
apps/app/src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type AccordionType = "single" | "multiple";
|
||||||
|
|
||||||
|
interface AccordionContextValue {
|
||||||
|
type: AccordionType;
|
||||||
|
value: string | string[];
|
||||||
|
onValueChange: (value: string) => void;
|
||||||
|
collapsible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccordionContext = React.createContext<AccordionContextValue | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
type?: "single" | "multiple";
|
||||||
|
value?: string | string[];
|
||||||
|
defaultValue?: string | string[];
|
||||||
|
onValueChange?: (value: string | string[]) => void;
|
||||||
|
collapsible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
type = "single",
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
onValueChange,
|
||||||
|
collapsible = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [internalValue, setInternalValue] = React.useState<string | string[]>(
|
||||||
|
() => {
|
||||||
|
if (value !== undefined) return value;
|
||||||
|
if (defaultValue !== undefined) return defaultValue;
|
||||||
|
return type === "single" ? "" : [];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentValue = value !== undefined ? value : internalValue;
|
||||||
|
|
||||||
|
const handleValueChange = React.useCallback(
|
||||||
|
(itemValue: string) => {
|
||||||
|
let newValue: string | string[];
|
||||||
|
|
||||||
|
if (type === "single") {
|
||||||
|
if (currentValue === itemValue && collapsible) {
|
||||||
|
newValue = "";
|
||||||
|
} else if (currentValue === itemValue && !collapsible) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
newValue = itemValue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const currentArray = Array.isArray(currentValue)
|
||||||
|
? currentValue
|
||||||
|
: [currentValue].filter(Boolean);
|
||||||
|
if (currentArray.includes(itemValue)) {
|
||||||
|
newValue = currentArray.filter((v) => v !== itemValue);
|
||||||
|
} else {
|
||||||
|
newValue = [...currentArray, itemValue];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === undefined) {
|
||||||
|
setInternalValue(newValue);
|
||||||
|
}
|
||||||
|
onValueChange?.(newValue);
|
||||||
|
},
|
||||||
|
[type, currentValue, collapsible, value, onValueChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextValue = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
type,
|
||||||
|
value: currentValue,
|
||||||
|
onValueChange: handleValueChange,
|
||||||
|
collapsible,
|
||||||
|
}),
|
||||||
|
[type, currentValue, handleValueChange, collapsible]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccordionContext.Provider value={contextValue}>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-slot="accordion"
|
||||||
|
className={cn("w-full", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AccordionContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Accordion.displayName = "Accordion";
|
||||||
|
|
||||||
|
interface AccordionItemContextValue {
|
||||||
|
value: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccordionItemContext =
|
||||||
|
React.createContext<AccordionItemContextValue | null>(null);
|
||||||
|
|
||||||
|
interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
|
||||||
|
({ className, value, children, ...props }, ref) => {
|
||||||
|
const accordionContext = React.useContext(AccordionContext);
|
||||||
|
|
||||||
|
if (!accordionContext) {
|
||||||
|
throw new Error("AccordionItem must be used within an Accordion");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOpen = Array.isArray(accordionContext.value)
|
||||||
|
? accordionContext.value.includes(value)
|
||||||
|
: accordionContext.value === value;
|
||||||
|
|
||||||
|
const contextValue = React.useMemo(
|
||||||
|
() => ({ value, isOpen }),
|
||||||
|
[value, isOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccordionItemContext.Provider value={contextValue}>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-slot="accordion-item"
|
||||||
|
data-state={isOpen ? "open" : "closed"}
|
||||||
|
className={cn("border-b border-border", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AccordionItemContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
AccordionItem.displayName = "AccordionItem";
|
||||||
|
|
||||||
|
interface AccordionTriggerProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
||||||
|
|
||||||
|
const AccordionTrigger = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
AccordionTriggerProps
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const accordionContext = React.useContext(AccordionContext);
|
||||||
|
const itemContext = React.useContext(AccordionItemContext);
|
||||||
|
|
||||||
|
if (!accordionContext || !itemContext) {
|
||||||
|
throw new Error("AccordionTrigger must be used within an AccordionItem");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onValueChange } = accordionContext;
|
||||||
|
const { value, isOpen } = itemContext;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-slot="accordion-header" className="flex">
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
data-state={isOpen ? "open" : "closed"}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
onClick={() => onValueChange(value)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
AccordionTrigger.displayName = "AccordionTrigger";
|
||||||
|
|
||||||
|
interface AccordionContentProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
|
const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>(
|
||||||
|
({ className, children, ...props }, ref) => {
|
||||||
|
const itemContext = React.useContext(AccordionItemContext);
|
||||||
|
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const [height, setHeight] = React.useState<number | undefined>(undefined);
|
||||||
|
|
||||||
|
if (!itemContext) {
|
||||||
|
throw new Error("AccordionContent must be used within an AccordionItem");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isOpen } = itemContext;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (contentRef.current) {
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
setHeight(entry.contentRect.height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(contentRef.current);
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="accordion-content"
|
||||||
|
data-state={isOpen ? "open" : "closed"}
|
||||||
|
className="overflow-hidden text-sm transition-all duration-200 ease-out"
|
||||||
|
style={{
|
||||||
|
height: isOpen ? (height !== undefined ? `${height}px` : "auto") : 0,
|
||||||
|
opacity: isOpen ? 1 : 0,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div ref={contentRef}>
|
||||||
|
<div ref={ref} className={cn("pb-4 pt-0", className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
AccordionContent.displayName = "AccordionContent";
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
223
apps/app/src/components/ui/autocomplete.tsx
Normal file
223
apps/app/src/components/ui/autocomplete.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Check, ChevronsUpDown, LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
|
||||||
|
export interface AutocompleteOption {
|
||||||
|
value: string;
|
||||||
|
label?: string;
|
||||||
|
badge?: string;
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutocompleteProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options: (string | AutocompleteOption)[];
|
||||||
|
placeholder?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
allowCreate?: boolean;
|
||||||
|
createLabel?: (value: string) => string;
|
||||||
|
"data-testid"?: string;
|
||||||
|
itemTestIdPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOption(opt: string | AutocompleteOption): AutocompleteOption {
|
||||||
|
if (typeof opt === "string") {
|
||||||
|
return { value: opt, label: opt };
|
||||||
|
}
|
||||||
|
return { ...opt, label: opt.label ?? opt.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Autocomplete({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder = "Select an option...",
|
||||||
|
searchPlaceholder = "Search...",
|
||||||
|
emptyMessage = "No results found.",
|
||||||
|
className,
|
||||||
|
disabled = false,
|
||||||
|
icon: Icon,
|
||||||
|
allowCreate = false,
|
||||||
|
createLabel = (v) => `Create "${v}"`,
|
||||||
|
"data-testid": testId,
|
||||||
|
itemTestIdPrefix = "option",
|
||||||
|
}: AutocompleteProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [inputValue, setInputValue] = React.useState("");
|
||||||
|
const [triggerWidth, setTriggerWidth] = React.useState<number>(0);
|
||||||
|
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const normalizedOptions = React.useMemo(
|
||||||
|
() => options.map(normalizeOption),
|
||||||
|
[options]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update trigger width when component mounts or value changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (triggerRef.current) {
|
||||||
|
const updateWidth = () => {
|
||||||
|
setTriggerWidth(triggerRef.current?.offsetWidth || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateWidth();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(updateWidth);
|
||||||
|
resizeObserver.observe(triggerRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// Filter options based on input
|
||||||
|
const filteredOptions = React.useMemo(() => {
|
||||||
|
if (!inputValue) return normalizedOptions;
|
||||||
|
const lower = inputValue.toLowerCase();
|
||||||
|
return normalizedOptions.filter(
|
||||||
|
(opt) =>
|
||||||
|
opt.value.toLowerCase().includes(lower) ||
|
||||||
|
opt.label?.toLowerCase().includes(lower)
|
||||||
|
);
|
||||||
|
}, [normalizedOptions, inputValue]);
|
||||||
|
|
||||||
|
// Check if user typed a new value that doesn't exist
|
||||||
|
const isNewValue =
|
||||||
|
allowCreate &&
|
||||||
|
inputValue.trim() &&
|
||||||
|
!normalizedOptions.some(
|
||||||
|
(opt) => opt.value.toLowerCase() === inputValue.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get display value
|
||||||
|
const displayValue = React.useMemo(() => {
|
||||||
|
if (!value) return null;
|
||||||
|
const found = normalizedOptions.find((opt) => opt.value === value);
|
||||||
|
return found?.label ?? value;
|
||||||
|
}, [value, normalizedOptions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-between",
|
||||||
|
Icon && "font-mono text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 truncate">
|
||||||
|
{Icon && (
|
||||||
|
<Icon className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{displayValue || placeholder}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="opacity-50 shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{
|
||||||
|
width: Math.max(triggerWidth, 200),
|
||||||
|
}}
|
||||||
|
data-testid={testId ? `${testId}-list` : undefined}
|
||||||
|
>
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className="h-9"
|
||||||
|
value={inputValue}
|
||||||
|
onValueChange={setInputValue}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
{isNewValue ? (
|
||||||
|
<div className="py-2 px-3 text-sm">
|
||||||
|
Press enter to create{" "}
|
||||||
|
<code className="bg-muted px-1 rounded">{inputValue}</code>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
emptyMessage
|
||||||
|
)}
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{/* Show "Create new" option if typing a new value */}
|
||||||
|
{isNewValue && (
|
||||||
|
<CommandItem
|
||||||
|
value={inputValue}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(inputValue);
|
||||||
|
setInputValue("");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-[var(--status-success)]"
|
||||||
|
data-testid={`${itemTestIdPrefix}-create-new`}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className="w-4 h-4 mr-2" />}
|
||||||
|
{createLabel(inputValue)}
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
(new)
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
)}
|
||||||
|
{filteredOptions.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
onChange(currentValue === value ? "" : currentValue);
|
||||||
|
setInputValue("");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
data-testid={`${itemTestIdPrefix}-${option.value.toLowerCase().replace(/[\s/\\]+/g, "-")}`}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className="w-4 h-4 mr-2" />}
|
||||||
|
{option.label}
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"ml-auto",
|
||||||
|
value === option.value ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{option.badge && (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
({option.badge})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
apps/app/src/components/ui/branch-autocomplete.tsx
Normal file
53
apps/app/src/components/ui/branch-autocomplete.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { GitBranch } from "lucide-react";
|
||||||
|
import { Autocomplete, AutocompleteOption } from "@/components/ui/autocomplete";
|
||||||
|
|
||||||
|
interface BranchAutocompleteProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
branches: string[];
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
"data-testid"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BranchAutocomplete({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
branches,
|
||||||
|
placeholder = "Select a branch...",
|
||||||
|
className,
|
||||||
|
disabled = false,
|
||||||
|
"data-testid": testId,
|
||||||
|
}: BranchAutocompleteProps) {
|
||||||
|
// Always include "main" at the top of suggestions
|
||||||
|
const branchOptions: AutocompleteOption[] = React.useMemo(() => {
|
||||||
|
const branchSet = new Set(["main", ...branches]);
|
||||||
|
return Array.from(branchSet).map((branch) => ({
|
||||||
|
value: branch,
|
||||||
|
label: branch,
|
||||||
|
badge: branch === "main" ? "default" : undefined,
|
||||||
|
}));
|
||||||
|
}, [branches]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={branchOptions}
|
||||||
|
placeholder={placeholder}
|
||||||
|
searchPlaceholder="Search or type new branch..."
|
||||||
|
emptyMessage="No branches found."
|
||||||
|
className={className}
|
||||||
|
disabled={disabled}
|
||||||
|
icon={GitBranch}
|
||||||
|
allowCreate
|
||||||
|
createLabel={(v) => `Create "${v}"`}
|
||||||
|
data-testid={testId}
|
||||||
|
itemTestIdPrefix="branch-option"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Check, ChevronsUpDown } from "lucide-react";
|
import { Autocomplete } from "@/components/ui/autocomplete";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from "@/components/ui/command";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
|
|
||||||
interface CategoryAutocompleteProps {
|
interface CategoryAutocompleteProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -38,54 +22,18 @@ export function CategoryAutocomplete({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
"data-testid": testId,
|
"data-testid": testId,
|
||||||
}: CategoryAutocompleteProps) {
|
}: CategoryAutocompleteProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Autocomplete
|
||||||
<PopoverTrigger asChild>
|
value={value}
|
||||||
<Button
|
onChange={onChange}
|
||||||
variant="outline"
|
options={suggestions}
|
||||||
role="combobox"
|
placeholder={placeholder}
|
||||||
aria-expanded={open}
|
searchPlaceholder="Search category..."
|
||||||
disabled={disabled}
|
emptyMessage="No category found."
|
||||||
className={cn("w-full justify-between", className)}
|
className={className}
|
||||||
data-testid={testId}
|
disabled={disabled}
|
||||||
>
|
data-testid={testId}
|
||||||
{value
|
itemTestIdPrefix="category-option"
|
||||||
? suggestions.find((s) => s === value) ?? value
|
/>
|
||||||
: placeholder}
|
|
||||||
<ChevronsUpDown className="opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[200px] p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Search category..." className="h-9" />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No category found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{suggestions.map((suggestion) => (
|
|
||||||
<CommandItem
|
|
||||||
key={suggestion}
|
|
||||||
value={suggestion}
|
|
||||||
onSelect={(currentValue) => {
|
|
||||||
onChange(currentValue === value ? "" : currentValue);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
|
|
||||||
>
|
|
||||||
{suggestion}
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"ml-auto",
|
|
||||||
value === suggestion ? "opacity-100" : "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,25 +6,51 @@ import { Check } from "lucide-react";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<
|
interface CheckboxProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "checked" | "defaultChecked"> {
|
||||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
checked?: boolean | "indeterminate";
|
||||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
defaultChecked?: boolean | "indeterminate";
|
||||||
>(({ className, ...props }, ref) => (
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
<CheckboxPrimitive.Root
|
required?: boolean;
|
||||||
ref={ref}
|
}
|
||||||
className={cn(
|
|
||||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80",
|
const CheckboxRoot = CheckboxPrimitive.Root as React.ForwardRefExoticComponent<
|
||||||
className
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
|
||||||
)}
|
children?: React.ReactNode;
|
||||||
{...props}
|
className?: string;
|
||||||
>
|
} & React.RefAttributes<HTMLButtonElement>
|
||||||
<CheckboxPrimitive.Indicator
|
>;
|
||||||
className={cn("flex items-center justify-center text-current")}
|
|
||||||
|
const CheckboxIndicator = CheckboxPrimitive.Indicator as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Indicator> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLSpanElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||||
|
({ className, onCheckedChange, children: _children, ...props }, ref) => (
|
||||||
|
<CheckboxRoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
// Handle indeterminate state by treating it as false for consumers expecting boolean
|
||||||
|
if (onCheckedChange) {
|
||||||
|
onCheckedChange(checked === true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4" />
|
<CheckboxIndicator
|
||||||
</CheckboxPrimitive.Indicator>
|
className={cn("flex items-center justify-center text-current")}
|
||||||
</CheckboxPrimitive.Root>
|
>
|
||||||
));
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxIndicator>
|
||||||
|
</CheckboxRoot>
|
||||||
|
)
|
||||||
|
);
|
||||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||||
|
|
||||||
export { Checkbox };
|
export { Checkbox };
|
||||||
|
|||||||
@@ -268,6 +268,52 @@ export function DescriptionImageDropZone({
|
|||||||
[images, onImagesChange]
|
[images, onImagesChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle paste events to detect and process images from clipboard
|
||||||
|
// Works across all OS (Windows, Linux, macOS)
|
||||||
|
const handlePaste = useCallback(
|
||||||
|
(e: React.ClipboardEvent) => {
|
||||||
|
if (disabled || isProcessing) return;
|
||||||
|
|
||||||
|
const clipboardItems = e.clipboardData?.items;
|
||||||
|
if (!clipboardItems) return;
|
||||||
|
|
||||||
|
const imageFiles: File[] = [];
|
||||||
|
|
||||||
|
// Iterate through clipboard items to find images
|
||||||
|
for (let i = 0; i < clipboardItems.length; i++) {
|
||||||
|
const item = clipboardItems[i];
|
||||||
|
|
||||||
|
// Check if the item is an image
|
||||||
|
if (item.type.startsWith("image/")) {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
// Generate a filename for pasted images since they don't have one
|
||||||
|
const extension = item.type.split("/")[1] || "png";
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
const renamedFile = new File(
|
||||||
|
[file],
|
||||||
|
`pasted-image-${timestamp}.${extension}`,
|
||||||
|
{ type: file.type }
|
||||||
|
);
|
||||||
|
imageFiles.push(renamedFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found images, process them and prevent default paste behavior
|
||||||
|
if (imageFiles.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Create a FileList-like object from the array
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
imageFiles.forEach((file) => dataTransfer.items.add(file));
|
||||||
|
processFiles(dataTransfer.files);
|
||||||
|
}
|
||||||
|
// If no images found, let the default paste behavior happen (paste text)
|
||||||
|
},
|
||||||
|
[disabled, isProcessing, processFiles]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative", className)}>
|
<div className={cn("relative", className)}>
|
||||||
{/* Hidden file input */}
|
{/* Hidden file input */}
|
||||||
@@ -313,6 +359,7 @@ export function DescriptionImageDropZone({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onPaste={handlePaste}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
aria-invalid={error}
|
aria-invalid={error}
|
||||||
@@ -326,7 +373,7 @@ export function DescriptionImageDropZone({
|
|||||||
|
|
||||||
{/* Hint text */}
|
{/* Hint text */}
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Drag and drop images here or{" "}
|
Paste, drag and drop images, or{" "}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleBrowseClick}
|
onClick={handleBrowseClick}
|
||||||
|
|||||||
@@ -6,6 +6,36 @@ import { XIcon } from "lucide-react";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||||
|
const DialogContentPrimitive = DialogPrimitive.Content as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLDivElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const DialogClosePrimitive = DialogPrimitive.Close as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLButtonElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const DialogTitlePrimitive = DialogPrimitive.Title as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLHeadingElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const DialogDescriptionPrimitive = DialogPrimitive.Description as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
} & React.RefAttributes<HTMLParagraphElement>
|
||||||
|
>;
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
@@ -30,12 +60,20 @@ function DialogClose({
|
|||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DialogOverlayPrimitive = DialogPrimitive.Overlay as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLDivElement>
|
||||||
|
>;
|
||||||
|
|
||||||
function DialogOverlay({
|
function DialogOverlay({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay> & {
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Overlay
|
<DialogOverlayPrimitive
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
|
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
|
||||||
@@ -66,7 +104,7 @@ function DialogContent({
|
|||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogContentPrimitive
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
|
"fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
|
||||||
@@ -91,7 +129,7 @@ function DialogContent({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close
|
<DialogClosePrimitive
|
||||||
data-slot="dialog-close"
|
data-slot="dialog-close"
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute rounded-lg opacity-60 transition-all duration-200 cursor-pointer",
|
"absolute rounded-lg opacity-60 transition-all duration-200 cursor-pointer",
|
||||||
@@ -105,9 +143,9 @@ function DialogContent({
|
|||||||
>
|
>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogClosePrimitive>
|
||||||
)}
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogContentPrimitive>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -137,27 +175,42 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
|
|
||||||
function DialogTitle({
|
function DialogTitle({
|
||||||
className,
|
className,
|
||||||
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Title> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Title
|
<DialogTitlePrimitive
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
className={cn("text-lg leading-none font-semibold tracking-tight", className)}
|
className={cn("text-lg leading-none font-semibold tracking-tight", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
</DialogTitlePrimitive>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogDescription({
|
function DialogDescription({
|
||||||
className,
|
className,
|
||||||
|
children,
|
||||||
|
title,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Description> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Description
|
<DialogDescriptionPrimitive
|
||||||
data-slot="dialog-description"
|
data-slot="dialog-description"
|
||||||
className={cn("text-muted-foreground text-sm leading-relaxed", className)}
|
className={cn("text-muted-foreground text-sm leading-relaxed", className)}
|
||||||
|
title={title}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
</DialogDescriptionPrimitive>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,83 @@ import { Check, ChevronRight, Circle } from "lucide-react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||||
|
const DropdownMenuTriggerPrimitive = DropdownMenuPrimitive.Trigger as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
asChild?: boolean;
|
||||||
|
} & React.RefAttributes<HTMLButtonElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const DropdownMenuSubTriggerPrimitive = DropdownMenuPrimitive.SubTrigger as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLDivElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroupPrimitive = DropdownMenuPrimitive.RadioGroup as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioGroup> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
} & React.RefAttributes<HTMLDivElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const DropdownMenuItemPrimitive = DropdownMenuPrimitive.Item as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const DropdownMenuRadioItemPrimitive = DropdownMenuPrimitive.RadioItem as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const DropdownMenuLabelPrimitive = DropdownMenuPrimitive.Label as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLDivElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItemPrimitive = DropdownMenuPrimitive.CheckboxItem as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLDivElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const DropdownMenuItemIndicatorPrimitive = DropdownMenuPrimitive.ItemIndicator as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.ItemIndicator> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
} & React.RefAttributes<HTMLSpanElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const DropdownMenuSeparatorPrimitive = DropdownMenuPrimitive.Separator as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLDivElement>
|
||||||
|
>;
|
||||||
|
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
function DropdownMenuTrigger({
|
||||||
|
children,
|
||||||
|
asChild,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuTriggerPrimitive asChild={asChild} {...props}>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuTriggerPrimitive>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
@@ -16,15 +90,26 @@ const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
|||||||
|
|
||||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
function DropdownMenuRadioGroup({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup> & { children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuRadioGroupPrimitive {...props}>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuRadioGroupPrimitive>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const DropdownMenuSubTrigger = React.forwardRef<
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean
|
inset?: boolean
|
||||||
|
children?: React.ReactNode
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuSubTriggerPrimitive
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent hover:bg-accent",
|
"flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent hover:bg-accent",
|
||||||
@@ -35,13 +120,15 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuSubTriggerPrimitive>
|
||||||
))
|
))
|
||||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
const DropdownMenuSubContent = React.forwardRef<
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> & {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Portal>
|
<DropdownMenuPrimitive.Portal>
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
@@ -58,7 +145,9 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
|
|||||||
|
|
||||||
const DropdownMenuContent = React.forwardRef<
|
const DropdownMenuContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Portal>
|
<DropdownMenuPrimitive.Portal>
|
||||||
<DropdownMenuPrimitive.Content
|
<DropdownMenuPrimitive.Content
|
||||||
@@ -78,9 +167,10 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean
|
inset?: boolean
|
||||||
}
|
children?: React.ReactNode
|
||||||
>(({ className, inset, ...props }, ref) => (
|
} & React.HTMLAttributes<HTMLDivElement>
|
||||||
<DropdownMenuPrimitive.Item
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuItemPrimitive
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
|
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
|
||||||
@@ -88,15 +178,20 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuItemPrimitive>
|
||||||
))
|
))
|
||||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
>(({ className, children, checked, ...props }, ref) => (
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuCheckboxItemPrimitive
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
|
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
|
||||||
@@ -106,21 +201,23 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuItemIndicatorPrimitive>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuItemIndicatorPrimitive>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuCheckboxItemPrimitive>
|
||||||
))
|
))
|
||||||
DropdownMenuCheckboxItem.displayName =
|
DropdownMenuCheckboxItem.displayName =
|
||||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
const DropdownMenuRadioItem = React.forwardRef<
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||||
|
children?: React.ReactNode
|
||||||
|
} & React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuRadioItemPrimitive
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
|
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
|
||||||
@@ -129,12 +226,12 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuItemIndicatorPrimitive>
|
||||||
<Circle className="h-2 w-2 fill-current" />
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuItemIndicatorPrimitive>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
</DropdownMenuRadioItemPrimitive>
|
||||||
))
|
))
|
||||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
@@ -142,9 +239,11 @@ const DropdownMenuLabel = React.forwardRef<
|
|||||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean
|
inset?: boolean
|
||||||
|
children?: React.ReactNode
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuLabelPrimitive
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1.5 text-sm font-semibold",
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
@@ -152,15 +251,19 @@ const DropdownMenuLabel = React.forwardRef<
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuLabelPrimitive>
|
||||||
))
|
))
|
||||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
const DropdownMenuSeparator = React.forwardRef<
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuSeparatorPrimitive
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -620,6 +620,41 @@ export function GitDiffPanel({
|
|||||||
onToggle={() => toggleFile(fileDiff.filePath)}
|
onToggle={() => toggleFile(fileDiff.filePath)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{/* Fallback for files that have no diff content (shouldn't happen after fix, but safety net) */}
|
||||||
|
{files.length > 0 && parsedDiffs.length === 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{files.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.path}
|
||||||
|
className="border border-border rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card">
|
||||||
|
{getFileIcon(file.status)}
|
||||||
|
<span className="flex-1 text-sm font-mono truncate text-foreground">
|
||||||
|
{file.path}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-1.5 py-0.5 rounded border font-medium",
|
||||||
|
getStatusBadgeColor(file.status)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getStatusDisplayName(file.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 text-sm text-muted-foreground bg-background border-t border-border">
|
||||||
|
{file.status === "?" ? (
|
||||||
|
<span>New file - content preview not available</span>
|
||||||
|
) : file.status === "D" ? (
|
||||||
|
<span>File deleted</span>
|
||||||
|
) : (
|
||||||
|
<span>Diff content not available</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -244,14 +244,16 @@ export function ImageDropZone({
|
|||||||
<p className="text-xs font-medium text-foreground truncate">
|
<p className="text-xs font-medium text-foreground truncate">
|
||||||
{image.filename}
|
{image.filename}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
{image.size !== undefined && (
|
||||||
{formatFileSize(image.size)}
|
<p className="text-xs text-muted-foreground">
|
||||||
</p>
|
{formatFileSize(image.size)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Remove button */}
|
{/* Remove button */}
|
||||||
{!disabled && (
|
{!disabled && image.id && (
|
||||||
<button
|
<button
|
||||||
onClick={() => removeImage(image.id)}
|
onClick={() => removeImage(image.id!)}
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive hover:text-destructive-foreground text-muted-foreground"
|
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive hover:text-destructive-foreground text-muted-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -14,13 +14,26 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
FileOutput,
|
FileOutput,
|
||||||
Brain,
|
Brain,
|
||||||
|
Eye,
|
||||||
|
Pencil,
|
||||||
|
Terminal,
|
||||||
|
Search,
|
||||||
|
ListTodo,
|
||||||
|
Layers,
|
||||||
|
X,
|
||||||
|
Filter,
|
||||||
|
Circle,
|
||||||
|
Play,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
parseLogOutput,
|
parseLogOutput,
|
||||||
getLogTypeColors,
|
getLogTypeColors,
|
||||||
|
shouldCollapseByDefault,
|
||||||
type LogEntry,
|
type LogEntry,
|
||||||
type LogEntryType,
|
type LogEntryType,
|
||||||
|
type ToolCategory,
|
||||||
} from "@/lib/log-parser";
|
} from "@/lib/log-parser";
|
||||||
|
|
||||||
interface LogViewerProps {
|
interface LogViewerProps {
|
||||||
@@ -53,6 +66,160 @@ const getLogIcon = (type: LogEntryType) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a tool-specific icon based on the tool category
|
||||||
|
*/
|
||||||
|
const getToolCategoryIcon = (category: ToolCategory | undefined) => {
|
||||||
|
switch (category) {
|
||||||
|
case "read":
|
||||||
|
return <Eye className="w-4 h-4" />;
|
||||||
|
case "edit":
|
||||||
|
return <Pencil className="w-4 h-4" />;
|
||||||
|
case "write":
|
||||||
|
return <FileOutput className="w-4 h-4" />;
|
||||||
|
case "bash":
|
||||||
|
return <Terminal className="w-4 h-4" />;
|
||||||
|
case "search":
|
||||||
|
return <Search className="w-4 h-4" />;
|
||||||
|
case "todo":
|
||||||
|
return <ListTodo className="w-4 h-4" />;
|
||||||
|
case "task":
|
||||||
|
return <Layers className="w-4 h-4" />;
|
||||||
|
default:
|
||||||
|
return <Wrench className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns color classes for a tool category
|
||||||
|
*/
|
||||||
|
const getToolCategoryColor = (category: ToolCategory | undefined): string => {
|
||||||
|
switch (category) {
|
||||||
|
case "read":
|
||||||
|
return "text-blue-400 bg-blue-500/10 border-blue-500/30";
|
||||||
|
case "edit":
|
||||||
|
return "text-amber-400 bg-amber-500/10 border-amber-500/30";
|
||||||
|
case "write":
|
||||||
|
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/30";
|
||||||
|
case "bash":
|
||||||
|
return "text-purple-400 bg-purple-500/10 border-purple-500/30";
|
||||||
|
case "search":
|
||||||
|
return "text-cyan-400 bg-cyan-500/10 border-cyan-500/30";
|
||||||
|
case "todo":
|
||||||
|
return "text-green-400 bg-green-500/10 border-green-500/30";
|
||||||
|
case "task":
|
||||||
|
return "text-indigo-400 bg-indigo-500/10 border-indigo-500/30";
|
||||||
|
default:
|
||||||
|
return "text-zinc-400 bg-zinc-500/10 border-zinc-500/30";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for parsed todo items from TodoWrite tool
|
||||||
|
*/
|
||||||
|
interface TodoItem {
|
||||||
|
content: string;
|
||||||
|
status: "pending" | "in_progress" | "completed";
|
||||||
|
activeForm?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses TodoWrite JSON content and extracts todo items
|
||||||
|
*/
|
||||||
|
function parseTodoContent(content: string): TodoItem[] | null {
|
||||||
|
try {
|
||||||
|
// Find the JSON object in the content
|
||||||
|
const jsonMatch = content.match(/\{[\s\S]*"todos"[\s\S]*\}/);
|
||||||
|
if (!jsonMatch) return null;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(jsonMatch[0]) as { todos?: TodoItem[] };
|
||||||
|
if (!parsed.todos || !Array.isArray(parsed.todos)) return null;
|
||||||
|
|
||||||
|
return parsed.todos;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a list of todo items with status icons and colors
|
||||||
|
*/
|
||||||
|
function TodoListRenderer({ todos }: { todos: TodoItem[] }) {
|
||||||
|
const getStatusIcon = (status: TodoItem["status"]) => {
|
||||||
|
switch (status) {
|
||||||
|
case "completed":
|
||||||
|
return <CheckCircle2 className="w-4 h-4 text-emerald-400" />;
|
||||||
|
case "in_progress":
|
||||||
|
return <Loader2 className="w-4 h-4 text-amber-400 animate-spin" />;
|
||||||
|
case "pending":
|
||||||
|
return <Circle className="w-4 h-4 text-zinc-500" />;
|
||||||
|
default:
|
||||||
|
return <Circle className="w-4 h-4 text-zinc-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: TodoItem["status"]) => {
|
||||||
|
switch (status) {
|
||||||
|
case "completed":
|
||||||
|
return "text-emerald-300 line-through opacity-70";
|
||||||
|
case "in_progress":
|
||||||
|
return "text-amber-300";
|
||||||
|
case "pending":
|
||||||
|
return "text-zinc-400";
|
||||||
|
default:
|
||||||
|
return "text-zinc-400";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: TodoItem["status"]) => {
|
||||||
|
switch (status) {
|
||||||
|
case "completed":
|
||||||
|
return (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400 ml-auto">
|
||||||
|
Done
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case "in_progress":
|
||||||
|
return (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400 ml-auto">
|
||||||
|
In Progress
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{todos.map((todo, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-2 p-2 rounded-md transition-colors",
|
||||||
|
todo.status === "in_progress" && "bg-amber-500/5 border border-amber-500/20",
|
||||||
|
todo.status === "completed" && "bg-emerald-500/5",
|
||||||
|
todo.status === "pending" && "bg-zinc-800/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mt-0.5 flex-shrink-0">{getStatusIcon(todo.status)}</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className={cn("text-sm", getStatusColor(todo.status))}>
|
||||||
|
{todo.content}
|
||||||
|
</p>
|
||||||
|
{todo.status === "in_progress" && todo.activeForm && (
|
||||||
|
<p className="text-xs text-amber-400/70 mt-0.5 italic">
|
||||||
|
{todo.activeForm}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{getStatusBadge(todo.status)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface LogEntryItemProps {
|
interface LogEntryItemProps {
|
||||||
entry: LogEntry;
|
entry: LogEntry;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
@@ -63,9 +230,54 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
|
|||||||
const colors = getLogTypeColors(entry.type);
|
const colors = getLogTypeColors(entry.type);
|
||||||
const hasContent = entry.content.length > 100;
|
const hasContent = entry.content.length > 100;
|
||||||
|
|
||||||
|
// For tool_call entries, use tool-specific styling
|
||||||
|
const isToolCall = entry.type === "tool_call";
|
||||||
|
const toolCategory = entry.metadata?.toolCategory;
|
||||||
|
const toolCategoryColors = isToolCall ? getToolCategoryColor(toolCategory) : "";
|
||||||
|
|
||||||
|
// Check if this is a TodoWrite entry and parse the todos
|
||||||
|
const isTodoWrite = entry.metadata?.toolName === "TodoWrite";
|
||||||
|
const parsedTodos = useMemo(() => {
|
||||||
|
if (!isTodoWrite) return null;
|
||||||
|
return parseTodoContent(entry.content);
|
||||||
|
}, [isTodoWrite, entry.content]);
|
||||||
|
|
||||||
|
// Get the appropriate icon based on entry type and tool category
|
||||||
|
const icon = isToolCall ? getToolCategoryIcon(toolCategory) : getLogIcon(entry.type);
|
||||||
|
|
||||||
|
// Get collapsed preview text - prefer smart summary for tool calls
|
||||||
|
const collapsedPreview = useMemo(() => {
|
||||||
|
if (isExpanded) return "";
|
||||||
|
|
||||||
|
// Use smart summary if available
|
||||||
|
if (entry.metadata?.summary) {
|
||||||
|
return entry.metadata.summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to truncated content
|
||||||
|
return entry.content.slice(0, 80) + (entry.content.length > 80 ? "..." : "");
|
||||||
|
}, [isExpanded, entry.metadata?.summary, entry.content]);
|
||||||
|
|
||||||
// Format content - detect and highlight JSON
|
// Format content - detect and highlight JSON
|
||||||
const formattedContent = useMemo(() => {
|
const formattedContent = useMemo(() => {
|
||||||
const content = entry.content;
|
let content = entry.content;
|
||||||
|
|
||||||
|
// For tool_call entries, remove redundant "Tool: X" and "Input:" prefixes
|
||||||
|
// since we already show the tool name in the header badge
|
||||||
|
if (isToolCall) {
|
||||||
|
// Remove "🔧 Tool: ToolName\n" or "Tool: ToolName\n" prefix
|
||||||
|
content = content.replace(/^(?:🔧\s*)?Tool:\s*\w+\s*\n?/i, "");
|
||||||
|
// Remove standalone "Input:" label (keep the JSON that follows)
|
||||||
|
content = content.replace(/^Input:\s*\n?/i, "");
|
||||||
|
content = content.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For summary entries, remove the <summary> and </summary> tags
|
||||||
|
if (entry.title === "Summary") {
|
||||||
|
content = content.replace(/^<summary>\s*/i, "");
|
||||||
|
content = content.replace(/\s*<\/summary>\s*$/i, "");
|
||||||
|
content = content.trim();
|
||||||
|
}
|
||||||
|
|
||||||
// Try to find and format JSON blocks
|
// Try to find and format JSON blocks
|
||||||
const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g;
|
const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g;
|
||||||
@@ -103,14 +315,20 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return parts.length > 0 ? parts : [{ type: "text" as const, content }];
|
return parts.length > 0 ? parts : [{ type: "text" as const, content }];
|
||||||
}, [entry.content]);
|
}, [entry.content, entry.title, isToolCall]);
|
||||||
|
|
||||||
|
// Get colors - use tool category colors for tool_call entries
|
||||||
|
const colorParts = toolCategoryColors.split(" ");
|
||||||
|
const textColor = isToolCall ? (colorParts[0] || "text-zinc-400") : colors.text;
|
||||||
|
const bgColor = isToolCall ? (colorParts[1] || "bg-zinc-500/10") : colors.bg;
|
||||||
|
const borderColor = isToolCall ? (colorParts[2] || "border-zinc-500/30") : colors.border;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border-l-4 transition-all duration-200",
|
"rounded-lg border-l-4 transition-all duration-200",
|
||||||
colors.bg,
|
bgColor,
|
||||||
colors.border,
|
borderColor,
|
||||||
"hover:brightness-110"
|
"hover:brightness-110"
|
||||||
)}
|
)}
|
||||||
data-testid={`log-entry-${entry.type}`}
|
data-testid={`log-entry-${entry.type}`}
|
||||||
@@ -130,14 +348,14 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
|
|||||||
<span className="w-4 flex-shrink-0" />
|
<span className="w-4 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className={cn("flex-shrink-0", colors.icon)}>
|
<span className={cn("flex-shrink-0", isToolCall ? toolCategoryColors.split(" ")[0] : colors.icon)}>
|
||||||
{getLogIcon(entry.type)}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0",
|
"text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0",
|
||||||
colors.badge
|
isToolCall ? toolCategoryColors : colors.badge
|
||||||
)}
|
)}
|
||||||
data-testid="log-entry-badge"
|
data-testid="log-entry-badge"
|
||||||
>
|
>
|
||||||
@@ -145,9 +363,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="text-xs text-zinc-400 truncate flex-1 ml-2">
|
<span className="text-xs text-zinc-400 truncate flex-1 ml-2">
|
||||||
{!isExpanded &&
|
{collapsedPreview}
|
||||||
entry.content.slice(0, 80) +
|
|
||||||
(entry.content.length > 80 ? "..." : "")}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -156,36 +372,140 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
|
|||||||
className="px-4 pb-3 pt-1"
|
className="px-4 pb-3 pt-1"
|
||||||
data-testid={`log-entry-content-${entry.id}`}
|
data-testid={`log-entry-content-${entry.id}`}
|
||||||
>
|
>
|
||||||
<div className="font-mono text-xs space-y-1">
|
{/* Render TodoWrite entries with special formatting */}
|
||||||
{formattedContent.map((part, index) => (
|
{parsedTodos ? (
|
||||||
<div key={index}>
|
<TodoListRenderer todos={parsedTodos} />
|
||||||
{part.type === "json" ? (
|
) : (
|
||||||
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto text-xs text-primary">
|
<div className="font-mono text-xs space-y-1">
|
||||||
{part.content}
|
{formattedContent.map((part, index) => (
|
||||||
</pre>
|
<div key={index}>
|
||||||
) : (
|
{part.type === "json" ? (
|
||||||
<pre
|
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto text-xs text-primary">
|
||||||
className={cn(
|
{part.content}
|
||||||
"whitespace-pre-wrap break-words",
|
</pre>
|
||||||
colors.text
|
) : (
|
||||||
)}
|
<pre
|
||||||
>
|
className={cn(
|
||||||
{part.content}
|
"whitespace-pre-wrap break-words",
|
||||||
</pre>
|
textColor
|
||||||
)}
|
)}
|
||||||
</div>
|
>
|
||||||
))}
|
{part.content}
|
||||||
</div>
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ToolCategoryStats {
|
||||||
|
read: number;
|
||||||
|
edit: number;
|
||||||
|
write: number;
|
||||||
|
bash: number;
|
||||||
|
search: number;
|
||||||
|
todo: number;
|
||||||
|
task: number;
|
||||||
|
other: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function LogViewer({ output, className }: LogViewerProps) {
|
export function LogViewer({ output, className }: LogViewerProps) {
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [hiddenTypes, setHiddenTypes] = useState<Set<LogEntryType>>(new Set());
|
||||||
|
const [hiddenCategories, setHiddenCategories] = useState<Set<ToolCategory>>(new Set());
|
||||||
|
|
||||||
const entries = useMemo(() => parseLogOutput(output), [output]);
|
// Parse entries and compute initial expanded state together
|
||||||
|
const { entries, initialExpandedIds } = useMemo(() => {
|
||||||
|
const parsedEntries = parseLogOutput(output);
|
||||||
|
const toExpand: string[] = [];
|
||||||
|
|
||||||
|
parsedEntries.forEach((entry) => {
|
||||||
|
// If entry should NOT collapse by default, mark it for expansion
|
||||||
|
if (!shouldCollapseByDefault(entry)) {
|
||||||
|
toExpand.push(entry.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: parsedEntries,
|
||||||
|
initialExpandedIds: new Set(toExpand),
|
||||||
|
};
|
||||||
|
}, [output]);
|
||||||
|
|
||||||
|
// Merge initial expanded IDs with user-toggled ones
|
||||||
|
// Use a ref to track if we've applied initial state
|
||||||
|
const appliedInitialRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Apply initial expanded state for new entries
|
||||||
|
const effectiveExpandedIds = useMemo(() => {
|
||||||
|
const result = new Set(expandedIds);
|
||||||
|
initialExpandedIds.forEach((id) => {
|
||||||
|
if (!appliedInitialRef.current.has(id)) {
|
||||||
|
appliedInitialRef.current.add(id);
|
||||||
|
result.add(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}, [expandedIds, initialExpandedIds]);
|
||||||
|
|
||||||
|
// Calculate stats for tool categories
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const toolCalls = entries.filter((e) => e.type === "tool_call");
|
||||||
|
const byCategory: ToolCategoryStats = {
|
||||||
|
read: 0,
|
||||||
|
edit: 0,
|
||||||
|
write: 0,
|
||||||
|
bash: 0,
|
||||||
|
search: 0,
|
||||||
|
todo: 0,
|
||||||
|
task: 0,
|
||||||
|
other: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
toolCalls.forEach((tc) => {
|
||||||
|
const cat = tc.metadata?.toolCategory || "other";
|
||||||
|
byCategory[cat]++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: toolCalls.length,
|
||||||
|
byCategory,
|
||||||
|
errors: entries.filter((e) => e.type === "error").length,
|
||||||
|
};
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
// Filter entries based on search and hidden types/categories
|
||||||
|
const filteredEntries = useMemo(() => {
|
||||||
|
return entries.filter((entry) => {
|
||||||
|
// Filter by hidden types
|
||||||
|
if (hiddenTypes.has(entry.type)) return false;
|
||||||
|
|
||||||
|
// Filter by hidden tool categories (for tool_call entries)
|
||||||
|
if (entry.type === "tool_call" && entry.metadata?.toolCategory) {
|
||||||
|
if (hiddenCategories.has(entry.metadata.toolCategory)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
entry.content.toLowerCase().includes(query) ||
|
||||||
|
entry.title.toLowerCase().includes(query) ||
|
||||||
|
entry.metadata?.toolName?.toLowerCase().includes(query) ||
|
||||||
|
entry.metadata?.summary?.toLowerCase().includes(query) ||
|
||||||
|
entry.metadata?.filePath?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [entries, hiddenTypes, hiddenCategories, searchQuery]);
|
||||||
|
|
||||||
const toggleEntry = (id: string) => {
|
const toggleEntry = (id: string) => {
|
||||||
setExpandedIds((prev) => {
|
setExpandedIds((prev) => {
|
||||||
@@ -200,13 +520,45 @@ export function LogViewer({ output, className }: LogViewerProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const expandAll = () => {
|
const expandAll = () => {
|
||||||
setExpandedIds(new Set(entries.map((e) => e.id)));
|
setExpandedIds(new Set(filteredEntries.map((e) => e.id)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const collapseAll = () => {
|
const collapseAll = () => {
|
||||||
setExpandedIds(new Set());
|
setExpandedIds(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleTypeFilter = (type: LogEntryType) => {
|
||||||
|
setHiddenTypes((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(type)) {
|
||||||
|
next.delete(type);
|
||||||
|
} else {
|
||||||
|
next.add(type);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCategoryFilter = (category: ToolCategory) => {
|
||||||
|
setHiddenCategories((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(category)) {
|
||||||
|
next.delete(category);
|
||||||
|
} else {
|
||||||
|
next.add(category);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchQuery("");
|
||||||
|
setHiddenTypes(new Set());
|
||||||
|
setHiddenCategories(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = searchQuery || hiddenTypes.size > 0 || hiddenCategories.size > 0;
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
||||||
@@ -229,28 +581,123 @@ export function LogViewer({ output, className }: LogViewerProps) {
|
|||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, number>);
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
// Tool categories to display in stats bar
|
||||||
|
const toolCategoryLabels: { key: ToolCategory; label: string }[] = [
|
||||||
|
{ key: "read", label: "Read" },
|
||||||
|
{ key: "edit", label: "Edit" },
|
||||||
|
{ key: "write", label: "Write" },
|
||||||
|
{ key: "bash", label: "Bash" },
|
||||||
|
{ key: "search", label: "Search" },
|
||||||
|
{ key: "todo", label: "Todo" },
|
||||||
|
{ key: "task", label: "Task" },
|
||||||
|
{ key: "other", label: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-2", className)}>
|
<div className={cn("flex flex-col", className)}>
|
||||||
{/* Header with controls */}
|
{/* Sticky header with search, stats, and filters */}
|
||||||
|
{/* Use -top-4 to compensate for parent's p-4 padding, pt-4 to restore visual spacing */}
|
||||||
|
<div className="sticky -top-4 z-10 bg-zinc-950/95 backdrop-blur-sm pt-4 pb-2 space-y-2 -mx-4 px-4">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="flex items-center gap-2 px-1" data-testid="log-search-bar">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search logs..."
|
||||||
|
className="w-full pl-8 pr-8 py-1.5 text-xs bg-zinc-900/50 border border-zinc-700/50 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-zinc-600"
|
||||||
|
data-testid="log-search-input"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
|
||||||
|
data-testid="log-search-clear"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors flex items-center gap-1"
|
||||||
|
data-testid="log-clear-filters"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tool category stats bar */}
|
||||||
|
{stats.total > 0 && (
|
||||||
|
<div className="flex items-center gap-1 px-1 flex-wrap" data-testid="log-stats-bar">
|
||||||
|
<span className="text-xs text-zinc-500 mr-1">
|
||||||
|
<Wrench className="w-3 h-3 inline mr-1" />
|
||||||
|
{stats.total} tools:
|
||||||
|
</span>
|
||||||
|
{toolCategoryLabels.map(({ key, label }) => {
|
||||||
|
const count = stats.byCategory[key];
|
||||||
|
if (count === 0) return null;
|
||||||
|
const isHidden = hiddenCategories.has(key);
|
||||||
|
const colorClasses = getToolCategoryColor(key);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => toggleCategoryFilter(key)}
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2 py-0.5 rounded-full border transition-all flex items-center gap-1",
|
||||||
|
colorClasses,
|
||||||
|
isHidden && "opacity-40 line-through"
|
||||||
|
)}
|
||||||
|
title={isHidden ? `Show ${label} tools` : `Hide ${label} tools`}
|
||||||
|
data-testid={`log-category-filter-${key}`}
|
||||||
|
>
|
||||||
|
{getToolCategoryIcon(key)}
|
||||||
|
<span>{count}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{stats.errors > 0 && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-400 border border-red-500/30 flex items-center gap-1">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
{stats.errors}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header with type filters and controls */}
|
||||||
<div className="flex items-center justify-between px-1" data-testid="log-viewer-header">
|
<div className="flex items-center justify-between px-1" data-testid="log-viewer-header">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
<Filter className="w-3 h-3 text-zinc-500 mr-1" />
|
||||||
{Object.entries(typeCounts).map(([type, count]) => {
|
{Object.entries(typeCounts).map(([type, count]) => {
|
||||||
const colors = getLogTypeColors(type as LogEntryType);
|
const colors = getLogTypeColors(type as LogEntryType);
|
||||||
|
const isHidden = hiddenTypes.has(type as LogEntryType);
|
||||||
return (
|
return (
|
||||||
<span
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
|
onClick={() => toggleTypeFilter(type as LogEntryType)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs px-2 py-0.5 rounded-full",
|
"text-xs px-2 py-0.5 rounded-full transition-all",
|
||||||
colors.badge
|
colors.badge,
|
||||||
|
isHidden && "opacity-40 line-through"
|
||||||
)}
|
)}
|
||||||
data-testid={`log-type-count-${type}`}
|
title={isHidden ? `Show ${type}` : `Hide ${type}`}
|
||||||
|
data-testid={`log-type-filter-${type}`}
|
||||||
>
|
>
|
||||||
{type}: {count}
|
{type}: {count}
|
||||||
</span>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
{filteredEntries.length}/{entries.length}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={expandAll}
|
onClick={expandAll}
|
||||||
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
|
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
|
||||||
@@ -267,17 +714,32 @@ export function LogViewer({ output, className }: LogViewerProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Log entries */}
|
{/* Log entries */}
|
||||||
<div className="space-y-2" data-testid="log-entries-container">
|
<div className="space-y-2 mt-2" data-testid="log-entries-container">
|
||||||
{entries.map((entry) => (
|
{filteredEntries.length === 0 ? (
|
||||||
<LogEntryItem
|
<div className="text-center py-4 text-zinc-500 text-sm">
|
||||||
key={entry.id}
|
No entries match your filters.
|
||||||
entry={entry}
|
{hasActiveFilters && (
|
||||||
isExpanded={expandedIds.has(entry.id)}
|
<button
|
||||||
onToggle={() => toggleEntry(entry.id)}
|
onClick={clearFilters}
|
||||||
/>
|
className="ml-2 text-primary hover:underline"
|
||||||
))}
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredEntries.map((entry) => (
|
||||||
|
<LogEntryItem
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
isExpanded={effectiveExpandedIds.has(entry.id)}
|
||||||
|
onToggle={() => toggleEntry(entry.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,20 @@ import * as PopoverPrimitive from "@radix-ui/react-popover"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||||
|
const PopoverTriggerPrimitive = PopoverPrimitive.Trigger as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Trigger> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
asChild?: boolean;
|
||||||
|
} & React.RefAttributes<HTMLButtonElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const PopoverContentPrimitive = PopoverPrimitive.Content as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLDivElement>
|
||||||
|
>;
|
||||||
|
|
||||||
function Popover({
|
function Popover({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
@@ -12,9 +26,18 @@ function Popover({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PopoverTrigger({
|
function PopoverTrigger({
|
||||||
|
children,
|
||||||
|
asChild,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger> & {
|
||||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
children?: React.ReactNode;
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PopoverTriggerPrimitive data-slot="popover-trigger" asChild={asChild} {...props}>
|
||||||
|
{children}
|
||||||
|
</PopoverTriggerPrimitive>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverContent({
|
function PopoverContent({
|
||||||
@@ -22,10 +45,12 @@ function PopoverContent({
|
|||||||
align = "center",
|
align = "center",
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<PopoverPrimitive.Portal>
|
<PopoverPrimitive.Portal>
|
||||||
<PopoverPrimitive.Content
|
<PopoverContentPrimitive
|
||||||
data-slot="popover-content"
|
data-slot="popover-content"
|
||||||
align={align}
|
align={align}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
|
|||||||
160
apps/app/src/components/ui/select.tsx
Normal file
160
apps/app/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
};
|
||||||
@@ -1,39 +1,43 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetTrigger({
|
function SheetTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetClose({
|
function SheetClose({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetPortal({
|
function SheetPortal({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetOverlay({
|
interface SheetOverlayProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
className,
|
forceMount?: true;
|
||||||
...props
|
}
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
|
||||||
|
const SheetOverlay = ({ className, ...props }: SheetOverlayProps) => {
|
||||||
|
const Overlay = SheetPrimitive.Overlay as React.ComponentType<
|
||||||
|
SheetOverlayProps & { "data-slot": string }
|
||||||
|
>;
|
||||||
return (
|
return (
|
||||||
<SheetPrimitive.Overlay
|
<Overlay
|
||||||
data-slot="sheet-overlay"
|
data-slot="sheet-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
@@ -41,21 +45,35 @@ function SheetOverlay({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SheetContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
|
forceMount?: true;
|
||||||
|
onEscapeKeyDown?: (event: KeyboardEvent) => void;
|
||||||
|
onPointerDownOutside?: (event: PointerEvent) => void;
|
||||||
|
onInteractOutside?: (event: Event) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetContent({
|
const SheetContent = ({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
side = "right",
|
side = "right",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
}: SheetContentProps) => {
|
||||||
side?: "top" | "right" | "bottom" | "left"
|
const Content = SheetPrimitive.Content as React.ComponentType<
|
||||||
}) {
|
SheetContentProps & { "data-slot": string }
|
||||||
|
>;
|
||||||
|
const Close = SheetPrimitive.Close as React.ComponentType<{
|
||||||
|
className: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
<SheetOverlay />
|
<SheetOverlay />
|
||||||
<SheetPrimitive.Content
|
<Content
|
||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
@@ -72,14 +90,14 @@ function SheetContent({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
<Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
<XIcon className="size-4" />
|
<XIcon className="size-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</SheetPrimitive.Close>
|
</Close>
|
||||||
</SheetPrimitive.Content>
|
</Content>
|
||||||
</SheetPortal>
|
</SheetPortal>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
@@ -88,7 +106,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -98,34 +116,39 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetTitle({
|
interface SheetTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
|
||||||
className,
|
|
||||||
...props
|
const SheetTitle = ({ className, ...props }: SheetTitleProps) => {
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
const Title = SheetPrimitive.Title as React.ComponentType<
|
||||||
|
SheetTitleProps & { "data-slot": string }
|
||||||
|
>;
|
||||||
return (
|
return (
|
||||||
<SheetPrimitive.Title
|
<Title
|
||||||
data-slot="sheet-title"
|
data-slot="sheet-title"
|
||||||
className={cn("text-foreground font-semibold", className)}
|
className={cn("text-foreground font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function SheetDescription({
|
interface SheetDescriptionProps
|
||||||
className,
|
extends React.HTMLAttributes<HTMLParagraphElement> {}
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
const SheetDescription = ({ className, ...props }: SheetDescriptionProps) => {
|
||||||
|
const Description = SheetPrimitive.Description as React.ComponentType<
|
||||||
|
SheetDescriptionProps & { "data-slot": string }
|
||||||
|
>;
|
||||||
return (
|
return (
|
||||||
<SheetPrimitive.Description
|
<Description
|
||||||
data-slot="sheet-description"
|
data-slot="sheet-description"
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Sheet,
|
Sheet,
|
||||||
@@ -136,4 +159,4 @@ export {
|
|||||||
SheetFooter,
|
SheetFooter,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -4,24 +4,65 @@ import * as React from "react";
|
|||||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Slider = React.forwardRef<
|
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||||
React.ComponentRef<typeof SliderPrimitive.Root>,
|
const SliderRootPrimitive = SliderPrimitive.Root as React.ForwardRefExoticComponent<
|
||||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
|
||||||
>(({ className, ...props }, ref) => (
|
children?: React.ReactNode;
|
||||||
<SliderPrimitive.Root
|
className?: string;
|
||||||
ref={ref}
|
} & React.RefAttributes<HTMLSpanElement>
|
||||||
className={cn(
|
>;
|
||||||
"relative flex w-full touch-none select-none items-center",
|
|
||||||
className
|
const SliderTrackPrimitive = SliderPrimitive.Track as React.ForwardRefExoticComponent<
|
||||||
)}
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Track> & {
|
||||||
{...props}
|
children?: React.ReactNode;
|
||||||
>
|
className?: string;
|
||||||
<SliderPrimitive.Track className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
|
} & React.RefAttributes<HTMLSpanElement>
|
||||||
<SliderPrimitive.Range className="slider-range absolute h-full bg-primary" />
|
>;
|
||||||
</SliderPrimitive.Track>
|
|
||||||
<SliderPrimitive.Thumb className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
|
const SliderRangePrimitive = SliderPrimitive.Range as React.ForwardRefExoticComponent<
|
||||||
</SliderPrimitive.Root>
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Range> & {
|
||||||
));
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLSpanElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const SliderThumbPrimitive = SliderPrimitive.Thumb as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Thumb> & {
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLSpanElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "defaultValue" | "dir"> {
|
||||||
|
value?: number[];
|
||||||
|
defaultValue?: number[];
|
||||||
|
onValueChange?: (value: number[]) => void;
|
||||||
|
onValueCommit?: (value: number[]) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
dir?: "ltr" | "rtl";
|
||||||
|
inverted?: boolean;
|
||||||
|
minStepsBetweenThumbs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<SliderRootPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none select-none items-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
|
||||||
|
<SliderRangePrimitive className="slider-range absolute h-full bg-primary" />
|
||||||
|
</SliderTrackPrimitive>
|
||||||
|
<SliderThumbPrimitive className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
|
||||||
|
</SliderRootPrimitive>
|
||||||
|
)
|
||||||
|
);
|
||||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||||
|
|
||||||
export { Slider };
|
export { Slider };
|
||||||
|
|||||||
@@ -5,41 +5,86 @@ import * as TabsPrimitive from "@radix-ui/react-tabs"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||||
|
const TabsRootPrimitive = TabsPrimitive.Root as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLDivElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const TabsListPrimitive = TabsPrimitive.List as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLDivElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const TabsTriggerPrimitive = TabsPrimitive.Trigger as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLButtonElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const TabsContentPrimitive = TabsPrimitive.Content as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLDivElement>
|
||||||
|
>;
|
||||||
|
|
||||||
function Tabs({
|
function Tabs({
|
||||||
className,
|
className,
|
||||||
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
}: React.ComponentProps<typeof TabsPrimitive.Root> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.Root
|
<TabsRootPrimitive
|
||||||
data-slot="tabs"
|
data-slot="tabs"
|
||||||
className={cn("flex flex-col gap-2", className)}
|
className={cn("flex flex-col gap-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
</TabsRootPrimitive>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsList({
|
function TabsList({
|
||||||
className,
|
className,
|
||||||
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
}: React.ComponentProps<typeof TabsPrimitive.List> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.List
|
<TabsListPrimitive
|
||||||
data-slot="tabs-list"
|
data-slot="tabs-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px] border border-border",
|
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px] border border-border",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
</TabsListPrimitive>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsTrigger({
|
function TabsTrigger({
|
||||||
className,
|
className,
|
||||||
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.Trigger
|
<TabsTriggerPrimitive
|
||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200 cursor-pointer",
|
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200 cursor-pointer",
|
||||||
@@ -51,20 +96,28 @@ function TabsTrigger({
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
</TabsTriggerPrimitive>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsContent({
|
function TabsContent({
|
||||||
className,
|
className,
|
||||||
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
}: React.ComponentProps<typeof TabsPrimitive.Content> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.Content
|
<TabsContentPrimitive
|
||||||
data-slot="tabs-content"
|
data-slot="tabs-content"
|
||||||
className={cn("flex-1 outline-none", className)}
|
className={cn("flex-1 outline-none", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
</TabsContentPrimitive>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
274
apps/app/src/components/ui/task-progress-panel.tsx
Normal file
274
apps/app/src/components/ui/task-progress-panel.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Check, Loader2, Circle, ChevronDown, ChevronRight, FileCode } from "lucide-react";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import type { AutoModeEvent } from "@/types/electron";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
interface TaskInfo {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
status: "pending" | "in_progress" | "completed";
|
||||||
|
filePath?: string;
|
||||||
|
phase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskProgressPanelProps {
|
||||||
|
featureId: string;
|
||||||
|
projectPath?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskProgressPanel({ featureId, projectPath, className }: TaskProgressPanelProps) {
|
||||||
|
const [tasks, setTasks] = useState<TaskInfo[]>([]);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load initial tasks from feature's planSpec
|
||||||
|
const loadInitialTasks = useCallback(async () => {
|
||||||
|
if (!projectPath) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.features) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.features.get(projectPath, featureId);
|
||||||
|
if (result.success && result.feature?.planSpec?.tasks) {
|
||||||
|
const planTasks = result.feature.planSpec.tasks;
|
||||||
|
const currentId = result.feature.planSpec.currentTaskId;
|
||||||
|
const completedCount = result.feature.planSpec.tasksCompleted || 0;
|
||||||
|
|
||||||
|
// Convert planSpec tasks to TaskInfo with proper status
|
||||||
|
const initialTasks: TaskInfo[] = planTasks.map((t: any, index: number) => ({
|
||||||
|
id: t.id,
|
||||||
|
description: t.description,
|
||||||
|
filePath: t.filePath,
|
||||||
|
phase: t.phase,
|
||||||
|
status: index < completedCount
|
||||||
|
? "completed" as const
|
||||||
|
: t.id === currentId
|
||||||
|
? "in_progress" as const
|
||||||
|
: "pending" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setTasks(initialTasks);
|
||||||
|
setCurrentTaskId(currentId || null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load initial tasks:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [featureId, projectPath]);
|
||||||
|
|
||||||
|
// Load initial state on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadInitialTasks();
|
||||||
|
}, [loadInitialTasks]);
|
||||||
|
|
||||||
|
// Listen to task events for real-time updates
|
||||||
|
useEffect(() => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.autoMode) return;
|
||||||
|
|
||||||
|
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
|
||||||
|
// Only handle events for this feature
|
||||||
|
if (!("featureId" in event) || event.featureId !== featureId) return;
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case "auto_mode_task_started":
|
||||||
|
if ("taskId" in event && "taskDescription" in event) {
|
||||||
|
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_started" }>;
|
||||||
|
setCurrentTaskId(taskEvent.taskId);
|
||||||
|
|
||||||
|
setTasks((prev) => {
|
||||||
|
// Check if task already exists
|
||||||
|
const existingIndex = prev.findIndex((t) => t.id === taskEvent.taskId);
|
||||||
|
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
// Update status to in_progress and mark previous as completed
|
||||||
|
return prev.map((t, idx) => {
|
||||||
|
if (t.id === taskEvent.taskId) {
|
||||||
|
return { ...t, status: "in_progress" as const };
|
||||||
|
}
|
||||||
|
// If we are moving to a task that is further down the list, assume previous ones are completed
|
||||||
|
// This is a heuristic, but usually correct for sequential execution
|
||||||
|
if (idx < existingIndex && t.status !== "completed") {
|
||||||
|
return { ...t, status: "completed" as const };
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new task if it doesn't exist (fallback)
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: taskEvent.taskId,
|
||||||
|
description: taskEvent.taskDescription,
|
||||||
|
status: "in_progress" as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "auto_mode_task_complete":
|
||||||
|
if ("taskId" in event) {
|
||||||
|
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_complete" }>;
|
||||||
|
setTasks((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === taskEvent.taskId ? { ...t, status: "completed" as const } : t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setCurrentTaskId(null);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [featureId]);
|
||||||
|
|
||||||
|
const completedCount = tasks.filter((t) => t.status === "completed").length;
|
||||||
|
const totalCount = tasks.length;
|
||||||
|
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
|
||||||
|
|
||||||
|
if (isLoading || tasks.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("group rounded-xl border bg-card/50 shadow-sm overflow-hidden transition-all duration-200", className)}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-full flex items-center justify-between p-4 bg-muted/10 hover:bg-muted/20 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
"flex h-8 w-8 items-center justify-center rounded-lg border shadow-sm transition-colors",
|
||||||
|
isExpanded ? "bg-background border-border" : "bg-muted border-transparent"
|
||||||
|
)}>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-foreground/70" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start gap-0.5">
|
||||||
|
<h3 className="font-semibold text-sm tracking-tight">Execution Plan</h3>
|
||||||
|
<span className="text-[10px] text-muted-foreground uppercase tracking-wider font-medium">
|
||||||
|
{completedCount} of {totalCount} tasks completed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Circular Progress (Mini) */}
|
||||||
|
<div className="relative h-8 w-8 flex items-center justify-center">
|
||||||
|
<svg className="h-full w-full -rotate-90 text-muted/20" viewBox="0 0 24 24">
|
||||||
|
<circle className="text-muted/20" cx="12" cy="12" r="10" strokeWidth="3" fill="none" stroke="currentColor" />
|
||||||
|
<circle
|
||||||
|
className="text-primary transition-all duration-500 ease-in-out"
|
||||||
|
cx="12" cy="12" r="10" strokeWidth="3" fill="none" stroke="currentColor"
|
||||||
|
strokeDasharray={63}
|
||||||
|
strokeDashoffset={63 - (63 * progressPercent) / 100}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="absolute text-[9px] font-bold">{progressPercent}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={cn(
|
||||||
|
"grid transition-all duration-300 ease-in-out",
|
||||||
|
isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||||||
|
)}>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="p-5 pt-2 relative max-h-[300px] overflow-y-auto scrollbar-visible">
|
||||||
|
{/* Vertical Connector Line */}
|
||||||
|
<div className="absolute left-[2.35rem] top-4 bottom-8 w-px bg-gradient-to-b from-border/80 via-border/40 to-transparent" />
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{tasks.map((task, index) => {
|
||||||
|
const isActive = task.status === "in_progress";
|
||||||
|
const isCompleted = task.status === "completed";
|
||||||
|
const isPending = task.status === "pending";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className={cn(
|
||||||
|
"relative flex gap-4 group/item transition-all duration-300",
|
||||||
|
isPending && "opacity-60 hover:opacity-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Icon Status */}
|
||||||
|
<div className={cn(
|
||||||
|
"relative z-10 flex h-7 w-7 items-center justify-center rounded-full border shadow-sm transition-all duration-300",
|
||||||
|
isCompleted && "bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400",
|
||||||
|
isActive && "bg-primary border-primary text-primary-foreground ring-4 ring-primary/10 scale-110",
|
||||||
|
isPending && "bg-muted border-border text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{isCompleted && <Check className="h-3.5 w-3.5" />}
|
||||||
|
{isActive && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||||
|
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task Content */}
|
||||||
|
<div className={cn(
|
||||||
|
"flex-1 pt-1 min-w-0 transition-all",
|
||||||
|
isActive && "translate-x-1"
|
||||||
|
)}>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<p className={cn(
|
||||||
|
"text-sm font-medium leading-none truncate pr-4",
|
||||||
|
isCompleted && "text-muted-foreground line-through decoration-border/60",
|
||||||
|
isActive && "text-primary font-semibold"
|
||||||
|
)}>
|
||||||
|
{task.description}
|
||||||
|
</p>
|
||||||
|
{isActive && (
|
||||||
|
<Badge variant="outline" className="h-5 px-1.5 text-[10px] bg-primary/5 text-primary border-primary/20 animate-pulse">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(task.filePath || isActive) && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
|
||||||
|
{task.filePath ? (
|
||||||
|
<>
|
||||||
|
<FileCode className="h-3 w-3 opacity-70" />
|
||||||
|
<span className="truncate opacity-80 hover:opacity-100 transition-opacity">
|
||||||
|
{task.filePath}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="h-3 block" /> /* Spacer */
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,18 +5,47 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||||
|
const TooltipTriggerPrimitive = TooltipPrimitive.Trigger as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
asChild?: boolean;
|
||||||
|
} & React.RefAttributes<HTMLButtonElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const TooltipContentPrimitive = TooltipPrimitive.Content as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLDivElement>
|
||||||
|
>;
|
||||||
|
|
||||||
const TooltipProvider = TooltipPrimitive.Provider
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
|
||||||
const Tooltip = TooltipPrimitive.Root
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
|
||||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
function TooltipTrigger({
|
||||||
|
children,
|
||||||
|
asChild,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TooltipTriggerPrimitive asChild={asChild} {...props}>
|
||||||
|
{children}
|
||||||
|
</TooltipTriggerPrimitive>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const TooltipContent = React.forwardRef<
|
const TooltipContent = React.forwardRef<
|
||||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
>(({ className, sideOffset = 6, ...props }, ref) => (
|
>(({ className, sideOffset = 6, ...props }, ref) => (
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipPrimitive.Content
|
<TooltipContentPrimitive
|
||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore, type AgentModel } from "@/store/app-store";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ImageDropZone } from "@/components/ui/image-drop-zone";
|
import { ImageDropZone } from "@/components/ui/image-drop-zone";
|
||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
PanelLeft,
|
PanelLeft,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
X,
|
X,
|
||||||
|
ImageIcon,
|
||||||
|
ChevronDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useElectronAgent } from "@/hooks/use-electron-agent";
|
import { useElectronAgent } from "@/hooks/use-electron-agent";
|
||||||
@@ -28,9 +30,17 @@ import {
|
|||||||
useKeyboardShortcutsConfig,
|
useKeyboardShortcutsConfig,
|
||||||
KeyboardShortcut,
|
KeyboardShortcut,
|
||||||
} from "@/hooks/use-keyboard-shortcuts";
|
} from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { CLAUDE_MODELS } from "@/components/views/board-view/shared/model-constants";
|
||||||
|
|
||||||
export function AgentView() {
|
export function AgentView() {
|
||||||
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
|
const { currentProject, setLastSelectedSession, getLastSelectedSession } =
|
||||||
|
useAppStore();
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
||||||
@@ -39,6 +49,7 @@ export function AgentView() {
|
|||||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||||
const [showSessionManager, setShowSessionManager] = useState(true);
|
const [showSessionManager, setShowSessionManager] = useState(true);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const [selectedModel, setSelectedModel] = useState<AgentModel>("sonnet");
|
||||||
|
|
||||||
// Track if initial session has been loaded
|
// Track if initial session has been loaded
|
||||||
const initialSessionLoadedRef = useRef(false);
|
const initialSessionLoadedRef = useRef(false);
|
||||||
@@ -64,6 +75,7 @@ export function AgentView() {
|
|||||||
} = useElectronAgent({
|
} = useElectronAgent({
|
||||||
sessionId: currentSessionId || "",
|
sessionId: currentSessionId || "",
|
||||||
workingDirectory: currentProject?.path,
|
workingDirectory: currentProject?.path,
|
||||||
|
model: selectedModel,
|
||||||
onToolUse: (toolName) => {
|
onToolUse: (toolName) => {
|
||||||
setCurrentTool(toolName);
|
setCurrentTool(toolName);
|
||||||
setTimeout(() => setCurrentTool(null), 2000);
|
setTimeout(() => setCurrentTool(null), 2000);
|
||||||
@@ -71,13 +83,16 @@ export function AgentView() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle session selection with persistence
|
// Handle session selection with persistence
|
||||||
const handleSelectSession = useCallback((sessionId: string | null) => {
|
const handleSelectSession = useCallback(
|
||||||
setCurrentSessionId(sessionId);
|
(sessionId: string | null) => {
|
||||||
// Persist the selection for this project
|
setCurrentSessionId(sessionId);
|
||||||
if (currentProject?.path) {
|
// Persist the selection for this project
|
||||||
setLastSelectedSession(currentProject.path, sessionId);
|
if (currentProject?.path) {
|
||||||
}
|
setLastSelectedSession(currentProject.path, sessionId);
|
||||||
}, [currentProject?.path, setLastSelectedSession]);
|
}
|
||||||
|
},
|
||||||
|
[currentProject?.path, setLastSelectedSession]
|
||||||
|
);
|
||||||
|
|
||||||
// Restore last selected session when switching to Agent view or when project changes
|
// Restore last selected session when switching to Agent view or when project changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -94,7 +109,10 @@ export function AgentView() {
|
|||||||
|
|
||||||
const lastSessionId = getLastSelectedSession(currentProject.path);
|
const lastSessionId = getLastSelectedSession(currentProject.path);
|
||||||
if (lastSessionId) {
|
if (lastSessionId) {
|
||||||
console.log("[AgentView] Restoring last selected session:", lastSessionId);
|
console.log(
|
||||||
|
"[AgentView] Restoring last selected session:",
|
||||||
|
lastSessionId
|
||||||
|
);
|
||||||
setCurrentSessionId(lastSessionId);
|
setCurrentSessionId(lastSessionId);
|
||||||
}
|
}
|
||||||
}, [currentProject?.path, getLastSelectedSession]);
|
}, [currentProject?.path, getLastSelectedSession]);
|
||||||
@@ -417,7 +435,9 @@ export function AgentView() {
|
|||||||
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-6">
|
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-6">
|
||||||
<Sparkles className="w-8 h-8 text-primary" />
|
<Sparkles className="w-8 h-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold mb-3 text-foreground">No Project Selected</h2>
|
<h2 className="text-xl font-semibold mb-3 text-foreground">
|
||||||
|
No Project Selected
|
||||||
|
</h2>
|
||||||
<p className="text-muted-foreground leading-relaxed">
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
Open or create a project to start working with the AI agent.
|
Open or create a project to start working with the AI agent.
|
||||||
</p>
|
</p>
|
||||||
@@ -479,7 +499,9 @@ export function AgentView() {
|
|||||||
<Bot className="w-5 h-5 text-primary" />
|
<Bot className="w-5 h-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
|
<h1 className="text-lg font-semibold text-foreground">
|
||||||
|
AI Agent
|
||||||
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{currentProject.name}
|
{currentProject.name}
|
||||||
{currentSessionId && !isConnected && " - Connecting..."}
|
{currentSessionId && !isConnected && " - Connecting..."}
|
||||||
@@ -489,6 +511,43 @@ export function AgentView() {
|
|||||||
|
|
||||||
{/* Status indicators & actions */}
|
{/* Status indicators & actions */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Model Selector */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 text-xs font-medium"
|
||||||
|
disabled={isProcessing}
|
||||||
|
data-testid="model-selector"
|
||||||
|
>
|
||||||
|
<Bot className="w-3.5 h-3.5" />
|
||||||
|
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace("Claude ", "") || "Sonnet"}
|
||||||
|
<ChevronDown className="w-3 h-3 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
{CLAUDE_MODELS.map((model) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={model.id}
|
||||||
|
onClick={() => setSelectedModel(model.id)}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
|
selectedModel === model.id && "bg-accent"
|
||||||
|
)}
|
||||||
|
data-testid={`model-option-${model.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{model.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{model.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
{currentTool && (
|
{currentTool && (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border">
|
||||||
<Wrench className="w-3 h-3 text-primary" />
|
<Wrench className="w-3 h-3 text-primary" />
|
||||||
@@ -496,7 +555,9 @@ export function AgentView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{agentError && (
|
{agentError && (
|
||||||
<span className="text-xs text-destructive font-medium">{agentError}</span>
|
<span className="text-xs text-destructive font-medium">
|
||||||
|
{agentError}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{currentSessionId && messages.length > 0 && (
|
{currentSessionId && messages.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
@@ -588,6 +649,50 @@ export function AgentView() {
|
|||||||
{message.content}
|
{message.content}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Display attached images for user messages */}
|
||||||
|
{message.role === "user" &&
|
||||||
|
message.images &&
|
||||||
|
message.images.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
|
||||||
|
<ImageIcon className="w-3 h-3" />
|
||||||
|
<span>
|
||||||
|
{message.images.length} image
|
||||||
|
{message.images.length > 1 ? "s" : ""} attached
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{message.images.map((image, index) => {
|
||||||
|
// Construct proper data URL from base64 data and mime type
|
||||||
|
const dataUrl = image.data.startsWith("data:")
|
||||||
|
? image.data
|
||||||
|
: `data:${image.mimeType || "image/png"};base64,${
|
||||||
|
image.data
|
||||||
|
}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={image.id || `img-${index}`}
|
||||||
|
className="relative group rounded-lg overflow-hidden border border-primary-foreground/20 bg-primary-foreground/10"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={dataUrl}
|
||||||
|
alt={
|
||||||
|
image.filename ||
|
||||||
|
`Attached image ${index + 1}`
|
||||||
|
}
|
||||||
|
className="w-20 h-20 object-cover hover:opacity-90 transition-opacity"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-black/50 px-1.5 py-0.5 text-[9px] text-white truncate">
|
||||||
|
{image.filename || `Image ${index + 1}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-[11px] mt-2 font-medium",
|
"text-[11px] mt-2 font-medium",
|
||||||
@@ -614,9 +719,18 @@ export function AgentView() {
|
|||||||
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
|
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" style={{ animationDelay: "0ms" }} />
|
<span
|
||||||
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" style={{ animationDelay: "150ms" }} />
|
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||||
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" style={{ animationDelay: "300ms" }} />
|
style={{ animationDelay: "0ms" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||||
|
style={{ animationDelay: "150ms" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||||
|
style={{ animationDelay: "300ms" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Thinking...
|
Thinking...
|
||||||
@@ -677,18 +791,22 @@ export function AgentView() {
|
|||||||
<p className="text-xs font-medium text-foreground truncate max-w-24">
|
<p className="text-xs font-medium text-foreground truncate max-w-24">
|
||||||
{image.filename}
|
{image.filename}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
{image.size !== undefined && (
|
||||||
{formatFileSize(image.size)}
|
<p className="text-[10px] text-muted-foreground">
|
||||||
</p>
|
{formatFileSize(image.size)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Remove button */}
|
{/* Remove button */}
|
||||||
<button
|
{image.id && (
|
||||||
onClick={() => removeImage(image.id)}
|
<button
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
onClick={() => removeImage(image.id!)}
|
||||||
disabled={isProcessing}
|
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||||
>
|
disabled={isProcessing}
|
||||||
<X className="h-3 w-3" />
|
>
|
||||||
</button>
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -729,7 +847,8 @@ export function AgentView() {
|
|||||||
/>
|
/>
|
||||||
{selectedImages.length > 0 && !isDragOver && (
|
{selectedImages.length > 0 && !isDragOver && (
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
|
||||||
{selectedImages.length} image{selectedImages.length > 1 ? "s" : ""}
|
{selectedImages.length} image
|
||||||
|
{selectedImages.length > 1 ? "s" : ""}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isDragOver && (
|
{isDragOver && (
|
||||||
@@ -748,7 +867,8 @@ export function AgentView() {
|
|||||||
disabled={isProcessing || !isConnected}
|
disabled={isProcessing || !isConnected}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-11 w-11 rounded-xl border-border",
|
"h-11 w-11 rounded-xl border-border",
|
||||||
showImageDropZone && "bg-primary/10 text-primary border-primary/30",
|
showImageDropZone &&
|
||||||
|
"bg-primary/10 text-primary border-primary/30",
|
||||||
selectedImages.length > 0 && "border-primary/30 text-primary"
|
selectedImages.length > 0 && "border-primary/30 text-primary"
|
||||||
)}
|
)}
|
||||||
title="Attach images"
|
title="Attach images"
|
||||||
@@ -773,7 +893,11 @@ export function AgentView() {
|
|||||||
|
|
||||||
{/* Keyboard hint */}
|
{/* Keyboard hint */}
|
||||||
<p className="text-[11px] text-muted-foreground mt-2 text-center">
|
<p className="text-[11px] text-muted-foreground mt-2 text-center">
|
||||||
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to send
|
Press{" "}
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">
|
||||||
|
Enter
|
||||||
|
</kbd>{" "}
|
||||||
|
to send
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
171
apps/app/src/components/views/board-view/board-controls.tsx
Normal file
171
apps/app/src/components/views/board-view/board-controls.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { ImageIcon, Archive, Minimize2, Square, Maximize2, History, Trash2, Layout } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
|
||||||
|
interface BoardControlsProps {
|
||||||
|
isMounted: boolean;
|
||||||
|
onShowBoardBackground: () => void;
|
||||||
|
onShowCompletedModal: () => void;
|
||||||
|
completedCount: number;
|
||||||
|
kanbanCardDetailLevel: "minimal" | "standard" | "detailed";
|
||||||
|
onDetailLevelChange: (level: "minimal" | "standard" | "detailed") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoardControls({
|
||||||
|
isMounted,
|
||||||
|
onShowBoardBackground,
|
||||||
|
onShowCompletedModal,
|
||||||
|
completedCount,
|
||||||
|
kanbanCardDetailLevel,
|
||||||
|
onDetailLevelChange,
|
||||||
|
}: BoardControlsProps) {
|
||||||
|
const { getEffectiveTheme } = useAppStore();
|
||||||
|
const effectiveTheme = getEffectiveTheme();
|
||||||
|
const isCleanTheme = effectiveTheme === "clean";
|
||||||
|
|
||||||
|
if (!isMounted) return null;
|
||||||
|
|
||||||
|
if (isCleanTheme) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 ml-6">
|
||||||
|
<button
|
||||||
|
className="p-2.5 glass rounded-xl text-slate-500 hover:text-white transition"
|
||||||
|
onClick={onShowCompletedModal}
|
||||||
|
>
|
||||||
|
<History className="w-[18px] h-[18px]" />
|
||||||
|
</button>
|
||||||
|
<button className="p-2.5 glass rounded-xl text-slate-500 hover:text-white transition">
|
||||||
|
<Trash2 className="w-[18px] h-[18px]" />
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-6 bg-white/10 mx-1"></div>
|
||||||
|
<button
|
||||||
|
className="p-2.5 glass rounded-xl text-slate-500 hover:text-white transition"
|
||||||
|
onClick={onShowBoardBackground}
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-[18px] h-[18px]" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-2.5 glass rounded-xl text-slate-500 hover:text-white transition"
|
||||||
|
onClick={() => onDetailLevelChange(kanbanCardDetailLevel === 'minimal' ? 'standard' : kanbanCardDetailLevel === 'standard' ? 'detailed' : 'minimal')}
|
||||||
|
>
|
||||||
|
<Layout className="w-[18px] h-[18px]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
{/* Board Background Button */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onShowBoardBackground}
|
||||||
|
className="h-8 px-2"
|
||||||
|
data-testid="board-background-button"
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Board Background Settings</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Completed/Archived Features Button */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onShowCompletedModal}
|
||||||
|
className="h-8 px-2 relative"
|
||||||
|
data-testid="completed-features-button"
|
||||||
|
>
|
||||||
|
<Archive className="w-4 h-4" />
|
||||||
|
{completedCount > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
|
||||||
|
{completedCount > 99 ? "99+" : completedCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Completed Features ({completedCount})</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Kanban Card Detail Level Toggle */}
|
||||||
|
<div
|
||||||
|
className="flex items-center rounded-lg bg-secondary border border-border"
|
||||||
|
data-testid="kanban-detail-toggle"
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => onDetailLevelChange("minimal")}
|
||||||
|
className={cn(
|
||||||
|
"p-2 rounded-l-lg transition-colors",
|
||||||
|
kanbanCardDetailLevel === "minimal"
|
||||||
|
? "bg-brand-500/20 text-brand-500"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
data-testid="kanban-toggle-minimal"
|
||||||
|
>
|
||||||
|
<Minimize2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Minimal - Title & category only</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => onDetailLevelChange("standard")}
|
||||||
|
className={cn(
|
||||||
|
"p-2 transition-colors",
|
||||||
|
kanbanCardDetailLevel === "standard"
|
||||||
|
? "bg-brand-500/20 text-brand-500"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
data-testid="kanban-toggle-standard"
|
||||||
|
>
|
||||||
|
<Square className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Standard - Steps & progress</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => onDetailLevelChange("detailed")}
|
||||||
|
className={cn(
|
||||||
|
"p-2 rounded-r-lg transition-colors",
|
||||||
|
kanbanCardDetailLevel === "detailed"
|
||||||
|
? "bg-brand-500/20 text-brand-500"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
data-testid="kanban-toggle-detailed"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Detailed - Model, tools & tasks</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
apps/app/src/components/views/board-view/board-header.tsx
Normal file
158
apps/app/src/components/views/board-view/board-header.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { Play, StopCircle, Plus, Users } from "lucide-react";
|
||||||
|
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
|
||||||
|
interface BoardHeaderProps {
|
||||||
|
projectName: string;
|
||||||
|
maxConcurrency: number;
|
||||||
|
onConcurrencyChange: (value: number) => void;
|
||||||
|
isAutoModeRunning: boolean;
|
||||||
|
onStartAutoMode: () => void;
|
||||||
|
onStopAutoMode: () => void;
|
||||||
|
onAddFeature: () => void;
|
||||||
|
addFeatureShortcut: KeyboardShortcut;
|
||||||
|
isMounted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoardHeader({
|
||||||
|
projectName,
|
||||||
|
maxConcurrency,
|
||||||
|
onConcurrencyChange,
|
||||||
|
isAutoModeRunning,
|
||||||
|
onStartAutoMode,
|
||||||
|
onStopAutoMode,
|
||||||
|
onAddFeature,
|
||||||
|
addFeatureShortcut,
|
||||||
|
isMounted,
|
||||||
|
}: BoardHeaderProps) {
|
||||||
|
const { getEffectiveTheme } = useAppStore();
|
||||||
|
const effectiveTheme = getEffectiveTheme();
|
||||||
|
const isCleanTheme = effectiveTheme === "clean";
|
||||||
|
|
||||||
|
if (isCleanTheme) {
|
||||||
|
return (
|
||||||
|
<header className="h-16 flex items-center justify-between px-8 border-b border-white/5 bg-[#0b101a]/40 backdrop-blur-md z-20 shrink-0">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white tracking-tight">Kanban Board</h2>
|
||||||
|
<p className="text-[10px] text-slate-500 uppercase tracking-[0.2em] font-bold mono">
|
||||||
|
{projectName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
{/* Concurrency Display (Visual only to match mockup for now, or interactive if needed) */}
|
||||||
|
<div className="flex items-center bg-white/5 border border-white/10 rounded-full px-4 py-1.5 gap-3">
|
||||||
|
<Users className="w-4 h-4 text-slate-500" />
|
||||||
|
<div className="toggle-track">
|
||||||
|
<div className="toggle-thumb"></div>
|
||||||
|
</div>
|
||||||
|
<span className="mono text-xs font-bold text-slate-400">{maxConcurrency}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto Mode Button */}
|
||||||
|
{isAutoModeRunning ? (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 glass px-5 py-2 rounded-xl text-xs font-bold hover:bg-white/10 transition text-rose-400 border-rose-500/30"
|
||||||
|
onClick={onStopAutoMode}
|
||||||
|
>
|
||||||
|
<StopCircle className="w-3.5 h-3.5" /> Stop
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 glass px-5 py-2 rounded-xl text-xs font-bold hover:bg-white/10 transition"
|
||||||
|
onClick={onStartAutoMode}
|
||||||
|
>
|
||||||
|
<Play className="w-3.5 h-3.5 text-cyan-400 fill-cyan-400" /> Auto Mode
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Feature Button */}
|
||||||
|
<button
|
||||||
|
className="btn-cyan px-6 py-2 rounded-xl text-xs font-black flex items-center gap-2 shadow-lg shadow-cyan-500/20"
|
||||||
|
onClick={onAddFeature}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 stroke-[3.5px]" /> ADD FEATURE
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Kanban Board</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{projectName}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
|
||||||
|
{isMounted && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border"
|
||||||
|
data-testid="concurrency-slider-container"
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<Slider
|
||||||
|
value={[maxConcurrency]}
|
||||||
|
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
step={1}
|
||||||
|
className="w-20"
|
||||||
|
data-testid="concurrency-slider"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-sm text-muted-foreground min-w-[2ch] text-center"
|
||||||
|
data-testid="concurrency-value"
|
||||||
|
>
|
||||||
|
{maxConcurrency}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
||||||
|
{isMounted && (
|
||||||
|
<>
|
||||||
|
{isAutoModeRunning ? (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={onStopAutoMode}
|
||||||
|
data-testid="stop-auto-mode"
|
||||||
|
>
|
||||||
|
<StopCircle className="w-4 h-4 mr-2" />
|
||||||
|
Stop Auto Mode
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={onStartAutoMode}
|
||||||
|
data-testid="start-auto-mode"
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
Auto Mode
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<HotkeyButton
|
||||||
|
size="sm"
|
||||||
|
onClick={onAddFeature}
|
||||||
|
hotkey={addFeatureShortcut}
|
||||||
|
hotkeyActive={false}
|
||||||
|
data-testid="add-feature-button"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Feature
|
||||||
|
</HotkeyButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
apps/app/src/components/views/board-view/board-search-bar.tsx
Normal file
112
apps/app/src/components/views/board-view/board-search-bar.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Search, X, Loader2 } from "lucide-react";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
|
||||||
|
interface BoardSearchBarProps {
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchChange: (query: string) => void;
|
||||||
|
isCreatingSpec: boolean;
|
||||||
|
creatingSpecProjectPath?: string;
|
||||||
|
currentProjectPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoardSearchBar({
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
isCreatingSpec,
|
||||||
|
creatingSpecProjectPath,
|
||||||
|
currentProjectPath,
|
||||||
|
}: BoardSearchBarProps) {
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { getEffectiveTheme } = useAppStore();
|
||||||
|
const effectiveTheme = getEffectiveTheme();
|
||||||
|
const isCleanTheme = effectiveTheme === "clean";
|
||||||
|
|
||||||
|
// Focus search input when "/" is pressed
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Only focus if not typing in an input/textarea
|
||||||
|
if (
|
||||||
|
e.key === "/" &&
|
||||||
|
!(e.target instanceof HTMLInputElement) &&
|
||||||
|
!(e.target instanceof HTMLTextAreaElement)
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isCleanTheme) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex-1 max-w-2xl group">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 group-focus-within:text-cyan-400 transition-colors" />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search features by keyword..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-2xl py-2.5 pl-12 pr-12 text-sm focus:outline-none focus:border-cyan-500/50 transition-all mono"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-4 top-1/2 -translate-y-1/2">
|
||||||
|
<span className="shortcut-badge">/</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative max-w-md flex-1 flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search features by keyword..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="pl-9 pr-12 border-border"
|
||||||
|
data-testid="kanban-search-input"
|
||||||
|
/>
|
||||||
|
{searchQuery ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onSearchChange("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-sm hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
data-testid="kanban-search-clear"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
|
||||||
|
data-testid="kanban-search-hotkey"
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Spec Creation Loading Badge */}
|
||||||
|
{isCreatingSpec &&
|
||||||
|
currentProjectPath === creatingSpecProjectPath && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-brand-500/10 border border-brand-500/20 shrink-0"
|
||||||
|
title="Creating App Specification"
|
||||||
|
data-testid="spec-creation-badge"
|
||||||
|
>
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
|
||||||
|
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
|
||||||
|
Creating spec
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { KanbanCard } from "./kanban-card";
|
||||||
|
export { KanbanColumn } from "./kanban-column";
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,198 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { useDroppable } from "@dnd-kit/core";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
|
||||||
|
interface KanbanColumnProps {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
colorClass: string;
|
||||||
|
count: number;
|
||||||
|
children: ReactNode;
|
||||||
|
headerAction?: ReactNode;
|
||||||
|
opacity?: number;
|
||||||
|
showBorder?: boolean;
|
||||||
|
hideScrollbar?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KanbanColumn = memo(function KanbanColumn({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
colorClass,
|
||||||
|
count,
|
||||||
|
children,
|
||||||
|
headerAction,
|
||||||
|
opacity = 100,
|
||||||
|
showBorder = true,
|
||||||
|
hideScrollbar = false,
|
||||||
|
}: KanbanColumnProps) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id });
|
||||||
|
const { getEffectiveTheme } = useAppStore();
|
||||||
|
const effectiveTheme = getEffectiveTheme();
|
||||||
|
const isCleanTheme = effectiveTheme === "clean";
|
||||||
|
|
||||||
|
// Map column IDs to clean theme classes
|
||||||
|
const getColumnClasses = () => {
|
||||||
|
switch (id) {
|
||||||
|
case "in_progress":
|
||||||
|
return "col-in-progress";
|
||||||
|
case "waiting_approval":
|
||||||
|
return "col-waiting";
|
||||||
|
case "verified":
|
||||||
|
return "col-verified";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map column IDs to status dot glow classes
|
||||||
|
const getStatusDotClasses = () => {
|
||||||
|
switch (id) {
|
||||||
|
case "in_progress":
|
||||||
|
return "status-dot-in-progress glow-cyan";
|
||||||
|
case "waiting_approval":
|
||||||
|
return "status-dot-waiting glow-orange";
|
||||||
|
case "verified":
|
||||||
|
return "status-dot-verified glow-green";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean theme column styles
|
||||||
|
if (isCleanTheme) {
|
||||||
|
const isBacklog = id === "backlog";
|
||||||
|
|
||||||
|
// Explicitly match mockup classes for status dots
|
||||||
|
const getCleanStatusDotClass = () => {
|
||||||
|
switch (id) {
|
||||||
|
case "backlog":
|
||||||
|
return "status-dot bg-slate-600";
|
||||||
|
case "in_progress":
|
||||||
|
return "status-dot bg-cyan-400 glow-cyan";
|
||||||
|
case "waiting_approval":
|
||||||
|
return "status-dot bg-orange-500 glow-orange";
|
||||||
|
case "verified":
|
||||||
|
return "status-dot bg-emerald-500 glow-green";
|
||||||
|
default:
|
||||||
|
return "status-dot bg-slate-600";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Explicitly match mockup classes for badges
|
||||||
|
const getBadgeClass = () => {
|
||||||
|
switch (id) {
|
||||||
|
case "in_progress":
|
||||||
|
return "mono text-[10px] bg-cyan-500/10 px-2.5 py-0.5 rounded-full text-cyan-400 border border-cyan-500/20";
|
||||||
|
case "verified":
|
||||||
|
return "mono text-[10px] bg-emerald-500/10 px-2.5 py-0.5 rounded-full text-emerald-500 border border-emerald-500/20";
|
||||||
|
case "backlog":
|
||||||
|
case "waiting_approval":
|
||||||
|
default:
|
||||||
|
return "mono text-[10px] bg-white/5 px-2.5 py-0.5 rounded-full text-slate-500 border border-white/5";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col h-full w-80 gap-5",
|
||||||
|
!isBacklog && "rounded-[2.5rem] p-3",
|
||||||
|
getColumnClasses()
|
||||||
|
)}
|
||||||
|
data-testid={`kanban-column-${id}`}
|
||||||
|
data-column-id={id}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-2 shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={getCleanStatusDotClass()} />
|
||||||
|
<h3 className={cn(
|
||||||
|
"text-[11px] font-black uppercase tracking-widest",
|
||||||
|
id === "backlog" ? "text-slate-400" :
|
||||||
|
id === "in_progress" ? "text-slate-200" : "text-slate-300"
|
||||||
|
)}>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{headerAction}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={getBadgeClass()}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex-1 overflow-y-auto custom-scrollbar space-y-4",
|
||||||
|
isBacklog ? "pr-2" : "pr-1",
|
||||||
|
hideScrollbar && "scrollbar-hide"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-col h-full rounded-xl transition-all duration-200 w-72 clean:w-80",
|
||||||
|
showBorder && "border border-border/60",
|
||||||
|
isOver && "ring-2 ring-primary/30 ring-offset-1 ring-offset-background",
|
||||||
|
getColumnClasses()
|
||||||
|
)}
|
||||||
|
data-testid={`kanban-column-${id}`}
|
||||||
|
data-column-id={id}
|
||||||
|
>
|
||||||
|
{/* Background layer with opacity */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200",
|
||||||
|
isOver ? "bg-accent/80" : "bg-card/80"
|
||||||
|
)}
|
||||||
|
style={{ opacity: opacity / 100 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Column Header */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex items-center gap-3 px-3 py-2.5",
|
||||||
|
showBorder && "border-b border-border/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn("w-2.5 h-2.5 rounded-full shrink-0 status-dot", colorClass, getStatusDotClasses())} />
|
||||||
|
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
|
||||||
|
{headerAction}
|
||||||
|
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column Content */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5",
|
||||||
|
hideScrollbar &&
|
||||||
|
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||||
|
// Smooth scrolling
|
||||||
|
"scroll-smooth"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drop zone indicator when dragging over */}
|
||||||
|
{isOver && (
|
||||||
|
<div className="absolute inset-0 rounded-xl bg-primary/5 pointer-events-none z-5 border-2 border-dashed border-primary/20" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
22
apps/app/src/components/views/board-view/constants.ts
Normal file
22
apps/app/src/components/views/board-view/constants.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Feature } from "@/store/app-store";
|
||||||
|
|
||||||
|
export type ColumnId = Feature["status"];
|
||||||
|
|
||||||
|
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
|
||||||
|
{ id: "backlog", title: "Backlog", colorClass: "bg-[var(--status-backlog)]" },
|
||||||
|
{
|
||||||
|
id: "in_progress",
|
||||||
|
title: "In Progress",
|
||||||
|
colorClass: "bg-[var(--status-in-progress)]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "waiting_approval",
|
||||||
|
title: "Waiting Approval",
|
||||||
|
colorClass: "bg-[var(--status-waiting)]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "verified",
|
||||||
|
title: "Verified",
|
||||||
|
colorClass: "bg-[var(--status-success)]",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
||||||
|
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
|
||||||
|
import {
|
||||||
|
DescriptionImageDropZone,
|
||||||
|
FeatureImagePath as DescriptionImagePath,
|
||||||
|
ImagePreviewMap,
|
||||||
|
} from "@/components/ui/description-image-dropzone";
|
||||||
|
import {
|
||||||
|
MessageSquare,
|
||||||
|
Settings2,
|
||||||
|
SlidersHorizontal,
|
||||||
|
FlaskConical,
|
||||||
|
Sparkles,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { modelSupportsThinking } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
useAppStore,
|
||||||
|
AgentModel,
|
||||||
|
ThinkingLevel,
|
||||||
|
FeatureImage,
|
||||||
|
AIProfile,
|
||||||
|
PlanningMode,
|
||||||
|
} from "@/store/app-store";
|
||||||
|
import {
|
||||||
|
ModelSelector,
|
||||||
|
ThinkingLevelSelector,
|
||||||
|
ProfileQuickSelect,
|
||||||
|
TestingTabContent,
|
||||||
|
PrioritySelector,
|
||||||
|
PlanningModeSelector,
|
||||||
|
} from "../shared";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
interface AddFeatureDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onAdd: (feature: {
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
steps: string[];
|
||||||
|
images: FeatureImage[];
|
||||||
|
imagePaths: DescriptionImagePath[];
|
||||||
|
skipTests: boolean;
|
||||||
|
model: AgentModel;
|
||||||
|
thinkingLevel: ThinkingLevel;
|
||||||
|
branchName: string;
|
||||||
|
priority: number;
|
||||||
|
planningMode: PlanningMode;
|
||||||
|
requirePlanApproval: boolean;
|
||||||
|
}) => void;
|
||||||
|
categorySuggestions: string[];
|
||||||
|
branchSuggestions: string[];
|
||||||
|
defaultSkipTests: boolean;
|
||||||
|
defaultBranch?: string;
|
||||||
|
isMaximized: boolean;
|
||||||
|
showProfilesOnly: boolean;
|
||||||
|
aiProfiles: AIProfile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddFeatureDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onAdd,
|
||||||
|
categorySuggestions,
|
||||||
|
branchSuggestions,
|
||||||
|
defaultSkipTests,
|
||||||
|
defaultBranch = "main",
|
||||||
|
isMaximized,
|
||||||
|
showProfilesOnly,
|
||||||
|
aiProfiles,
|
||||||
|
}: AddFeatureDialogProps) {
|
||||||
|
const [newFeature, setNewFeature] = useState({
|
||||||
|
category: "",
|
||||||
|
description: "",
|
||||||
|
steps: [""],
|
||||||
|
images: [] as FeatureImage[],
|
||||||
|
imagePaths: [] as DescriptionImagePath[],
|
||||||
|
skipTests: false,
|
||||||
|
model: "opus" as AgentModel,
|
||||||
|
thinkingLevel: "none" as ThinkingLevel,
|
||||||
|
branchName: "main",
|
||||||
|
priority: 2 as number, // Default to medium priority
|
||||||
|
});
|
||||||
|
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
|
||||||
|
useState<ImagePreviewMap>(() => new Map());
|
||||||
|
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||||
|
const [descriptionError, setDescriptionError] = useState(false);
|
||||||
|
const [isEnhancing, setIsEnhancing] = useState(false);
|
||||||
|
const [enhancementMode, setEnhancementMode] = useState<
|
||||||
|
"improve" | "technical" | "simplify" | "acceptance"
|
||||||
|
>("improve");
|
||||||
|
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||||
|
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||||
|
|
||||||
|
// Get enhancement model, planning mode defaults, and worktrees setting from store
|
||||||
|
const { enhancementModel, defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
|
||||||
|
|
||||||
|
// Sync defaults when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setNewFeature((prev) => ({
|
||||||
|
...prev,
|
||||||
|
skipTests: defaultSkipTests,
|
||||||
|
branchName: defaultBranch,
|
||||||
|
}));
|
||||||
|
setPlanningMode(defaultPlanningMode);
|
||||||
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
|
}
|
||||||
|
}, [open, defaultSkipTests, defaultBranch, defaultPlanningMode, defaultRequirePlanApproval]);
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!newFeature.description.trim()) {
|
||||||
|
setDescriptionError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = newFeature.category || "Uncategorized";
|
||||||
|
const selectedModel = newFeature.model;
|
||||||
|
const normalizedThinking = modelSupportsThinking(selectedModel)
|
||||||
|
? newFeature.thinkingLevel
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
onAdd({
|
||||||
|
category,
|
||||||
|
description: newFeature.description,
|
||||||
|
steps: newFeature.steps.filter((s) => s.trim()),
|
||||||
|
images: newFeature.images,
|
||||||
|
imagePaths: newFeature.imagePaths,
|
||||||
|
skipTests: newFeature.skipTests,
|
||||||
|
model: selectedModel,
|
||||||
|
thinkingLevel: normalizedThinking,
|
||||||
|
branchName: newFeature.branchName,
|
||||||
|
priority: newFeature.priority,
|
||||||
|
planningMode,
|
||||||
|
requirePlanApproval,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setNewFeature({
|
||||||
|
category: "",
|
||||||
|
description: "",
|
||||||
|
steps: [""],
|
||||||
|
images: [],
|
||||||
|
imagePaths: [],
|
||||||
|
skipTests: defaultSkipTests,
|
||||||
|
model: "opus",
|
||||||
|
priority: 2,
|
||||||
|
thinkingLevel: "none",
|
||||||
|
branchName: defaultBranch,
|
||||||
|
});
|
||||||
|
setPlanningMode(defaultPlanningMode);
|
||||||
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
|
setNewFeaturePreviewMap(new Map());
|
||||||
|
setShowAdvancedOptions(false);
|
||||||
|
setDescriptionError(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogClose = (open: boolean) => {
|
||||||
|
onOpenChange(open);
|
||||||
|
if (!open) {
|
||||||
|
setNewFeaturePreviewMap(new Map());
|
||||||
|
setShowAdvancedOptions(false);
|
||||||
|
setDescriptionError(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnhanceDescription = async () => {
|
||||||
|
if (!newFeature.description.trim() || isEnhancing) return;
|
||||||
|
|
||||||
|
setIsEnhancing(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.enhancePrompt?.enhance(
|
||||||
|
newFeature.description,
|
||||||
|
enhancementMode,
|
||||||
|
enhancementModel
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result?.success && result.enhancedText) {
|
||||||
|
const enhancedText = result.enhancedText;
|
||||||
|
setNewFeature((prev) => ({ ...prev, description: enhancedText }));
|
||||||
|
toast.success("Description enhanced!");
|
||||||
|
} else {
|
||||||
|
toast.error(result?.error || "Failed to enhance description");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Enhancement failed:", error);
|
||||||
|
toast.error("Failed to enhance description");
|
||||||
|
} finally {
|
||||||
|
setIsEnhancing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelSelect = (model: AgentModel) => {
|
||||||
|
setNewFeature({
|
||||||
|
...newFeature,
|
||||||
|
model,
|
||||||
|
thinkingLevel: modelSupportsThinking(model)
|
||||||
|
? newFeature.thinkingLevel
|
||||||
|
: "none",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProfileSelect = (
|
||||||
|
model: AgentModel,
|
||||||
|
thinkingLevel: ThinkingLevel
|
||||||
|
) => {
|
||||||
|
setNewFeature({
|
||||||
|
...newFeature,
|
||||||
|
model,
|
||||||
|
thinkingLevel,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const newModelAllowsThinking = modelSupportsThinking(newFeature.model);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleDialogClose}>
|
||||||
|
<DialogContent
|
||||||
|
compact={!isMaximized}
|
||||||
|
data-testid="add-feature-dialog"
|
||||||
|
onPointerDownOutside={(e: CustomEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onInteractOutside={(e: CustomEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add New Feature</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new feature card for the Kanban board.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Tabs
|
||||||
|
defaultValue="prompt"
|
||||||
|
className="py-4 flex-1 min-h-0 flex flex-col"
|
||||||
|
>
|
||||||
|
<TabsList className="w-full grid grid-cols-3 mb-4">
|
||||||
|
<TabsTrigger value="prompt" data-testid="tab-prompt">
|
||||||
|
<MessageSquare className="w-4 h-4 mr-2" />
|
||||||
|
Prompt
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="model" data-testid="tab-model">
|
||||||
|
<Settings2 className="w-4 h-4 mr-2" />
|
||||||
|
Model
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="options" data-testid="tab-options">
|
||||||
|
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
||||||
|
Options
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Prompt Tab */}
|
||||||
|
<TabsContent
|
||||||
|
value="prompt"
|
||||||
|
className="space-y-4 overflow-y-auto cursor-default"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<DescriptionImageDropZone
|
||||||
|
value={newFeature.description}
|
||||||
|
onChange={(value) => {
|
||||||
|
setNewFeature({ ...newFeature, description: value });
|
||||||
|
if (value.trim()) {
|
||||||
|
setDescriptionError(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
images={newFeature.imagePaths}
|
||||||
|
onImagesChange={(images) =>
|
||||||
|
setNewFeature({ ...newFeature, imagePaths: images })
|
||||||
|
}
|
||||||
|
placeholder="Describe the feature..."
|
||||||
|
previewMap={newFeaturePreviewMap}
|
||||||
|
onPreviewMapChange={setNewFeaturePreviewMap}
|
||||||
|
autoFocus
|
||||||
|
error={descriptionError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-[200px] justify-between"
|
||||||
|
>
|
||||||
|
{enhancementMode === "improve" && "Improve Clarity"}
|
||||||
|
{enhancementMode === "technical" && "Add Technical Details"}
|
||||||
|
{enhancementMode === "simplify" && "Simplify"}
|
||||||
|
{enhancementMode === "acceptance" &&
|
||||||
|
"Add Acceptance Criteria"}
|
||||||
|
<ChevronDown className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setEnhancementMode("improve")}
|
||||||
|
>
|
||||||
|
Improve Clarity
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setEnhancementMode("technical")}
|
||||||
|
>
|
||||||
|
Add Technical Details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setEnhancementMode("simplify")}
|
||||||
|
>
|
||||||
|
Simplify
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setEnhancementMode("acceptance")}
|
||||||
|
>
|
||||||
|
Add Acceptance Criteria
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleEnhanceDescription}
|
||||||
|
disabled={!newFeature.description.trim() || isEnhancing}
|
||||||
|
loading={isEnhancing}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
|
Enhance with AI
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="category">Category (optional)</Label>
|
||||||
|
<CategoryAutocomplete
|
||||||
|
value={newFeature.category}
|
||||||
|
onChange={(value) =>
|
||||||
|
setNewFeature({ ...newFeature, category: value })
|
||||||
|
}
|
||||||
|
suggestions={categorySuggestions}
|
||||||
|
placeholder="e.g., Core, UI, API"
|
||||||
|
data-testid="feature-category-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{useWorktrees && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="branch">Target Branch</Label>
|
||||||
|
<BranchAutocomplete
|
||||||
|
value={newFeature.branchName}
|
||||||
|
onChange={(value) =>
|
||||||
|
setNewFeature({ ...newFeature, branchName: value })
|
||||||
|
}
|
||||||
|
branches={branchSuggestions}
|
||||||
|
placeholder="Select or create branch..."
|
||||||
|
data-testid="feature-branch-input"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Work will be done in this branch. A worktree will be created if
|
||||||
|
needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Priority Selector */}
|
||||||
|
<PrioritySelector
|
||||||
|
selectedPriority={newFeature.priority}
|
||||||
|
onPrioritySelect={(priority) =>
|
||||||
|
setNewFeature({ ...newFeature, priority })
|
||||||
|
}
|
||||||
|
testIdPrefix="priority"
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Model Tab */}
|
||||||
|
<TabsContent
|
||||||
|
value="model"
|
||||||
|
className="space-y-4 overflow-y-auto cursor-default"
|
||||||
|
>
|
||||||
|
{/* Show Advanced Options Toggle */}
|
||||||
|
{showProfilesOnly && (
|
||||||
|
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
Simple Mode Active
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Only showing AI profiles. Advanced model tweaking is hidden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
|
||||||
|
data-testid="show-advanced-options-toggle"
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4 mr-2" />
|
||||||
|
{showAdvancedOptions ? "Hide" : "Show"} Advanced
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Select Profile Section */}
|
||||||
|
<ProfileQuickSelect
|
||||||
|
profiles={aiProfiles}
|
||||||
|
selectedModel={newFeature.model}
|
||||||
|
selectedThinkingLevel={newFeature.thinkingLevel}
|
||||||
|
onSelect={handleProfileSelect}
|
||||||
|
showManageLink
|
||||||
|
onManageLinkClick={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
useAppStore.getState().setCurrentView("profiles");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
{aiProfiles.length > 0 &&
|
||||||
|
(!showProfilesOnly || showAdvancedOptions) && (
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Claude Models Section */}
|
||||||
|
{(!showProfilesOnly || showAdvancedOptions) && (
|
||||||
|
<>
|
||||||
|
<ModelSelector
|
||||||
|
selectedModel={newFeature.model}
|
||||||
|
onModelSelect={handleModelSelect}
|
||||||
|
/>
|
||||||
|
{newModelAllowsThinking && (
|
||||||
|
<ThinkingLevelSelector
|
||||||
|
selectedLevel={newFeature.thinkingLevel}
|
||||||
|
onLevelSelect={(level) =>
|
||||||
|
setNewFeature({ ...newFeature, thinkingLevel: level })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Options Tab */}
|
||||||
|
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
|
||||||
|
{/* Planning Mode Section */}
|
||||||
|
<PlanningModeSelector
|
||||||
|
mode={planningMode}
|
||||||
|
onModeChange={setPlanningMode}
|
||||||
|
requireApproval={requirePlanApproval}
|
||||||
|
onRequireApprovalChange={setRequirePlanApproval}
|
||||||
|
featureDescription={newFeature.description}
|
||||||
|
testIdPrefix="add-feature"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="border-t border-border my-4" />
|
||||||
|
|
||||||
|
{/* Testing Section */}
|
||||||
|
<TestingTabContent
|
||||||
|
skipTests={newFeature.skipTests}
|
||||||
|
onSkipTestsChange={(skipTests) =>
|
||||||
|
setNewFeature({ ...newFeature, skipTests })
|
||||||
|
}
|
||||||
|
steps={newFeature.steps}
|
||||||
|
onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<HotkeyButton
|
||||||
|
onClick={handleAdd}
|
||||||
|
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||||
|
hotkeyActive={open}
|
||||||
|
data-testid="confirm-add-feature"
|
||||||
|
>
|
||||||
|
Add Feature
|
||||||
|
</HotkeyButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { Loader2, List, FileText, GitBranch } from "lucide-react";
|
|||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
import { LogViewer } from "@/components/ui/log-viewer";
|
import { LogViewer } from "@/components/ui/log-viewer";
|
||||||
import { GitDiffPanel } from "@/components/ui/git-diff-panel";
|
import { GitDiffPanel } from "@/components/ui/git-diff-panel";
|
||||||
|
import { TaskProgressPanel } from "@/components/ui/task-progress-panel";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
import type { AutoModeEvent } from "@/types/electron";
|
import type { AutoModeEvent } from "@/types/electron";
|
||||||
|
|
||||||
@@ -99,24 +100,6 @@ export function AgentOutputModal({
|
|||||||
loadOutput();
|
loadOutput();
|
||||||
}, [open, featureId]);
|
}, [open, featureId]);
|
||||||
|
|
||||||
// Save output to file
|
|
||||||
const saveOutput = async (newContent: string) => {
|
|
||||||
if (!projectPathRef.current) return;
|
|
||||||
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use features API - agent output is stored in features/{id}/agent-output.md
|
|
||||||
// We need to write it directly since there's no updateAgentOutput method
|
|
||||||
// The context-manager handles this on the backend, but for frontend edits we write directly
|
|
||||||
const outputPath = `${projectPathRef.current}/.automaker/features/${featureId}/agent-output.md`;
|
|
||||||
await api.writeFile(outputPath, newContent);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save output:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Listen to auto mode events and update output
|
// Listen to auto mode events and update output
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -142,7 +125,7 @@ export function AgentOutputModal({
|
|||||||
? JSON.stringify(event.input, null, 2)
|
? JSON.stringify(event.input, null, 2)
|
||||||
: "";
|
: "";
|
||||||
newContent = `\n🔧 Tool: ${toolName}\n${
|
newContent = `\n🔧 Tool: ${toolName}\n${
|
||||||
toolInput ? `Input: ${toolInput}` : ""
|
toolInput ? `Input: ${toolInput}\n` : ""
|
||||||
}`;
|
}`;
|
||||||
break;
|
break;
|
||||||
case "auto_mode_phase":
|
case "auto_mode_phase":
|
||||||
@@ -187,6 +170,64 @@ export function AgentOutputModal({
|
|||||||
|
|
||||||
newContent = prepContent;
|
newContent = prepContent;
|
||||||
break;
|
break;
|
||||||
|
case "planning_started":
|
||||||
|
// Show when planning mode begins
|
||||||
|
if ("mode" in event && "message" in event) {
|
||||||
|
const modeLabel =
|
||||||
|
event.mode === "lite"
|
||||||
|
? "Lite"
|
||||||
|
: event.mode === "spec"
|
||||||
|
? "Spec"
|
||||||
|
: "Full";
|
||||||
|
newContent = `\n📋 Planning Mode: ${modeLabel}\n${event.message}\n`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "plan_approval_required":
|
||||||
|
// Show when plan requires approval
|
||||||
|
if ("planningMode" in event) {
|
||||||
|
newContent = `\n⏸️ Plan generated - waiting for your approval...\n`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "plan_approved":
|
||||||
|
// Show when plan is manually approved
|
||||||
|
if ("hasEdits" in event) {
|
||||||
|
newContent = event.hasEdits
|
||||||
|
? `\n✅ Plan approved (with edits) - continuing to implementation...\n`
|
||||||
|
: `\n✅ Plan approved - continuing to implementation...\n`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "plan_auto_approved":
|
||||||
|
// Show when plan is auto-approved
|
||||||
|
newContent = `\n✅ Plan auto-approved - continuing to implementation...\n`;
|
||||||
|
break;
|
||||||
|
case "plan_revision_requested":
|
||||||
|
// Show when user requests plan revision
|
||||||
|
if ("planVersion" in event) {
|
||||||
|
const revisionEvent = event as Extract<AutoModeEvent, { type: "plan_revision_requested" }>;
|
||||||
|
newContent = `\n🔄 Revising plan based on your feedback (v${revisionEvent.planVersion})...\n`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "auto_mode_task_started":
|
||||||
|
// Show when a task starts
|
||||||
|
if ("taskId" in event && "taskDescription" in event) {
|
||||||
|
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_started" }>;
|
||||||
|
newContent = `\n▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}\n`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "auto_mode_task_complete":
|
||||||
|
// Show task completion progress
|
||||||
|
if ("taskId" in event && "tasksCompleted" in event && "tasksTotal" in event) {
|
||||||
|
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_complete" }>;
|
||||||
|
newContent = `\n✓ ${taskEvent.taskId} completed (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})\n`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "auto_mode_phase_complete":
|
||||||
|
// Show phase completion for full mode
|
||||||
|
if ("phaseNumber" in event) {
|
||||||
|
const phaseEvent = event as Extract<AutoModeEvent, { type: "auto_mode_phase_complete" }>;
|
||||||
|
newContent = `\n🏁 Phase ${phaseEvent.phaseNumber} complete\n`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "auto_mode_feature_complete":
|
case "auto_mode_feature_complete":
|
||||||
const emoji = event.passes ? "✅" : "⚠️";
|
const emoji = event.passes ? "✅" : "⚠️";
|
||||||
newContent = `\n${emoji} Task completed: ${event.message}\n`;
|
newContent = `\n${emoji} Task completed: ${event.message}\n`;
|
||||||
@@ -202,11 +243,8 @@ export function AgentOutputModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newContent) {
|
if (newContent) {
|
||||||
setOutput((prev) => {
|
// Only update local state - server is the single source of truth for file writes
|
||||||
const updated = prev + newContent;
|
setOutput((prev) => prev + newContent);
|
||||||
saveOutput(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,6 +347,13 @@ export function AgentOutputModal({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Task Progress Panel - shows when tasks are being executed */}
|
||||||
|
<TaskProgressPanel
|
||||||
|
featureId={featureId}
|
||||||
|
projectPath={projectPath}
|
||||||
|
className="flex-shrink-0 mx-1"
|
||||||
|
/>
|
||||||
|
|
||||||
{viewMode === "changes" ? (
|
{viewMode === "changes" ? (
|
||||||
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
|
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
|
||||||
{projectPath ? (
|
{projectPath ? (
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { GitCommit, Loader2 } from "lucide-react";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface WorktreeInfo {
|
||||||
|
path: string;
|
||||||
|
branch: string;
|
||||||
|
isMain: boolean;
|
||||||
|
hasChanges?: boolean;
|
||||||
|
changedFilesCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommitWorktreeDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
worktree: WorktreeInfo | null;
|
||||||
|
onCommitted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommitWorktreeDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
worktree,
|
||||||
|
onCommitted,
|
||||||
|
}: CommitWorktreeDialogProps) {
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCommit = async () => {
|
||||||
|
if (!worktree || !message.trim()) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.commit) {
|
||||||
|
setError("Worktree API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await api.worktree.commit(worktree.path, message);
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
if (result.result.committed) {
|
||||||
|
toast.success("Changes committed", {
|
||||||
|
description: `Commit ${result.result.commitHash} on ${result.result.branch}`,
|
||||||
|
});
|
||||||
|
onCommitted();
|
||||||
|
onOpenChange(false);
|
||||||
|
setMessage("");
|
||||||
|
} else {
|
||||||
|
toast.info("No changes to commit", {
|
||||||
|
description: result.result.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(result.error || "Failed to commit changes");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to commit");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && e.metaKey && !isLoading && message.trim()) {
|
||||||
|
handleCommit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!worktree) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<GitCommit className="w-5 h-5" />
|
||||||
|
Commit Changes
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Commit changes in the{" "}
|
||||||
|
<code className="font-mono bg-muted px-1 rounded">
|
||||||
|
{worktree.branch}
|
||||||
|
</code>{" "}
|
||||||
|
worktree.
|
||||||
|
{worktree.changedFilesCount && (
|
||||||
|
<span className="ml-1">
|
||||||
|
({worktree.changedFilesCount} file
|
||||||
|
{worktree.changedFilesCount > 1 ? "s" : ""} changed)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="commit-message">Commit Message</Label>
|
||||||
|
<Textarea
|
||||||
|
id="commit-message"
|
||||||
|
placeholder="Describe your changes..."
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => {
|
||||||
|
setMessage(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="min-h-[100px] font-mono text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Press <kbd className="px-1 py-0.5 bg-muted rounded text-xs">Cmd+Enter</kbd> to commit
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCommit}
|
||||||
|
disabled={isLoading || !message.trim()}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Committing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<GitCommit className="w-4 h-4 mr-2" />
|
||||||
|
Commit
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { ArchiveRestore, Trash2 } from "lucide-react";
|
||||||
|
import { Feature } from "@/store/app-store";
|
||||||
|
|
||||||
|
interface CompletedFeaturesModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
completedFeatures: Feature[];
|
||||||
|
onUnarchive: (feature: Feature) => void;
|
||||||
|
onDelete: (feature: Feature) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompletedFeaturesModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
completedFeatures,
|
||||||
|
onUnarchive,
|
||||||
|
onDelete,
|
||||||
|
}: CompletedFeaturesModalProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-6xl max-h-[90vh] flex flex-col"
|
||||||
|
data-testid="completed-features-modal"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Completed Features</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{completedFeatures.length === 0
|
||||||
|
? "No completed features yet."
|
||||||
|
: `${completedFeatures.length} completed feature${
|
||||||
|
completedFeatures.length > 1 ? "s" : ""
|
||||||
|
}`}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 overflow-y-auto py-4">
|
||||||
|
{completedFeatures.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">
|
||||||
|
<ArchiveRestore className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p>No completed features</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{completedFeatures.map((feature) => (
|
||||||
|
<Card
|
||||||
|
key={feature.id}
|
||||||
|
className="flex flex-col"
|
||||||
|
data-testid={`completed-card-${feature.id}`}
|
||||||
|
>
|
||||||
|
<CardHeader className="p-3 pb-2 flex-1">
|
||||||
|
<CardTitle className="text-sm leading-tight line-clamp-3">
|
||||||
|
{feature.description || feature.summary || feature.id}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs mt-1 truncate">
|
||||||
|
{feature.category || "Uncategorized"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<div className="p-3 pt-0 flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-7 text-xs"
|
||||||
|
onClick={() => onUnarchive(feature)}
|
||||||
|
data-testid={`unarchive-${feature.id}`}
|
||||||
|
>
|
||||||
|
<ArchiveRestore className="w-3 h-3 mr-1" />
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => onDelete(feature)}
|
||||||
|
data-testid={`delete-completed-${feature.id}`}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { GitBranchPlus, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface WorktreeInfo {
|
||||||
|
path: string;
|
||||||
|
branch: string;
|
||||||
|
isMain: boolean;
|
||||||
|
hasChanges?: boolean;
|
||||||
|
changedFilesCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateBranchDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
worktree: WorktreeInfo | null;
|
||||||
|
onCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateBranchDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
worktree,
|
||||||
|
onCreated,
|
||||||
|
}: CreateBranchDialogProps) {
|
||||||
|
const [branchName, setBranchName] = useState("");
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Reset state when dialog opens/closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setBranchName("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!worktree || !branchName.trim()) return;
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
const invalidChars = /[\s~^:?*[\]\\]/;
|
||||||
|
if (invalidChars.test(branchName)) {
|
||||||
|
setError("Branch name contains invalid characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreating(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.checkoutBranch) {
|
||||||
|
toast.error("Branch API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.worktree.checkoutBranch(worktree.path, branchName.trim());
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
toast.success(result.result.message);
|
||||||
|
onCreated();
|
||||||
|
onOpenChange(false);
|
||||||
|
} else {
|
||||||
|
setError(result.error || "Failed to create branch");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Create branch failed:", err);
|
||||||
|
setError("Failed to create branch");
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<GitBranchPlus className="w-5 h-5" />
|
||||||
|
Create New Branch
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new branch from <span className="font-mono text-foreground">{worktree?.branch || "current branch"}</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="branch-name">Branch Name</Label>
|
||||||
|
<Input
|
||||||
|
id="branch-name"
|
||||||
|
placeholder="feature/my-new-feature"
|
||||||
|
value={branchName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBranchName(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && branchName.trim() && !isCreating) {
|
||||||
|
handleCreate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isCreating}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!branchName.trim() || isCreating}
|
||||||
|
>
|
||||||
|
{isCreating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Create Branch"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { GitPullRequest, Loader2, ExternalLink } from "lucide-react";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface WorktreeInfo {
|
||||||
|
path: string;
|
||||||
|
branch: string;
|
||||||
|
isMain: boolean;
|
||||||
|
hasChanges?: boolean;
|
||||||
|
changedFilesCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatePRDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
worktree: WorktreeInfo | null;
|
||||||
|
onCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreatePRDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
worktree,
|
||||||
|
onCreated,
|
||||||
|
}: CreatePRDialogProps) {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [baseBranch, setBaseBranch] = useState("main");
|
||||||
|
const [commitMessage, setCommitMessage] = useState("");
|
||||||
|
const [isDraft, setIsDraft] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [prUrl, setPrUrl] = useState<string | null>(null);
|
||||||
|
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
|
||||||
|
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
|
||||||
|
|
||||||
|
// Reset state when dialog opens or worktree changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
// Only reset form fields, not the result states (prUrl, browserUrl, showBrowserFallback)
|
||||||
|
// These are set by the API response and should persist until dialog closes
|
||||||
|
setTitle("");
|
||||||
|
setBody("");
|
||||||
|
setCommitMessage("");
|
||||||
|
setBaseBranch("main");
|
||||||
|
setIsDraft(false);
|
||||||
|
setError(null);
|
||||||
|
} else {
|
||||||
|
// Reset everything when dialog closes
|
||||||
|
setTitle("");
|
||||||
|
setBody("");
|
||||||
|
setCommitMessage("");
|
||||||
|
setBaseBranch("main");
|
||||||
|
setIsDraft(false);
|
||||||
|
setError(null);
|
||||||
|
setPrUrl(null);
|
||||||
|
setBrowserUrl(null);
|
||||||
|
setShowBrowserFallback(false);
|
||||||
|
}
|
||||||
|
}, [open, worktree?.path]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!worktree) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.createPR) {
|
||||||
|
setError("Worktree API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await api.worktree.createPR(worktree.path, {
|
||||||
|
commitMessage: commitMessage || undefined,
|
||||||
|
prTitle: title || worktree.branch,
|
||||||
|
prBody: body || `Changes from branch ${worktree.branch}`,
|
||||||
|
baseBranch,
|
||||||
|
draft: isDraft,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
if (result.result.prCreated && result.result.prUrl) {
|
||||||
|
setPrUrl(result.result.prUrl);
|
||||||
|
toast.success("Pull request created!", {
|
||||||
|
description: `PR created from ${result.result.branch}`,
|
||||||
|
action: {
|
||||||
|
label: "View PR",
|
||||||
|
onClick: () => window.open(result.result!.prUrl!, "_blank"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
onCreated();
|
||||||
|
} else {
|
||||||
|
// Branch was pushed successfully
|
||||||
|
const prError = result.result.prError;
|
||||||
|
const hasBrowserUrl = !!result.result.browserUrl;
|
||||||
|
|
||||||
|
// Check if we should show browser fallback
|
||||||
|
if (!result.result.prCreated && hasBrowserUrl) {
|
||||||
|
// If gh CLI is not available, show browser fallback UI
|
||||||
|
if (prError === "gh_cli_not_available" || !result.result.ghCliAvailable) {
|
||||||
|
setBrowserUrl(result.result.browserUrl ?? null);
|
||||||
|
setShowBrowserFallback(true);
|
||||||
|
toast.success("Branch pushed", {
|
||||||
|
description: result.result.committed
|
||||||
|
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
|
||||||
|
: `Branch ${result.result.branch} pushed`,
|
||||||
|
});
|
||||||
|
// Don't call onCreated() here - we want to keep the dialog open to show the browser URL
|
||||||
|
setIsLoading(false);
|
||||||
|
return; // Don't close dialog, show browser fallback UI
|
||||||
|
}
|
||||||
|
|
||||||
|
// gh CLI is available but failed - show error with browser option
|
||||||
|
if (prError) {
|
||||||
|
// Parse common gh CLI errors for better messages
|
||||||
|
let errorMessage = prError;
|
||||||
|
if (prError.includes("No commits between")) {
|
||||||
|
errorMessage = "No new commits to create PR. Make sure your branch has changes compared to the base branch.";
|
||||||
|
} else if (prError.includes("already exists")) {
|
||||||
|
errorMessage = "A pull request already exists for this branch.";
|
||||||
|
} else if (prError.includes("not logged in") || prError.includes("auth")) {
|
||||||
|
errorMessage = "GitHub CLI not authenticated. Run 'gh auth login' in terminal.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error but also provide browser option
|
||||||
|
setBrowserUrl(result.result.browserUrl ?? null);
|
||||||
|
setShowBrowserFallback(true);
|
||||||
|
toast.error("PR creation failed", {
|
||||||
|
description: errorMessage,
|
||||||
|
duration: 8000,
|
||||||
|
});
|
||||||
|
// Don't call onCreated() here - we want to keep the dialog open to show the browser URL
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success toast for push
|
||||||
|
toast.success("Branch pushed", {
|
||||||
|
description: result.result.committed
|
||||||
|
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
|
||||||
|
: `Branch ${result.result.branch} pushed`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// No browser URL available, just close
|
||||||
|
if (!result.result.prCreated) {
|
||||||
|
if (!hasBrowserUrl) {
|
||||||
|
toast.info("PR not created", {
|
||||||
|
description: "Could not determine repository URL. GitHub CLI (gh) may not be installed or authenticated.",
|
||||||
|
duration: 8000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onCreated();
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(result.error || "Failed to create pull request");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to create PR");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
// Reset state after dialog closes
|
||||||
|
setTimeout(() => {
|
||||||
|
setTitle("");
|
||||||
|
setBody("");
|
||||||
|
setCommitMessage("");
|
||||||
|
setBaseBranch("main");
|
||||||
|
setIsDraft(false);
|
||||||
|
setError(null);
|
||||||
|
setPrUrl(null);
|
||||||
|
setBrowserUrl(null);
|
||||||
|
setShowBrowserFallback(false);
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!worktree) return null;
|
||||||
|
|
||||||
|
const shouldShowBrowserFallback = showBrowserFallback && browserUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-[550px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<GitPullRequest className="w-5 h-5" />
|
||||||
|
Create Pull Request
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Push changes and create a pull request from{" "}
|
||||||
|
<code className="font-mono bg-muted px-1 rounded">
|
||||||
|
{worktree.branch}
|
||||||
|
</code>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{prUrl ? (
|
||||||
|
<div className="py-6 text-center space-y-4">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-500/10">
|
||||||
|
<GitPullRequest className="w-8 h-8 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Pull Request Created!</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Your PR is ready for review
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.open(prUrl, "_blank")}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
View Pull Request
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : shouldShowBrowserFallback ? (
|
||||||
|
<div className="py-6 text-center space-y-4">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-500/10">
|
||||||
|
<GitPullRequest className="w-8 h-8 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Branch Pushed!</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Your changes have been pushed to GitHub.
|
||||||
|
<br />
|
||||||
|
Click below to create a pull request in your browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (browserUrl) {
|
||||||
|
window.open(browserUrl, "_blank");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="gap-2 w-full"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
Create PR in Browser
|
||||||
|
</Button>
|
||||||
|
<div className="p-2 bg-muted rounded text-xs break-all font-mono">
|
||||||
|
{browserUrl}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Tip: Install the GitHub CLI (<code className="bg-muted px-1 rounded">gh</code>) to create PRs directly from the app
|
||||||
|
</p>
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button variant="outline" onClick={handleClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
{worktree.hasChanges && (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="commit-message">
|
||||||
|
Commit Message{" "}
|
||||||
|
<span className="text-muted-foreground">(optional)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="commit-message"
|
||||||
|
placeholder="Leave empty to auto-generate"
|
||||||
|
value={commitMessage}
|
||||||
|
onChange={(e) => setCommitMessage(e.target.value)}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{worktree.changedFilesCount} uncommitted file(s) will be
|
||||||
|
committed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="pr-title">PR Title</Label>
|
||||||
|
<Input
|
||||||
|
id="pr-title"
|
||||||
|
placeholder={worktree.branch}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="pr-body">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="pr-body"
|
||||||
|
placeholder="Describe the changes in this PR..."
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
className="min-h-[80px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="base-branch">Base Branch</Label>
|
||||||
|
<Input
|
||||||
|
id="base-branch"
|
||||||
|
placeholder="main"
|
||||||
|
value={baseBranch}
|
||||||
|
onChange={(e) => setBaseBranch(e.target.value)}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="draft"
|
||||||
|
checked={isDraft}
|
||||||
|
onCheckedChange={(checked) => setIsDraft(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="draft" className="cursor-pointer">
|
||||||
|
Create as draft
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={handleClose} disabled={isLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreate} disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<GitPullRequest className="w-4 h-4 mr-2" />
|
||||||
|
Create PR
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { GitBranch, Loader2 } from "lucide-react";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface CreatedWorktreeInfo {
|
||||||
|
path: string;
|
||||||
|
branch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateWorktreeDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
projectPath: string;
|
||||||
|
onCreated: (worktree: CreatedWorktreeInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateWorktreeDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
projectPath,
|
||||||
|
onCreated,
|
||||||
|
}: CreateWorktreeDialogProps) {
|
||||||
|
const [branchName, setBranchName] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!branchName.trim()) {
|
||||||
|
setError("Branch name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate branch name (git-compatible)
|
||||||
|
const validBranchRegex = /^[a-zA-Z0-9._/-]+$/;
|
||||||
|
if (!validBranchRegex.test(branchName)) {
|
||||||
|
setError(
|
||||||
|
"Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.create) {
|
||||||
|
setError("Worktree API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await api.worktree.create(projectPath, branchName);
|
||||||
|
|
||||||
|
if (result.success && result.worktree) {
|
||||||
|
toast.success(
|
||||||
|
`Worktree created for branch "${result.worktree.branch}"`,
|
||||||
|
{
|
||||||
|
description: result.worktree.isNew
|
||||||
|
? "New branch created"
|
||||||
|
: "Using existing branch",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
onCreated({ path: result.worktree.path, branch: result.worktree.branch });
|
||||||
|
onOpenChange(false);
|
||||||
|
setBranchName("");
|
||||||
|
} else {
|
||||||
|
setError(result.error || "Failed to create worktree");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to create worktree");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && !isLoading && branchName.trim()) {
|
||||||
|
handleCreate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<GitBranch className="w-5 h-5" />
|
||||||
|
Create New Worktree
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new git worktree with its own branch. This allows you to
|
||||||
|
work on multiple features in parallel.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="branch-name">Branch Name</Label>
|
||||||
|
<Input
|
||||||
|
id="branch-name"
|
||||||
|
placeholder="feature/my-new-feature"
|
||||||
|
value={branchName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBranchName(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground space-y-1">
|
||||||
|
<p>Examples:</p>
|
||||||
|
<ul className="list-disc list-inside pl-2 space-y-0.5">
|
||||||
|
<li>
|
||||||
|
<code className="bg-muted px-1 rounded">feature/user-auth</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="bg-muted px-1 rounded">fix/login-bug</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="bg-muted px-1 rounded">hotfix/security-patch</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={isLoading || !branchName.trim()}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<GitBranch className="w-4 h-4 mr-2" />
|
||||||
|
Create Worktree
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface DeleteAllVerifiedDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
verifiedCount: number;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteAllVerifiedDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
verifiedCount,
|
||||||
|
onConfirm,
|
||||||
|
}: DeleteAllVerifiedDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent data-testid="delete-all-verified-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete All Verified Features</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete all verified features? This action
|
||||||
|
cannot be undone.
|
||||||
|
{verifiedCount > 0 && (
|
||||||
|
<span className="block mt-2 text-yellow-500">
|
||||||
|
{verifiedCount} feature(s) will be deleted.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={onConfirm} data-testid="confirm-delete-all-verified">
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete All
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { Feature } from "@/store/app-store";
|
||||||
|
|
||||||
|
interface DeleteCompletedFeatureDialogProps {
|
||||||
|
feature: Feature | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteCompletedFeatureDialog({
|
||||||
|
feature,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: DeleteCompletedFeatureDialogProps) {
|
||||||
|
if (!feature) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!feature} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent data-testid="delete-completed-confirmation-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
Delete Feature
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to permanently delete this feature?
|
||||||
|
<span className="block mt-2 font-medium text-foreground">
|
||||||
|
"{feature.description?.slice(0, 100)}
|
||||||
|
{(feature.description?.length ?? 0) > 100 ? "..." : ""}"
|
||||||
|
</span>
|
||||||
|
<span className="block mt-2 text-destructive font-medium">
|
||||||
|
This action cannot be undone.
|
||||||
|
</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
data-testid="cancel-delete-completed-button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={onConfirm}
|
||||||
|
data-testid="confirm-delete-completed-button"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Loader2, Trash2, AlertTriangle } from "lucide-react";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface WorktreeInfo {
|
||||||
|
path: string;
|
||||||
|
branch: string;
|
||||||
|
isMain: boolean;
|
||||||
|
hasChanges?: boolean;
|
||||||
|
changedFilesCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteWorktreeDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
projectPath: string;
|
||||||
|
worktree: WorktreeInfo | null;
|
||||||
|
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteWorktreeDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
projectPath,
|
||||||
|
worktree,
|
||||||
|
onDeleted,
|
||||||
|
}: DeleteWorktreeDialogProps) {
|
||||||
|
const [deleteBranch, setDeleteBranch] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!worktree) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.delete) {
|
||||||
|
toast.error("Worktree API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await api.worktree.delete(
|
||||||
|
projectPath,
|
||||||
|
worktree.path,
|
||||||
|
deleteBranch
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`Worktree deleted`, {
|
||||||
|
description: deleteBranch
|
||||||
|
? `Branch "${worktree.branch}" was also deleted`
|
||||||
|
: `Branch "${worktree.branch}" was kept`,
|
||||||
|
});
|
||||||
|
onDeleted(worktree, deleteBranch);
|
||||||
|
onOpenChange(false);
|
||||||
|
setDeleteBranch(false);
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to delete worktree", {
|
||||||
|
description: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to delete worktree", {
|
||||||
|
description: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!worktree) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Trash2 className="w-5 h-5 text-destructive" />
|
||||||
|
Delete Worktree
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="space-y-3">
|
||||||
|
<span>
|
||||||
|
Are you sure you want to delete the worktree for branch{" "}
|
||||||
|
<code className="font-mono bg-muted px-1 rounded">
|
||||||
|
{worktree.branch}
|
||||||
|
</code>
|
||||||
|
?
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{worktree.hasChanges && (
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-yellow-500 text-sm">
|
||||||
|
This worktree has {worktree.changedFilesCount} uncommitted
|
||||||
|
change(s). These will be lost if you proceed.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 py-4">
|
||||||
|
<Checkbox
|
||||||
|
id="delete-branch"
|
||||||
|
checked={deleteBranch}
|
||||||
|
onCheckedChange={(checked) => setDeleteBranch(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="delete-branch" className="text-sm cursor-pointer">
|
||||||
|
Also delete the branch{" "}
|
||||||
|
<code className="font-mono bg-muted px-1 rounded">
|
||||||
|
{worktree.branch}
|
||||||
|
</code>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Feature } from "@/store/app-store";
|
||||||
|
import { AlertCircle, CheckCircle2, Circle } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface DependencyTreeDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
feature: Feature | null;
|
||||||
|
allFeatures: Feature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DependencyTreeDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
feature,
|
||||||
|
allFeatures,
|
||||||
|
}: DependencyTreeDialogProps) {
|
||||||
|
const [dependencyTree, setDependencyTree] = useState<{
|
||||||
|
dependencies: Feature[];
|
||||||
|
dependents: Feature[];
|
||||||
|
}>({ dependencies: [], dependents: [] });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!feature) return;
|
||||||
|
|
||||||
|
// Find features this depends on
|
||||||
|
const dependencies = (feature.dependencies || [])
|
||||||
|
.map((depId) => allFeatures.find((f) => f.id === depId))
|
||||||
|
.filter((f): f is Feature => f !== undefined);
|
||||||
|
|
||||||
|
// Find features that depend on this one
|
||||||
|
const dependents = allFeatures.filter((f) =>
|
||||||
|
f.dependencies?.includes(feature.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
setDependencyTree({ dependencies, dependents });
|
||||||
|
}, [feature, allFeatures]);
|
||||||
|
|
||||||
|
if (!feature) return null;
|
||||||
|
|
||||||
|
const getStatusIcon = (status: Feature["status"]) => {
|
||||||
|
switch (status) {
|
||||||
|
case "completed":
|
||||||
|
case "verified":
|
||||||
|
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||||
|
case "in_progress":
|
||||||
|
case "waiting_approval":
|
||||||
|
return <Circle className="w-4 h-4 text-blue-500 fill-blue-500/20" />;
|
||||||
|
default:
|
||||||
|
return <Circle className="w-4 h-4 text-muted-foreground/50" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityBadge = (priority?: number) => {
|
||||||
|
if (!priority) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-1.5 py-0.5 rounded font-medium",
|
||||||
|
priority === 1 && "bg-red-500/20 text-red-500",
|
||||||
|
priority === 2 && "bg-yellow-500/20 text-yellow-500",
|
||||||
|
priority === 3 && "bg-blue-500/20 text-blue-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
P{priority}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Dependency Tree</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 mt-4">
|
||||||
|
{/* Current Feature */}
|
||||||
|
<div className="border-2 border-primary rounded-lg p-4 bg-primary/5">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
{getStatusIcon(feature.status)}
|
||||||
|
<h3 className="font-semibold text-sm">Current Feature</h3>
|
||||||
|
{getPriorityBadge(feature.priority)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{feature.description}</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-2">
|
||||||
|
Category: {feature.category}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dependencies (what this feature needs) */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<h3 className="font-semibold text-sm">
|
||||||
|
Dependencies ({dependencyTree.dependencies.length})
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
This feature requires:
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dependencyTree.dependencies.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground/70 italic border border-dashed rounded-lg p-4 text-center">
|
||||||
|
No dependencies - this feature can be started independently
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{dependencyTree.dependencies.map((dep) => (
|
||||||
|
<div
|
||||||
|
key={dep.id}
|
||||||
|
className={cn(
|
||||||
|
"border rounded-lg p-3 transition-colors",
|
||||||
|
dep.status === "completed" || dep.status === "verified"
|
||||||
|
? "bg-green-500/5 border-green-500/20"
|
||||||
|
: "bg-muted/30 border-border"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
{getStatusIcon(dep.status)}
|
||||||
|
<span className="text-sm font-medium flex-1">
|
||||||
|
{dep.description.slice(0, 100)}
|
||||||
|
{dep.description.length > 100 && "..."}
|
||||||
|
</span>
|
||||||
|
{getPriorityBadge(dep.priority)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 ml-7">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{dep.category}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2 py-0.5 rounded-full",
|
||||||
|
dep.status === "completed" || dep.status === "verified"
|
||||||
|
? "bg-green-500/20 text-green-600"
|
||||||
|
: dep.status === "in_progress"
|
||||||
|
? "bg-blue-500/20 text-blue-600"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{dep.status.replace(/_/g, " ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dependents (what depends on this feature) */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<h3 className="font-semibold text-sm">
|
||||||
|
Dependents ({dependencyTree.dependents.length})
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Features blocked by this:
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dependencyTree.dependents.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground/70 italic border border-dashed rounded-lg p-4 text-center">
|
||||||
|
No dependents - no other features are waiting on this one
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{dependencyTree.dependents.map((dependent) => (
|
||||||
|
<div
|
||||||
|
key={dependent.id}
|
||||||
|
className="border rounded-lg p-3 bg-muted/30"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
{getStatusIcon(dependent.status)}
|
||||||
|
<span className="text-sm font-medium flex-1">
|
||||||
|
{dependent.description.slice(0, 100)}
|
||||||
|
{dependent.description.length > 100 && "..."}
|
||||||
|
</span>
|
||||||
|
{getPriorityBadge(dependent.priority)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 ml-7">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{dependent.category}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2 py-0.5 rounded-full",
|
||||||
|
dependent.status === "completed" ||
|
||||||
|
dependent.status === "verified"
|
||||||
|
? "bg-green-500/20 text-green-600"
|
||||||
|
: dependent.status === "in_progress"
|
||||||
|
? "bg-blue-500/20 text-blue-600"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{dependent.status.replace(/_/g, " ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning for incomplete dependencies */}
|
||||||
|
{dependencyTree.dependencies.some(
|
||||||
|
(d) => d.status !== "completed" && d.status !== "verified"
|
||||||
|
) && (
|
||||||
|
<div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||||
|
<AlertCircle className="w-5 h-5 text-yellow-600 shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-yellow-700 dark:text-yellow-500">
|
||||||
|
Incomplete Dependencies
|
||||||
|
</p>
|
||||||
|
<p className="text-yellow-600 dark:text-yellow-400 mt-1">
|
||||||
|
This feature has dependencies that aren't completed yet.
|
||||||
|
Consider completing them first for a smoother implementation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
||||||
|
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
|
||||||
|
import {
|
||||||
|
DescriptionImageDropZone,
|
||||||
|
FeatureImagePath as DescriptionImagePath,
|
||||||
|
ImagePreviewMap,
|
||||||
|
} from "@/components/ui/description-image-dropzone";
|
||||||
|
import {
|
||||||
|
MessageSquare,
|
||||||
|
Settings2,
|
||||||
|
SlidersHorizontal,
|
||||||
|
FlaskConical,
|
||||||
|
Sparkles,
|
||||||
|
ChevronDown,
|
||||||
|
GitBranch,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { modelSupportsThinking } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Feature,
|
||||||
|
AgentModel,
|
||||||
|
ThinkingLevel,
|
||||||
|
AIProfile,
|
||||||
|
useAppStore,
|
||||||
|
PlanningMode,
|
||||||
|
} from "@/store/app-store";
|
||||||
|
import {
|
||||||
|
ModelSelector,
|
||||||
|
ThinkingLevelSelector,
|
||||||
|
ProfileQuickSelect,
|
||||||
|
TestingTabContent,
|
||||||
|
PrioritySelector,
|
||||||
|
PlanningModeSelector,
|
||||||
|
} from "../shared";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { DependencyTreeDialog } from "./dependency-tree-dialog";
|
||||||
|
|
||||||
|
interface EditFeatureDialogProps {
|
||||||
|
feature: Feature | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpdate: (
|
||||||
|
featureId: string,
|
||||||
|
updates: {
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
steps: string[];
|
||||||
|
skipTests: boolean;
|
||||||
|
model: AgentModel;
|
||||||
|
thinkingLevel: ThinkingLevel;
|
||||||
|
imagePaths: DescriptionImagePath[];
|
||||||
|
branchName: string;
|
||||||
|
priority: number;
|
||||||
|
planningMode: PlanningMode;
|
||||||
|
requirePlanApproval: boolean;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
categorySuggestions: string[];
|
||||||
|
branchSuggestions: string[];
|
||||||
|
isMaximized: boolean;
|
||||||
|
showProfilesOnly: boolean;
|
||||||
|
aiProfiles: AIProfile[];
|
||||||
|
allFeatures: Feature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditFeatureDialog({
|
||||||
|
feature,
|
||||||
|
onClose,
|
||||||
|
onUpdate,
|
||||||
|
categorySuggestions,
|
||||||
|
branchSuggestions,
|
||||||
|
isMaximized,
|
||||||
|
showProfilesOnly,
|
||||||
|
aiProfiles,
|
||||||
|
allFeatures,
|
||||||
|
}: EditFeatureDialogProps) {
|
||||||
|
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
||||||
|
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
|
||||||
|
useState<ImagePreviewMap>(() => new Map());
|
||||||
|
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
|
||||||
|
const [isEnhancing, setIsEnhancing] = useState(false);
|
||||||
|
const [enhancementMode, setEnhancementMode] = useState<
|
||||||
|
"improve" | "technical" | "simplify" | "acceptance"
|
||||||
|
>("improve");
|
||||||
|
const [showDependencyTree, setShowDependencyTree] = useState(false);
|
||||||
|
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
|
||||||
|
const [requirePlanApproval, setRequirePlanApproval] = useState(feature?.requirePlanApproval ?? false);
|
||||||
|
|
||||||
|
// Get enhancement model and worktrees setting from store
|
||||||
|
const { enhancementModel, useWorktrees } = useAppStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditingFeature(feature);
|
||||||
|
if (feature) {
|
||||||
|
setPlanningMode(feature.planningMode ?? 'skip');
|
||||||
|
setRequirePlanApproval(feature.requirePlanApproval ?? false);
|
||||||
|
} else {
|
||||||
|
setEditFeaturePreviewMap(new Map());
|
||||||
|
setShowEditAdvancedOptions(false);
|
||||||
|
}
|
||||||
|
}, [feature]);
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
if (!editingFeature) return;
|
||||||
|
|
||||||
|
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
|
||||||
|
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
|
||||||
|
selectedModel
|
||||||
|
)
|
||||||
|
? editingFeature.thinkingLevel ?? "none"
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
category: editingFeature.category,
|
||||||
|
description: editingFeature.description,
|
||||||
|
steps: editingFeature.steps,
|
||||||
|
skipTests: editingFeature.skipTests ?? false,
|
||||||
|
model: selectedModel,
|
||||||
|
thinkingLevel: normalizedThinking,
|
||||||
|
imagePaths: editingFeature.imagePaths ?? [],
|
||||||
|
branchName: editingFeature.branchName ?? "main",
|
||||||
|
priority: editingFeature.priority ?? 2,
|
||||||
|
planningMode,
|
||||||
|
requirePlanApproval,
|
||||||
|
};
|
||||||
|
|
||||||
|
onUpdate(editingFeature.id, updates);
|
||||||
|
setEditFeaturePreviewMap(new Map());
|
||||||
|
setShowEditAdvancedOptions(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogClose = (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelSelect = (model: AgentModel) => {
|
||||||
|
if (!editingFeature) return;
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
model,
|
||||||
|
thinkingLevel: modelSupportsThinking(model)
|
||||||
|
? editingFeature.thinkingLevel
|
||||||
|
: "none",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProfileSelect = (
|
||||||
|
model: AgentModel,
|
||||||
|
thinkingLevel: ThinkingLevel
|
||||||
|
) => {
|
||||||
|
if (!editingFeature) return;
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
model,
|
||||||
|
thinkingLevel,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnhanceDescription = async () => {
|
||||||
|
if (!editingFeature?.description.trim() || isEnhancing) return;
|
||||||
|
|
||||||
|
setIsEnhancing(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.enhancePrompt?.enhance(
|
||||||
|
editingFeature.description,
|
||||||
|
enhancementMode,
|
||||||
|
enhancementModel
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result?.success && result.enhancedText) {
|
||||||
|
const enhancedText = result.enhancedText;
|
||||||
|
setEditingFeature((prev) =>
|
||||||
|
prev ? { ...prev, description: enhancedText } : prev
|
||||||
|
);
|
||||||
|
toast.success("Description enhanced!");
|
||||||
|
} else {
|
||||||
|
toast.error(result?.error || "Failed to enhance description");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Enhancement failed:", error);
|
||||||
|
toast.error("Failed to enhance description");
|
||||||
|
} finally {
|
||||||
|
setIsEnhancing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editModelAllowsThinking = modelSupportsThinking(editingFeature?.model);
|
||||||
|
|
||||||
|
if (!editingFeature) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!editingFeature} onOpenChange={handleDialogClose}>
|
||||||
|
<DialogContent
|
||||||
|
compact={!isMaximized}
|
||||||
|
data-testid="edit-feature-dialog"
|
||||||
|
onPointerDownOutside={(e: CustomEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onInteractOutside={(e: CustomEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Feature</DialogTitle>
|
||||||
|
<DialogDescription>Modify the feature details.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Tabs
|
||||||
|
defaultValue="prompt"
|
||||||
|
className="py-4 flex-1 min-h-0 flex flex-col"
|
||||||
|
>
|
||||||
|
<TabsList className="w-full grid grid-cols-3 mb-4">
|
||||||
|
<TabsTrigger value="prompt" data-testid="edit-tab-prompt">
|
||||||
|
<MessageSquare className="w-4 h-4 mr-2" />
|
||||||
|
Prompt
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="model" data-testid="edit-tab-model">
|
||||||
|
<Settings2 className="w-4 h-4 mr-2" />
|
||||||
|
Model
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="options" data-testid="edit-tab-options">
|
||||||
|
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
||||||
|
Options
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Prompt Tab */}
|
||||||
|
<TabsContent
|
||||||
|
value="prompt"
|
||||||
|
className="space-y-4 overflow-y-auto cursor-default"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-description">Description</Label>
|
||||||
|
<DescriptionImageDropZone
|
||||||
|
value={editingFeature.description}
|
||||||
|
onChange={(value) =>
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
description: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
images={editingFeature.imagePaths ?? []}
|
||||||
|
onImagesChange={(images) =>
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
imagePaths: images,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Describe the feature..."
|
||||||
|
previewMap={editFeaturePreviewMap}
|
||||||
|
onPreviewMapChange={setEditFeaturePreviewMap}
|
||||||
|
data-testid="edit-feature-description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-[180px] justify-between"
|
||||||
|
>
|
||||||
|
{enhancementMode === "improve" && "Improve Clarity"}
|
||||||
|
{enhancementMode === "technical" && "Add Technical Details"}
|
||||||
|
{enhancementMode === "simplify" && "Simplify"}
|
||||||
|
{enhancementMode === "acceptance" &&
|
||||||
|
"Add Acceptance Criteria"}
|
||||||
|
<ChevronDown className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setEnhancementMode("improve")}
|
||||||
|
>
|
||||||
|
Improve Clarity
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setEnhancementMode("technical")}
|
||||||
|
>
|
||||||
|
Add Technical Details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setEnhancementMode("simplify")}
|
||||||
|
>
|
||||||
|
Simplify
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setEnhancementMode("acceptance")}
|
||||||
|
>
|
||||||
|
Add Acceptance Criteria
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleEnhanceDescription}
|
||||||
|
disabled={!editingFeature.description.trim() || isEnhancing}
|
||||||
|
loading={isEnhancing}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
|
Enhance with AI
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-category">Category (optional)</Label>
|
||||||
|
<CategoryAutocomplete
|
||||||
|
value={editingFeature.category}
|
||||||
|
onChange={(value) =>
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
category: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
suggestions={categorySuggestions}
|
||||||
|
placeholder="e.g., Core, UI, API"
|
||||||
|
data-testid="edit-feature-category"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{useWorktrees && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-branch">Target Branch</Label>
|
||||||
|
<BranchAutocomplete
|
||||||
|
value={editingFeature.branchName ?? "main"}
|
||||||
|
onChange={(value) =>
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
branchName: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
branches={branchSuggestions}
|
||||||
|
placeholder="Select or create branch..."
|
||||||
|
data-testid="edit-feature-branch"
|
||||||
|
disabled={editingFeature.status !== "backlog"}
|
||||||
|
/>
|
||||||
|
{editingFeature.status !== "backlog" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Branch cannot be changed after work has started.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{editingFeature.status === "backlog" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Work will be done in this branch. A worktree will be created
|
||||||
|
if needed.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Priority Selector */}
|
||||||
|
<PrioritySelector
|
||||||
|
selectedPriority={editingFeature.priority ?? 2}
|
||||||
|
onPrioritySelect={(priority) =>
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
priority,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
testIdPrefix="edit-priority"
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Model Tab */}
|
||||||
|
<TabsContent
|
||||||
|
value="model"
|
||||||
|
className="space-y-4 overflow-y-auto cursor-default"
|
||||||
|
>
|
||||||
|
{/* Show Advanced Options Toggle */}
|
||||||
|
{showProfilesOnly && (
|
||||||
|
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
Simple Mode Active
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Only showing AI profiles. Advanced model tweaking is hidden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setShowEditAdvancedOptions(!showEditAdvancedOptions)
|
||||||
|
}
|
||||||
|
data-testid="edit-show-advanced-options-toggle"
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4 mr-2" />
|
||||||
|
{showEditAdvancedOptions ? "Hide" : "Show"} Advanced
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Select Profile Section */}
|
||||||
|
<ProfileQuickSelect
|
||||||
|
profiles={aiProfiles}
|
||||||
|
selectedModel={editingFeature.model ?? "opus"}
|
||||||
|
selectedThinkingLevel={editingFeature.thinkingLevel ?? "none"}
|
||||||
|
onSelect={handleProfileSelect}
|
||||||
|
testIdPrefix="edit-profile-quick-select"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
{aiProfiles.length > 0 &&
|
||||||
|
(!showProfilesOnly || showEditAdvancedOptions) && (
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Claude Models Section */}
|
||||||
|
{(!showProfilesOnly || showEditAdvancedOptions) && (
|
||||||
|
<>
|
||||||
|
<ModelSelector
|
||||||
|
selectedModel={(editingFeature.model ?? "opus") as AgentModel}
|
||||||
|
onModelSelect={handleModelSelect}
|
||||||
|
testIdPrefix="edit-model-select"
|
||||||
|
/>
|
||||||
|
{editModelAllowsThinking && (
|
||||||
|
<ThinkingLevelSelector
|
||||||
|
selectedLevel={editingFeature.thinkingLevel ?? "none"}
|
||||||
|
onLevelSelect={(level) =>
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
thinkingLevel: level,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
testIdPrefix="edit-thinking-level"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Options Tab */}
|
||||||
|
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
|
||||||
|
{/* Planning Mode Section */}
|
||||||
|
<PlanningModeSelector
|
||||||
|
mode={planningMode}
|
||||||
|
onModeChange={setPlanningMode}
|
||||||
|
requireApproval={requirePlanApproval}
|
||||||
|
onRequireApprovalChange={setRequirePlanApproval}
|
||||||
|
featureDescription={editingFeature.description}
|
||||||
|
testIdPrefix="edit-feature"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="border-t border-border my-4" />
|
||||||
|
|
||||||
|
{/* Testing Section */}
|
||||||
|
<TestingTabContent
|
||||||
|
skipTests={editingFeature.skipTests ?? false}
|
||||||
|
onSkipTestsChange={(skipTests) =>
|
||||||
|
setEditingFeature({ ...editingFeature, skipTests })
|
||||||
|
}
|
||||||
|
steps={editingFeature.steps}
|
||||||
|
onStepsChange={(steps) =>
|
||||||
|
setEditingFeature({ ...editingFeature, steps })
|
||||||
|
}
|
||||||
|
testIdPrefix="edit"
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
<DialogFooter className="sm:!justify-between">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDependencyTree(true)}
|
||||||
|
className="gap-2 h-10"
|
||||||
|
>
|
||||||
|
<GitBranch className="w-4 h-4" />
|
||||||
|
View Dependency Tree
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<HotkeyButton
|
||||||
|
onClick={handleUpdate}
|
||||||
|
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||||
|
hotkeyActive={!!editingFeature}
|
||||||
|
data-testid="confirm-edit-feature"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</HotkeyButton>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DependencyTreeDialog
|
||||||
|
open={showDependencyTree}
|
||||||
|
onClose={() => setShowDependencyTree(false)}
|
||||||
|
feature={editingFeature}
|
||||||
|
allFeatures={allFeatures}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -239,6 +239,7 @@ export function FeatureSuggestionsDialog({
|
|||||||
steps: s.steps,
|
steps: s.steps,
|
||||||
status: "backlog" as const,
|
status: "backlog" as const,
|
||||||
skipTests: true, // As specified, testing mode true
|
skipTests: true, // As specified, testing mode true
|
||||||
|
priority: s.priority, // Preserve priority from suggestion
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Create each new feature using the features API
|
// Create each new feature using the features API
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
|
import {
|
||||||
|
DescriptionImageDropZone,
|
||||||
|
FeatureImagePath as DescriptionImagePath,
|
||||||
|
ImagePreviewMap,
|
||||||
|
} from "@/components/ui/description-image-dropzone";
|
||||||
|
import { MessageSquare } from "lucide-react";
|
||||||
|
import { Feature } from "@/store/app-store";
|
||||||
|
|
||||||
|
interface FollowUpDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
feature: Feature | null;
|
||||||
|
prompt: string;
|
||||||
|
imagePaths: DescriptionImagePath[];
|
||||||
|
previewMap: ImagePreviewMap;
|
||||||
|
onPromptChange: (prompt: string) => void;
|
||||||
|
onImagePathsChange: (paths: DescriptionImagePath[]) => void;
|
||||||
|
onPreviewMapChange: (map: ImagePreviewMap) => void;
|
||||||
|
onSend: () => void;
|
||||||
|
isMaximized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowUpDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
feature,
|
||||||
|
prompt,
|
||||||
|
imagePaths,
|
||||||
|
previewMap,
|
||||||
|
onPromptChange,
|
||||||
|
onImagePathsChange,
|
||||||
|
onPreviewMapChange,
|
||||||
|
onSend,
|
||||||
|
isMaximized,
|
||||||
|
}: FollowUpDialogProps) {
|
||||||
|
const handleClose = (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent
|
||||||
|
compact={!isMaximized}
|
||||||
|
data-testid="follow-up-dialog"
|
||||||
|
onKeyDown={(e: React.KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && prompt.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Follow-Up Prompt</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Send additional instructions to continue working on this feature.
|
||||||
|
{feature && (
|
||||||
|
<span className="block mt-2 text-primary">
|
||||||
|
Feature: {feature.description.slice(0, 100)}
|
||||||
|
{feature.description.length > 100 ? "..." : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="follow-up-prompt">Instructions</Label>
|
||||||
|
<DescriptionImageDropZone
|
||||||
|
value={prompt}
|
||||||
|
onChange={onPromptChange}
|
||||||
|
images={imagePaths}
|
||||||
|
onImagesChange={onImagePathsChange}
|
||||||
|
placeholder="Describe what needs to be fixed or changed..."
|
||||||
|
previewMap={previewMap}
|
||||||
|
onPreviewMapChange={onPreviewMapChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The agent will continue from where it left off, using the existing
|
||||||
|
context. You can attach screenshots to help explain the issue.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<HotkeyButton
|
||||||
|
onClick={onSend}
|
||||||
|
disabled={!prompt.trim()}
|
||||||
|
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||||
|
hotkeyActive={open}
|
||||||
|
data-testid="confirm-follow-up"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-4 h-4 mr-2" />
|
||||||
|
Send Follow-Up
|
||||||
|
</HotkeyButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export { AddFeatureDialog } from "./add-feature-dialog";
|
||||||
|
export { AgentOutputModal } from "./agent-output-modal";
|
||||||
|
export { CompletedFeaturesModal } from "./completed-features-modal";
|
||||||
|
export { DeleteAllVerifiedDialog } from "./delete-all-verified-dialog";
|
||||||
|
export { DeleteCompletedFeatureDialog } from "./delete-completed-feature-dialog";
|
||||||
|
export { EditFeatureDialog } from "./edit-feature-dialog";
|
||||||
|
export { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
|
||||||
|
export { FollowUpDialog } from "./follow-up-dialog";
|
||||||
|
export { PlanApprovalDialog } from "./plan-approval-dialog";
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Markdown } from "@/components/ui/markdown";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Feature } from "@/store/app-store";
|
||||||
|
import { Check, RefreshCw, Edit2, Eye, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface PlanApprovalDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
feature: Feature | null;
|
||||||
|
planContent: string;
|
||||||
|
onApprove: (editedPlan?: string) => void;
|
||||||
|
onReject: (feedback?: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
viewOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlanApprovalDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
feature,
|
||||||
|
planContent,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
isLoading = false,
|
||||||
|
viewOnly = false,
|
||||||
|
}: PlanApprovalDialogProps) {
|
||||||
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
|
const [editedPlan, setEditedPlan] = useState(planContent);
|
||||||
|
const [showRejectFeedback, setShowRejectFeedback] = useState(false);
|
||||||
|
const [rejectFeedback, setRejectFeedback] = useState("");
|
||||||
|
|
||||||
|
// Reset state when dialog opens or plan content changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setEditedPlan(planContent);
|
||||||
|
setIsEditMode(false);
|
||||||
|
setShowRejectFeedback(false);
|
||||||
|
setRejectFeedback("");
|
||||||
|
}
|
||||||
|
}, [open, planContent]);
|
||||||
|
|
||||||
|
const handleApprove = () => {
|
||||||
|
// Only pass edited plan if it was modified
|
||||||
|
const wasEdited = editedPlan !== planContent;
|
||||||
|
onApprove(wasEdited ? editedPlan : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = () => {
|
||||||
|
if (showRejectFeedback) {
|
||||||
|
onReject(rejectFeedback.trim() || undefined);
|
||||||
|
} else {
|
||||||
|
setShowRejectFeedback(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelReject = () => {
|
||||||
|
setShowRejectFeedback(false);
|
||||||
|
setRejectFeedback("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (open: boolean) => {
|
||||||
|
if (!open && !isLoading) {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-4xl"
|
||||||
|
data-testid="plan-approval-dialog"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{viewOnly ? "View Plan" : "Review Plan"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{viewOnly
|
||||||
|
? "View the generated plan for this feature."
|
||||||
|
: "Review the generated plan before implementation begins."}
|
||||||
|
{feature && (
|
||||||
|
<span className="block mt-2 text-primary">
|
||||||
|
Feature: {feature.description.slice(0, 150)}
|
||||||
|
{feature.description.length > 150 ? "..." : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||||
|
{/* Mode Toggle - Only show when not in viewOnly mode */}
|
||||||
|
{!viewOnly && (
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<Label className="text-sm text-muted-foreground">
|
||||||
|
{isEditMode ? "Edit Mode" : "View Mode"}
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsEditMode(!isEditMode)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isEditMode ? (
|
||||||
|
<>
|
||||||
|
<Eye className="w-4 h-4 mr-2" />
|
||||||
|
View
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Edit2 className="w-4 h-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plan Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto max-h-[70vh] border border-border rounded-lg">
|
||||||
|
{isEditMode && !viewOnly ? (
|
||||||
|
<Textarea
|
||||||
|
value={editedPlan}
|
||||||
|
onChange={(e) => setEditedPlan(e.target.value)}
|
||||||
|
className="min-h-[400px] h-full w-full border-0 rounded-lg resize-none font-mono text-sm"
|
||||||
|
placeholder="Enter plan content..."
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 overflow-auto">
|
||||||
|
<Markdown>{editedPlan || "No plan content available."}</Markdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Revision Feedback Section - Only show when not in viewOnly mode */}
|
||||||
|
{showRejectFeedback && !viewOnly && (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<Label htmlFor="reject-feedback">What changes would you like?</Label>
|
||||||
|
<Textarea
|
||||||
|
id="reject-feedback"
|
||||||
|
value={rejectFeedback}
|
||||||
|
onChange={(e) => setRejectFeedback(e.target.value)}
|
||||||
|
placeholder="Describe the changes you'd like to see in the plan..."
|
||||||
|
className="min-h-[80px]"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Leave empty to cancel the feature, or provide feedback to regenerate the plan.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex-shrink-0 gap-2">
|
||||||
|
{viewOnly ? (
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
) : showRejectFeedback ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleCancelReject}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{rejectFeedback.trim() ? "Revise Plan" : "Cancel Feature"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Request Changes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleApprove}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/app/src/components/views/board-view/hooks/index.ts
Normal file
10
apps/app/src/components/views/board-view/hooks/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export { useBoardFeatures } from "./use-board-features";
|
||||||
|
export { useBoardDragDrop } from "./use-board-drag-drop";
|
||||||
|
export { useBoardActions } from "./use-board-actions";
|
||||||
|
export { useBoardKeyboardShortcuts } from "./use-board-keyboard-shortcuts";
|
||||||
|
export { useBoardColumnFeatures } from "./use-board-column-features";
|
||||||
|
export { useBoardEffects } from "./use-board-effects";
|
||||||
|
export { useBoardBackground } from "./use-board-background";
|
||||||
|
export { useBoardPersistence } from "./use-board-persistence";
|
||||||
|
export { useFollowUpState } from "./use-follow-up-state";
|
||||||
|
export { useSuggestionsState } from "./use-suggestions-state";
|
||||||
@@ -0,0 +1,900 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Feature,
|
||||||
|
FeatureImage,
|
||||||
|
AgentModel,
|
||||||
|
ThinkingLevel,
|
||||||
|
PlanningMode,
|
||||||
|
useAppStore,
|
||||||
|
} from "@/store/app-store";
|
||||||
|
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||||
|
import { truncateDescription } from "@/lib/utils";
|
||||||
|
import { getBlockingDependencies } from "@/lib/dependency-resolver";
|
||||||
|
|
||||||
|
interface UseBoardActionsProps {
|
||||||
|
currentProject: { path: string; id: string } | null;
|
||||||
|
features: Feature[];
|
||||||
|
runningAutoTasks: string[];
|
||||||
|
loadFeatures: () => Promise<void>;
|
||||||
|
persistFeatureCreate: (feature: Feature) => Promise<void>;
|
||||||
|
persistFeatureUpdate: (
|
||||||
|
featureId: string,
|
||||||
|
updates: Partial<Feature>
|
||||||
|
) => Promise<void>;
|
||||||
|
persistFeatureDelete: (featureId: string) => Promise<void>;
|
||||||
|
saveCategory: (category: string) => Promise<void>;
|
||||||
|
setEditingFeature: (feature: Feature | null) => void;
|
||||||
|
setShowOutputModal: (show: boolean) => void;
|
||||||
|
setOutputFeature: (feature: Feature | null) => void;
|
||||||
|
followUpFeature: Feature | null;
|
||||||
|
followUpPrompt: string;
|
||||||
|
followUpImagePaths: DescriptionImagePath[];
|
||||||
|
setFollowUpFeature: (feature: Feature | null) => void;
|
||||||
|
setFollowUpPrompt: (prompt: string) => void;
|
||||||
|
setFollowUpImagePaths: (paths: DescriptionImagePath[]) => void;
|
||||||
|
setFollowUpPreviewMap: (map: Map<string, string>) => void;
|
||||||
|
setShowFollowUpDialog: (show: boolean) => void;
|
||||||
|
inProgressFeaturesForShortcuts: Feature[];
|
||||||
|
outputFeature: Feature | null;
|
||||||
|
projectPath: string | null;
|
||||||
|
onWorktreeCreated?: () => void;
|
||||||
|
currentWorktreeBranch: string | null; // Branch name of the selected worktree for filtering
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBoardActions({
|
||||||
|
currentProject,
|
||||||
|
features,
|
||||||
|
runningAutoTasks,
|
||||||
|
loadFeatures,
|
||||||
|
persistFeatureCreate,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
persistFeatureDelete,
|
||||||
|
saveCategory,
|
||||||
|
setEditingFeature,
|
||||||
|
setShowOutputModal,
|
||||||
|
setOutputFeature,
|
||||||
|
followUpFeature,
|
||||||
|
followUpPrompt,
|
||||||
|
followUpImagePaths,
|
||||||
|
setFollowUpFeature,
|
||||||
|
setFollowUpPrompt,
|
||||||
|
setFollowUpImagePaths,
|
||||||
|
setFollowUpPreviewMap,
|
||||||
|
setShowFollowUpDialog,
|
||||||
|
inProgressFeaturesForShortcuts,
|
||||||
|
outputFeature,
|
||||||
|
projectPath,
|
||||||
|
onWorktreeCreated,
|
||||||
|
currentWorktreeBranch,
|
||||||
|
}: UseBoardActionsProps) {
|
||||||
|
const {
|
||||||
|
addFeature,
|
||||||
|
updateFeature,
|
||||||
|
removeFeature,
|
||||||
|
moveFeature,
|
||||||
|
useWorktrees,
|
||||||
|
enableDependencyBlocking,
|
||||||
|
} = useAppStore();
|
||||||
|
const autoMode = useAutoMode();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the worktree path for a feature based on its branchName.
|
||||||
|
* - If branchName is "main" or empty, returns the project path
|
||||||
|
* - Otherwise, creates a worktree for that branch if needed
|
||||||
|
*/
|
||||||
|
const getOrCreateWorktreeForFeature = useCallback(
|
||||||
|
async (feature: Feature): Promise<string | null> => {
|
||||||
|
if (!projectPath) return null;
|
||||||
|
|
||||||
|
const branchName = feature.branchName || "main";
|
||||||
|
|
||||||
|
// If targeting main branch, use the project path directly
|
||||||
|
if (branchName === "main" || branchName === "master") {
|
||||||
|
return projectPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other branches, create a worktree if it doesn't exist
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.create) {
|
||||||
|
console.error("[BoardActions] Worktree API not available");
|
||||||
|
return projectPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to create the worktree (will return existing if already exists)
|
||||||
|
const result = await api.worktree.create(projectPath, branchName);
|
||||||
|
|
||||||
|
if (result.success && result.worktree) {
|
||||||
|
console.log(
|
||||||
|
`[BoardActions] Worktree ready for branch "${branchName}": ${result.worktree.path}`
|
||||||
|
);
|
||||||
|
if (result.worktree.isNew) {
|
||||||
|
toast.success(`Worktree created for branch "${branchName}"`, {
|
||||||
|
description: "A new worktree was created for this feature.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.worktree.path;
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"[BoardActions] Failed to create worktree:",
|
||||||
|
result.error
|
||||||
|
);
|
||||||
|
toast.error("Failed to create worktree", {
|
||||||
|
description:
|
||||||
|
result.error || "Could not create worktree for this branch.",
|
||||||
|
});
|
||||||
|
return projectPath; // Fall back to project path
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[BoardActions] Error creating worktree:", error);
|
||||||
|
toast.error("Error creating worktree", {
|
||||||
|
description: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
return projectPath; // Fall back to project path
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectPath]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddFeature = useCallback(
|
||||||
|
async (featureData: {
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
steps: string[];
|
||||||
|
images: FeatureImage[];
|
||||||
|
imagePaths: DescriptionImagePath[];
|
||||||
|
skipTests: boolean;
|
||||||
|
model: AgentModel;
|
||||||
|
thinkingLevel: ThinkingLevel;
|
||||||
|
branchName: string;
|
||||||
|
priority: number;
|
||||||
|
planningMode: PlanningMode;
|
||||||
|
requirePlanApproval: boolean;
|
||||||
|
}) => {
|
||||||
|
let worktreePath: string | undefined;
|
||||||
|
|
||||||
|
// If worktrees are enabled and a non-main branch is selected, create the worktree
|
||||||
|
if (useWorktrees && featureData.branchName) {
|
||||||
|
const branchName = featureData.branchName;
|
||||||
|
if (branchName !== "main" && branchName !== "master") {
|
||||||
|
// Create a temporary feature-like object for getOrCreateWorktreeForFeature
|
||||||
|
const tempFeature = { branchName } as Feature;
|
||||||
|
const path = await getOrCreateWorktreeForFeature(tempFeature);
|
||||||
|
if (path && path !== projectPath) {
|
||||||
|
worktreePath = path;
|
||||||
|
// Refresh worktree selector after creating worktree
|
||||||
|
onWorktreeCreated?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFeatureData = {
|
||||||
|
...featureData,
|
||||||
|
status: "backlog" as const,
|
||||||
|
worktreePath,
|
||||||
|
};
|
||||||
|
const createdFeature = addFeature(newFeatureData);
|
||||||
|
// Must await to ensure feature exists on server before user can drag it
|
||||||
|
await persistFeatureCreate(createdFeature);
|
||||||
|
saveCategory(featureData.category);
|
||||||
|
},
|
||||||
|
[addFeature, persistFeatureCreate, saveCategory, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUpdateFeature = useCallback(
|
||||||
|
async (
|
||||||
|
featureId: string,
|
||||||
|
updates: {
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
steps: string[];
|
||||||
|
skipTests: boolean;
|
||||||
|
model: AgentModel;
|
||||||
|
thinkingLevel: ThinkingLevel;
|
||||||
|
imagePaths: DescriptionImagePath[];
|
||||||
|
branchName: string;
|
||||||
|
priority: number;
|
||||||
|
planningMode?: PlanningMode;
|
||||||
|
requirePlanApproval?: boolean;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
// Get the current feature to check if branch is changing
|
||||||
|
const currentFeature = features.find((f) => f.id === featureId);
|
||||||
|
const currentBranch = currentFeature?.branchName || "main";
|
||||||
|
const newBranch = updates.branchName || "main";
|
||||||
|
const branchIsChanging = currentBranch !== newBranch;
|
||||||
|
|
||||||
|
let worktreePath: string | undefined;
|
||||||
|
let shouldClearWorktreePath = false;
|
||||||
|
|
||||||
|
// If worktrees are enabled and branch is changing to a non-main branch, create worktree
|
||||||
|
if (useWorktrees && branchIsChanging) {
|
||||||
|
if (newBranch === "main" || newBranch === "master") {
|
||||||
|
// Changing to main - clear the worktreePath
|
||||||
|
shouldClearWorktreePath = true;
|
||||||
|
} else {
|
||||||
|
// Changing to a feature branch - create worktree if needed
|
||||||
|
const tempFeature = { branchName: newBranch } as Feature;
|
||||||
|
const path = await getOrCreateWorktreeForFeature(tempFeature);
|
||||||
|
if (path && path !== projectPath) {
|
||||||
|
worktreePath = path;
|
||||||
|
// Refresh worktree selector after creating worktree
|
||||||
|
onWorktreeCreated?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build final updates with worktreePath if it was changed
|
||||||
|
let finalUpdates: typeof updates & { worktreePath?: string };
|
||||||
|
if (branchIsChanging && useWorktrees) {
|
||||||
|
if (shouldClearWorktreePath) {
|
||||||
|
// Use null to clear the value in persistence (cast to work around type system)
|
||||||
|
finalUpdates = { ...updates, worktreePath: null as unknown as string | undefined };
|
||||||
|
} else {
|
||||||
|
finalUpdates = { ...updates, worktreePath };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
finalUpdates = updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFeature(featureId, finalUpdates);
|
||||||
|
persistFeatureUpdate(featureId, finalUpdates);
|
||||||
|
if (updates.category) {
|
||||||
|
saveCategory(updates.category);
|
||||||
|
}
|
||||||
|
setEditingFeature(null);
|
||||||
|
},
|
||||||
|
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, features, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteFeature = useCallback(
|
||||||
|
async (featureId: string) => {
|
||||||
|
const feature = features.find((f) => f.id === featureId);
|
||||||
|
if (!feature) return;
|
||||||
|
|
||||||
|
const isRunning = runningAutoTasks.includes(featureId);
|
||||||
|
|
||||||
|
if (isRunning) {
|
||||||
|
try {
|
||||||
|
await autoMode.stopFeature(featureId);
|
||||||
|
toast.success("Agent stopped", {
|
||||||
|
description: `Stopped and deleted: ${truncateDescription(
|
||||||
|
feature.description
|
||||||
|
)}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Error stopping feature before delete:", error);
|
||||||
|
toast.error("Failed to stop agent", {
|
||||||
|
description: "The feature will still be deleted.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feature.imagePaths && feature.imagePaths.length > 0) {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
for (const imagePathObj of feature.imagePaths) {
|
||||||
|
try {
|
||||||
|
await api.deleteFile(imagePathObj.path);
|
||||||
|
console.log(`[Board] Deleted image: ${imagePathObj.path}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[Board] Failed to delete image ${imagePathObj.path}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[Board] Error deleting images for feature ${featureId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFeature(featureId);
|
||||||
|
persistFeatureDelete(featureId);
|
||||||
|
},
|
||||||
|
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRunFeature = useCallback(
|
||||||
|
async (feature: Feature) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.autoMode) {
|
||||||
|
console.error("Auto mode API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the feature's assigned worktreePath (set when moving to in_progress)
|
||||||
|
// This ensures work happens in the correct worktree based on the feature's branchName
|
||||||
|
const featureWorktreePath = feature.worktreePath;
|
||||||
|
|
||||||
|
const result = await api.autoMode.runFeature(
|
||||||
|
currentProject.path,
|
||||||
|
feature.id,
|
||||||
|
useWorktrees,
|
||||||
|
featureWorktreePath || undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log(
|
||||||
|
"[Board] Feature run started successfully in worktree:",
|
||||||
|
featureWorktreePath || "main"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error("[Board] Failed to run feature:", result.error);
|
||||||
|
await loadFeatures();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Error running feature:", error);
|
||||||
|
await loadFeatures();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, useWorktrees, loadFeatures]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStartImplementation = useCallback(
|
||||||
|
async (feature: Feature) => {
|
||||||
|
if (!autoMode.canStartNewTask) {
|
||||||
|
toast.error("Concurrency limit reached", {
|
||||||
|
description: `You can only have ${autoMode.maxConcurrency} task${
|
||||||
|
autoMode.maxConcurrency > 1 ? "s" : ""
|
||||||
|
} running at a time. Wait for a task to complete or increase the limit.`,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for blocking dependencies and show warning if enabled
|
||||||
|
if (enableDependencyBlocking) {
|
||||||
|
const blockingDeps = getBlockingDependencies(feature, features);
|
||||||
|
if (blockingDeps.length > 0) {
|
||||||
|
const depDescriptions = blockingDeps.map(depId => {
|
||||||
|
const dep = features.find(f => f.id === depId);
|
||||||
|
return dep ? truncateDescription(dep.description, 40) : depId;
|
||||||
|
}).join(", ");
|
||||||
|
|
||||||
|
toast.warning("Starting feature with incomplete dependencies", {
|
||||||
|
description: `This feature depends on: ${depDescriptions}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
status: "in_progress" as const,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
updateFeature(feature.id, updates);
|
||||||
|
// Must await to ensure feature status is persisted before starting agent
|
||||||
|
await persistFeatureUpdate(feature.id, updates);
|
||||||
|
console.log("[Board] Feature moved to in_progress, starting agent...");
|
||||||
|
await handleRunFeature(feature);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[autoMode, enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleVerifyFeature = useCallback(
|
||||||
|
async (feature: Feature) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.autoMode) {
|
||||||
|
console.error("Auto mode API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.autoMode.verifyFeature(
|
||||||
|
currentProject.path,
|
||||||
|
feature.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log("[Board] Feature verification started successfully");
|
||||||
|
} else {
|
||||||
|
console.error("[Board] Failed to verify feature:", result.error);
|
||||||
|
await loadFeatures();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Error verifying feature:", error);
|
||||||
|
await loadFeatures();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, loadFeatures]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResumeFeature = useCallback(
|
||||||
|
async (feature: Feature) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.autoMode) {
|
||||||
|
console.error("Auto mode API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.autoMode.resumeFeature(
|
||||||
|
currentProject.path,
|
||||||
|
feature.id,
|
||||||
|
useWorktrees
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log("[Board] Feature resume started successfully");
|
||||||
|
} else {
|
||||||
|
console.error("[Board] Failed to resume feature:", result.error);
|
||||||
|
await loadFeatures();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Error resuming feature:", error);
|
||||||
|
await loadFeatures();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, loadFeatures, useWorktrees]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleManualVerify = useCallback(
|
||||||
|
(feature: Feature) => {
|
||||||
|
moveFeature(feature.id, "verified");
|
||||||
|
persistFeatureUpdate(feature.id, {
|
||||||
|
status: "verified",
|
||||||
|
justFinishedAt: undefined,
|
||||||
|
});
|
||||||
|
toast.success("Feature verified", {
|
||||||
|
description: `Marked as verified: ${truncateDescription(
|
||||||
|
feature.description
|
||||||
|
)}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[moveFeature, persistFeatureUpdate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMoveBackToInProgress = useCallback(
|
||||||
|
(feature: Feature) => {
|
||||||
|
const updates = {
|
||||||
|
status: "in_progress" as const,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
updateFeature(feature.id, updates);
|
||||||
|
persistFeatureUpdate(feature.id, updates);
|
||||||
|
toast.info("Feature moved back", {
|
||||||
|
description: `Moved back to In Progress: ${truncateDescription(
|
||||||
|
feature.description
|
||||||
|
)}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateFeature, persistFeatureUpdate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenFollowUp = useCallback(
|
||||||
|
(feature: Feature) => {
|
||||||
|
setFollowUpFeature(feature);
|
||||||
|
setFollowUpPrompt("");
|
||||||
|
setFollowUpImagePaths([]);
|
||||||
|
setShowFollowUpDialog(true);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
setFollowUpFeature,
|
||||||
|
setFollowUpPrompt,
|
||||||
|
setFollowUpImagePaths,
|
||||||
|
setShowFollowUpDialog,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSendFollowUp = useCallback(async () => {
|
||||||
|
if (!currentProject || !followUpFeature || !followUpPrompt.trim()) return;
|
||||||
|
|
||||||
|
const featureId = followUpFeature.id;
|
||||||
|
const featureDescription = followUpFeature.description;
|
||||||
|
const prompt = followUpPrompt;
|
||||||
|
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.autoMode?.followUpFeature) {
|
||||||
|
console.error("Follow-up feature API not available");
|
||||||
|
toast.error("Follow-up not available", {
|
||||||
|
description: "This feature is not available in the current version.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
status: "in_progress" as const,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
justFinishedAt: undefined,
|
||||||
|
};
|
||||||
|
updateFeature(featureId, updates);
|
||||||
|
persistFeatureUpdate(featureId, updates);
|
||||||
|
|
||||||
|
setShowFollowUpDialog(false);
|
||||||
|
setFollowUpFeature(null);
|
||||||
|
setFollowUpPrompt("");
|
||||||
|
setFollowUpImagePaths([]);
|
||||||
|
setFollowUpPreviewMap(new Map());
|
||||||
|
|
||||||
|
toast.success("Follow-up started", {
|
||||||
|
description: `Continuing work on: ${truncateDescription(
|
||||||
|
featureDescription
|
||||||
|
)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imagePaths = followUpImagePaths.map((img) => img.path);
|
||||||
|
// Use the feature's worktreePath to ensure work happens in the correct branch
|
||||||
|
const featureWorktreePath = followUpFeature.worktreePath;
|
||||||
|
api.autoMode
|
||||||
|
.followUpFeature(
|
||||||
|
currentProject.path,
|
||||||
|
followUpFeature.id,
|
||||||
|
followUpPrompt,
|
||||||
|
imagePaths,
|
||||||
|
featureWorktreePath
|
||||||
|
)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[Board] Error sending follow-up:", error);
|
||||||
|
toast.error("Failed to send follow-up", {
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : "An error occurred",
|
||||||
|
});
|
||||||
|
loadFeatures();
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
currentProject,
|
||||||
|
followUpFeature,
|
||||||
|
followUpPrompt,
|
||||||
|
followUpImagePaths,
|
||||||
|
updateFeature,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
setShowFollowUpDialog,
|
||||||
|
setFollowUpFeature,
|
||||||
|
setFollowUpPrompt,
|
||||||
|
setFollowUpImagePaths,
|
||||||
|
setFollowUpPreviewMap,
|
||||||
|
loadFeatures,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleCommitFeature = useCallback(
|
||||||
|
async (feature: Feature) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.autoMode?.commitFeature) {
|
||||||
|
console.error("Commit feature API not available");
|
||||||
|
toast.error("Commit not available", {
|
||||||
|
description:
|
||||||
|
"This feature is not available in the current version.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass the feature's worktreePath to ensure commits happen in the correct worktree
|
||||||
|
const result = await api.autoMode.commitFeature(
|
||||||
|
currentProject.path,
|
||||||
|
feature.id,
|
||||||
|
feature.worktreePath
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
moveFeature(feature.id, "verified");
|
||||||
|
persistFeatureUpdate(feature.id, { status: "verified" });
|
||||||
|
toast.success("Feature committed", {
|
||||||
|
description: `Committed and verified: ${truncateDescription(
|
||||||
|
feature.description
|
||||||
|
)}`,
|
||||||
|
});
|
||||||
|
// Refresh worktree selector to update commit counts
|
||||||
|
onWorktreeCreated?.();
|
||||||
|
} else {
|
||||||
|
console.error("[Board] Failed to commit feature:", result.error);
|
||||||
|
toast.error("Failed to commit feature", {
|
||||||
|
description: result.error || "An error occurred",
|
||||||
|
});
|
||||||
|
await loadFeatures();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Error committing feature:", error);
|
||||||
|
toast.error("Failed to commit feature", {
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : "An error occurred",
|
||||||
|
});
|
||||||
|
await loadFeatures();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
currentProject,
|
||||||
|
moveFeature,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
loadFeatures,
|
||||||
|
onWorktreeCreated,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMergeFeature = useCallback(
|
||||||
|
async (feature: Feature) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.mergeFeature) {
|
||||||
|
console.error("Worktree API not available");
|
||||||
|
toast.error("Merge not available", {
|
||||||
|
description:
|
||||||
|
"This feature is not available in the current version.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.worktree.mergeFeature(
|
||||||
|
currentProject.path,
|
||||||
|
feature.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await loadFeatures();
|
||||||
|
toast.success("Feature merged", {
|
||||||
|
description: `Changes merged to main branch: ${truncateDescription(
|
||||||
|
feature.description
|
||||||
|
)}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error("[Board] Failed to merge feature:", result.error);
|
||||||
|
toast.error("Failed to merge feature", {
|
||||||
|
description: result.error || "An error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Error merging feature:", error);
|
||||||
|
toast.error("Failed to merge feature", {
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : "An error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, loadFeatures]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCompleteFeature = useCallback(
|
||||||
|
(feature: Feature) => {
|
||||||
|
const updates = {
|
||||||
|
status: "completed" as const,
|
||||||
|
};
|
||||||
|
updateFeature(feature.id, updates);
|
||||||
|
persistFeatureUpdate(feature.id, updates);
|
||||||
|
|
||||||
|
toast.success("Feature completed", {
|
||||||
|
description: `Archived: ${truncateDescription(feature.description)}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateFeature, persistFeatureUpdate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUnarchiveFeature = useCallback(
|
||||||
|
(feature: Feature) => {
|
||||||
|
const updates = {
|
||||||
|
status: "verified" as const,
|
||||||
|
};
|
||||||
|
updateFeature(feature.id, updates);
|
||||||
|
persistFeatureUpdate(feature.id, updates);
|
||||||
|
|
||||||
|
toast.success("Feature restored", {
|
||||||
|
description: `Moved back to verified: ${truncateDescription(
|
||||||
|
feature.description
|
||||||
|
)}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateFeature, persistFeatureUpdate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleViewOutput = useCallback(
|
||||||
|
(feature: Feature) => {
|
||||||
|
setOutputFeature(feature);
|
||||||
|
setShowOutputModal(true);
|
||||||
|
},
|
||||||
|
[setOutputFeature, setShowOutputModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOutputModalNumberKeyPress = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
const index = key === "0" ? 9 : parseInt(key, 10) - 1;
|
||||||
|
const targetFeature = inProgressFeaturesForShortcuts[index];
|
||||||
|
|
||||||
|
if (!targetFeature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetFeature.id === outputFeature?.id) {
|
||||||
|
setShowOutputModal(false);
|
||||||
|
} else {
|
||||||
|
setOutputFeature(targetFeature);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
inProgressFeaturesForShortcuts,
|
||||||
|
outputFeature?.id,
|
||||||
|
setShowOutputModal,
|
||||||
|
setOutputFeature,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleForceStopFeature = useCallback(
|
||||||
|
async (feature: Feature) => {
|
||||||
|
try {
|
||||||
|
await autoMode.stopFeature(feature.id);
|
||||||
|
|
||||||
|
const targetStatus =
|
||||||
|
feature.skipTests && feature.status === "waiting_approval"
|
||||||
|
? "waiting_approval"
|
||||||
|
: "backlog";
|
||||||
|
|
||||||
|
if (targetStatus !== feature.status) {
|
||||||
|
moveFeature(feature.id, targetStatus);
|
||||||
|
// Must await to ensure file is written before user can restart
|
||||||
|
await persistFeatureUpdate(feature.id, { status: targetStatus });
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Agent stopped", {
|
||||||
|
description:
|
||||||
|
targetStatus === "waiting_approval"
|
||||||
|
? `Stopped commit - returned to waiting approval: ${truncateDescription(
|
||||||
|
feature.description
|
||||||
|
)}`
|
||||||
|
: `Stopped working on: ${truncateDescription(
|
||||||
|
feature.description
|
||||||
|
)}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Error stopping feature:", error);
|
||||||
|
toast.error("Failed to stop agent", {
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : "An error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[autoMode, moveFeature, persistFeatureUpdate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStartNextFeatures = useCallback(async () => {
|
||||||
|
// Filter backlog features by the currently selected worktree branch
|
||||||
|
// This ensures "G" only starts features from the filtered list
|
||||||
|
const backlogFeatures = features.filter((f) => {
|
||||||
|
if (f.status !== "backlog") return false;
|
||||||
|
|
||||||
|
// Determine the feature's branch (default to "main" if not set)
|
||||||
|
const featureBranch = f.branchName || "main";
|
||||||
|
|
||||||
|
// If no worktree is selected (currentWorktreeBranch is null or main-like),
|
||||||
|
// show features with no branch or "main"/"master" branch
|
||||||
|
if (
|
||||||
|
!currentWorktreeBranch ||
|
||||||
|
currentWorktreeBranch === "main" ||
|
||||||
|
currentWorktreeBranch === "master"
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
!f.branchName ||
|
||||||
|
featureBranch === "main" ||
|
||||||
|
featureBranch === "master"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, only show features matching the selected worktree branch
|
||||||
|
return featureBranch === currentWorktreeBranch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableSlots =
|
||||||
|
useAppStore.getState().maxConcurrency - runningAutoTasks.length;
|
||||||
|
|
||||||
|
if (availableSlots <= 0) {
|
||||||
|
toast.error("Concurrency limit reached", {
|
||||||
|
description:
|
||||||
|
"Wait for a task to complete or increase the concurrency limit.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backlogFeatures.length === 0) {
|
||||||
|
toast.info("Backlog empty", {
|
||||||
|
description:
|
||||||
|
currentWorktreeBranch &&
|
||||||
|
currentWorktreeBranch !== "main" &&
|
||||||
|
currentWorktreeBranch !== "master"
|
||||||
|
? `No features in backlog for branch "${currentWorktreeBranch}".`
|
||||||
|
: "No features in backlog to start.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by priority (lower number = higher priority, priority 1 is highest)
|
||||||
|
// This matches the auto mode service behavior for consistency
|
||||||
|
const sortedBacklog = [...backlogFeatures].sort(
|
||||||
|
(a, b) => (a.priority || 999) - (b.priority || 999)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start only one feature per keypress (user must press again for next)
|
||||||
|
const featuresToStart = sortedBacklog.slice(0, 1);
|
||||||
|
|
||||||
|
for (const feature of featuresToStart) {
|
||||||
|
// Only create worktrees if the feature is enabled
|
||||||
|
let worktreePath: string | null = null;
|
||||||
|
if (useWorktrees) {
|
||||||
|
// Get or create worktree based on the feature's assigned branch (same as drag-to-in-progress)
|
||||||
|
worktreePath = await getOrCreateWorktreeForFeature(feature);
|
||||||
|
if (worktreePath) {
|
||||||
|
await persistFeatureUpdate(feature.id, { worktreePath });
|
||||||
|
}
|
||||||
|
// Refresh worktree selector after creating worktree
|
||||||
|
onWorktreeCreated?.();
|
||||||
|
}
|
||||||
|
// Start the implementation
|
||||||
|
// Pass feature with worktreePath so handleRunFeature uses the correct path
|
||||||
|
await handleStartImplementation({
|
||||||
|
...feature,
|
||||||
|
worktreePath: worktreePath || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
features,
|
||||||
|
runningAutoTasks,
|
||||||
|
handleStartImplementation,
|
||||||
|
getOrCreateWorktreeForFeature,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
onWorktreeCreated,
|
||||||
|
currentWorktreeBranch,
|
||||||
|
useWorktrees,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDeleteAllVerified = useCallback(async () => {
|
||||||
|
const verifiedFeatures = features.filter((f) => f.status === "verified");
|
||||||
|
|
||||||
|
for (const feature of verifiedFeatures) {
|
||||||
|
const isRunning = runningAutoTasks.includes(feature.id);
|
||||||
|
if (isRunning) {
|
||||||
|
try {
|
||||||
|
await autoMode.stopFeature(feature.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Error stopping feature before delete:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removeFeature(feature.id);
|
||||||
|
persistFeatureDelete(feature.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("All verified features deleted", {
|
||||||
|
description: `Deleted ${verifiedFeatures.length} feature(s).`,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
features,
|
||||||
|
runningAutoTasks,
|
||||||
|
autoMode,
|
||||||
|
removeFeature,
|
||||||
|
persistFeatureDelete,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleAddFeature,
|
||||||
|
handleUpdateFeature,
|
||||||
|
handleDeleteFeature,
|
||||||
|
handleStartImplementation,
|
||||||
|
handleVerifyFeature,
|
||||||
|
handleResumeFeature,
|
||||||
|
handleManualVerify,
|
||||||
|
handleMoveBackToInProgress,
|
||||||
|
handleOpenFollowUp,
|
||||||
|
handleSendFollowUp,
|
||||||
|
handleCommitFeature,
|
||||||
|
handleMergeFeature,
|
||||||
|
handleCompleteFeature,
|
||||||
|
handleUnarchiveFeature,
|
||||||
|
handleViewOutput,
|
||||||
|
handleOutputModalNumberKeyPress,
|
||||||
|
handleForceStopFeature,
|
||||||
|
handleStartNextFeatures,
|
||||||
|
handleDeleteAllVerified,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useAppStore, defaultBackgroundSettings } from "@/store/app-store";
|
||||||
|
|
||||||
|
interface UseBoardBackgroundProps {
|
||||||
|
currentProject: { path: string; id: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBoardBackground({ currentProject }: UseBoardBackgroundProps) {
|
||||||
|
const boardBackgroundByProject = useAppStore(
|
||||||
|
(state) => state.boardBackgroundByProject
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get background settings for current project
|
||||||
|
const backgroundSettings = useMemo(() => {
|
||||||
|
return (
|
||||||
|
(currentProject && boardBackgroundByProject[currentProject.path]) ||
|
||||||
|
defaultBackgroundSettings
|
||||||
|
);
|
||||||
|
}, [currentProject, boardBackgroundByProject]);
|
||||||
|
|
||||||
|
// Build background image style if image exists
|
||||||
|
const backgroundImageStyle = useMemo(() => {
|
||||||
|
if (!backgroundSettings.imagePath || !currentProject) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundImage: `url(${
|
||||||
|
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"
|
||||||
|
}/api/fs/image?path=${encodeURIComponent(
|
||||||
|
backgroundSettings.imagePath
|
||||||
|
)}&projectPath=${encodeURIComponent(currentProject.path)}${
|
||||||
|
backgroundSettings.imageVersion
|
||||||
|
? `&v=${backgroundSettings.imageVersion}`
|
||||||
|
: ""
|
||||||
|
})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
} as React.CSSProperties;
|
||||||
|
}, [backgroundSettings, currentProject]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundSettings,
|
||||||
|
backgroundImageStyle,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useMemo, useCallback } from "react";
|
||||||
|
import { Feature } from "@/store/app-store";
|
||||||
|
import { resolveDependencies } from "@/lib/dependency-resolver";
|
||||||
|
import { pathsEqual } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ColumnId = Feature["status"];
|
||||||
|
|
||||||
|
interface UseBoardColumnFeaturesProps {
|
||||||
|
features: Feature[];
|
||||||
|
runningAutoTasks: string[];
|
||||||
|
searchQuery: string;
|
||||||
|
currentWorktreePath: string | null; // Currently selected worktree path
|
||||||
|
currentWorktreeBranch: string | null; // Branch name of the selected worktree (null = main)
|
||||||
|
projectPath: string | null; // Main project path (for main worktree)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBoardColumnFeatures({
|
||||||
|
features,
|
||||||
|
runningAutoTasks,
|
||||||
|
searchQuery,
|
||||||
|
currentWorktreePath,
|
||||||
|
currentWorktreeBranch,
|
||||||
|
projectPath,
|
||||||
|
}: UseBoardColumnFeaturesProps) {
|
||||||
|
// Memoize column features to prevent unnecessary re-renders
|
||||||
|
const columnFeaturesMap = useMemo(() => {
|
||||||
|
const map: Record<ColumnId, Feature[]> = {
|
||||||
|
backlog: [],
|
||||||
|
in_progress: [],
|
||||||
|
waiting_approval: [],
|
||||||
|
verified: [],
|
||||||
|
completed: [], // Completed features are shown in the archive modal, not as a column
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter features by search query (case-insensitive)
|
||||||
|
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||||
|
const filteredFeatures = normalizedQuery
|
||||||
|
? features.filter(
|
||||||
|
(f) =>
|
||||||
|
f.description.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
f.category?.toLowerCase().includes(normalizedQuery)
|
||||||
|
)
|
||||||
|
: features;
|
||||||
|
|
||||||
|
// Determine the effective worktree path and branch for filtering
|
||||||
|
// If currentWorktreePath is null, we're on the main worktree
|
||||||
|
const effectiveWorktreePath = currentWorktreePath || projectPath;
|
||||||
|
// Use the branch name from the selected worktree
|
||||||
|
// If we're selecting main (currentWorktreePath is null), currentWorktreeBranch
|
||||||
|
// should contain the main branch's actual name, defaulting to "main"
|
||||||
|
// If we're selecting a non-main worktree but can't find it, currentWorktreeBranch is null
|
||||||
|
// In that case, we can't do branch-based filtering, so we'll handle it specially below
|
||||||
|
const effectiveBranch = currentWorktreeBranch;
|
||||||
|
|
||||||
|
filteredFeatures.forEach((f) => {
|
||||||
|
// If feature has a running agent, always show it in "in_progress"
|
||||||
|
const isRunning = runningAutoTasks.includes(f.id);
|
||||||
|
|
||||||
|
// Check if feature matches the current worktree
|
||||||
|
// Match by worktreePath if set, OR by branchName if set
|
||||||
|
// Features with neither are considered unassigned (show on ALL worktrees)
|
||||||
|
const featureBranch = f.branchName || "main";
|
||||||
|
const hasWorktreeAssigned = f.worktreePath || f.branchName;
|
||||||
|
|
||||||
|
let matchesWorktree: boolean;
|
||||||
|
if (!hasWorktreeAssigned) {
|
||||||
|
// No worktree or branch assigned - show on ALL worktrees (unassigned)
|
||||||
|
matchesWorktree = true;
|
||||||
|
} else if (f.worktreePath) {
|
||||||
|
// Has worktreePath - match by path (use pathsEqual for cross-platform compatibility)
|
||||||
|
matchesWorktree = pathsEqual(f.worktreePath, effectiveWorktreePath);
|
||||||
|
} else if (effectiveBranch === null) {
|
||||||
|
// We're viewing main but branch hasn't been initialized yet
|
||||||
|
// (worktrees disabled or haven't loaded yet).
|
||||||
|
// Show features assigned to main/master branch since we're on the main worktree.
|
||||||
|
matchesWorktree = featureBranch === "main" || featureBranch === "master";
|
||||||
|
} else {
|
||||||
|
// Has branchName but no worktreePath - match by branch name
|
||||||
|
matchesWorktree = featureBranch === effectiveBranch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRunning) {
|
||||||
|
// Only show running tasks if they match the current worktree
|
||||||
|
if (matchesWorktree) {
|
||||||
|
map.in_progress.push(f);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Otherwise, use the feature's status (fallback to backlog for unknown statuses)
|
||||||
|
const status = f.status as ColumnId;
|
||||||
|
|
||||||
|
// Filter all items by worktree, including backlog
|
||||||
|
// This ensures backlog items with a branch assigned only show in that branch
|
||||||
|
if (status === "backlog") {
|
||||||
|
if (matchesWorktree) {
|
||||||
|
map.backlog.push(f);
|
||||||
|
}
|
||||||
|
} else if (map[status]) {
|
||||||
|
// Only show if matches current worktree or has no worktree assigned
|
||||||
|
if (matchesWorktree) {
|
||||||
|
map[status].push(f);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unknown status, default to backlog
|
||||||
|
map.backlog.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply dependency-aware sorting to backlog
|
||||||
|
// This ensures features appear in dependency order (dependencies before dependents)
|
||||||
|
// Within the same dependency level, features are sorted by priority
|
||||||
|
if (map.backlog.length > 0) {
|
||||||
|
const { orderedFeatures } = resolveDependencies(map.backlog);
|
||||||
|
map.backlog = orderedFeatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);
|
||||||
|
|
||||||
|
const getColumnFeatures = useCallback(
|
||||||
|
(columnId: ColumnId) => {
|
||||||
|
return columnFeaturesMap[columnId];
|
||||||
|
},
|
||||||
|
[columnFeaturesMap]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize completed features for the archive modal
|
||||||
|
const completedFeatures = useMemo(() => {
|
||||||
|
return features.filter((f) => f.status === "completed");
|
||||||
|
}, [features]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
columnFeaturesMap,
|
||||||
|
getColumnFeatures,
|
||||||
|
completedFeatures,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { DragStartEvent, DragEndEvent } from "@dnd-kit/core";
|
||||||
|
import { Feature } from "@/store/app-store";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { COLUMNS, ColumnId } from "../constants";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
|
||||||
|
interface UseBoardDragDropProps {
|
||||||
|
features: Feature[];
|
||||||
|
currentProject: { path: string; id: string } | null;
|
||||||
|
runningAutoTasks: string[];
|
||||||
|
persistFeatureUpdate: (
|
||||||
|
featureId: string,
|
||||||
|
updates: Partial<Feature>
|
||||||
|
) => Promise<void>;
|
||||||
|
handleStartImplementation: (feature: Feature) => Promise<boolean>;
|
||||||
|
projectPath: string | null; // Main project path
|
||||||
|
onWorktreeCreated?: () => void; // Callback when a new worktree is created
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBoardDragDrop({
|
||||||
|
features,
|
||||||
|
currentProject,
|
||||||
|
runningAutoTasks,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
handleStartImplementation,
|
||||||
|
projectPath,
|
||||||
|
onWorktreeCreated,
|
||||||
|
}: UseBoardDragDropProps) {
|
||||||
|
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||||
|
const { moveFeature, useWorktrees } = useAppStore();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the worktree path for a feature based on its branchName.
|
||||||
|
* - If branchName is "main" or empty, returns the project path
|
||||||
|
* - Otherwise, creates a worktree for that branch if needed
|
||||||
|
*/
|
||||||
|
const getOrCreateWorktreeForFeature = useCallback(
|
||||||
|
async (feature: Feature): Promise<string | null> => {
|
||||||
|
if (!projectPath) return null;
|
||||||
|
|
||||||
|
const branchName = feature.branchName || "main";
|
||||||
|
|
||||||
|
// If targeting main branch, use the project path directly
|
||||||
|
if (branchName === "main" || branchName === "master") {
|
||||||
|
return projectPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other branches, create a worktree if it doesn't exist
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.create) {
|
||||||
|
console.error("[DragDrop] Worktree API not available");
|
||||||
|
return projectPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to create the worktree (will return existing if already exists)
|
||||||
|
const result = await api.worktree.create(projectPath, branchName);
|
||||||
|
|
||||||
|
if (result.success && result.worktree) {
|
||||||
|
console.log(
|
||||||
|
`[DragDrop] Worktree ready for branch "${branchName}": ${result.worktree.path}`
|
||||||
|
);
|
||||||
|
if (result.worktree.isNew) {
|
||||||
|
toast.success(`Worktree created for branch "${branchName}"`, {
|
||||||
|
description: "A new worktree was created for this feature.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.worktree.path;
|
||||||
|
} else {
|
||||||
|
console.error("[DragDrop] Failed to create worktree:", result.error);
|
||||||
|
toast.error("Failed to create worktree", {
|
||||||
|
description: result.error || "Could not create worktree for this branch.",
|
||||||
|
});
|
||||||
|
return projectPath; // Fall back to project path
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[DragDrop] Error creating worktree:", error);
|
||||||
|
toast.error("Error creating worktree", {
|
||||||
|
description: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
return projectPath; // Fall back to project path
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectPath]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(event: DragStartEvent) => {
|
||||||
|
const { active } = event;
|
||||||
|
const feature = features.find((f) => f.id === active.id);
|
||||||
|
if (feature) {
|
||||||
|
setActiveFeature(feature);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[features]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
async (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
setActiveFeature(null);
|
||||||
|
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const featureId = active.id as string;
|
||||||
|
const overId = over.id as string;
|
||||||
|
|
||||||
|
// Find the feature being dragged
|
||||||
|
const draggedFeature = features.find((f) => f.id === featureId);
|
||||||
|
if (!draggedFeature) return;
|
||||||
|
|
||||||
|
// Check if this is a running task (non-skipTests, TDD)
|
||||||
|
const isRunningTask = runningAutoTasks.includes(featureId);
|
||||||
|
|
||||||
|
// Determine if dragging is allowed based on status and skipTests
|
||||||
|
// - Backlog items can always be dragged
|
||||||
|
// - waiting_approval items can always be dragged (to allow manual verification via drag)
|
||||||
|
// - verified items can always be dragged (to allow moving back to waiting_approval)
|
||||||
|
// - skipTests (non-TDD) items can be dragged between in_progress and verified
|
||||||
|
// - Non-skipTests (TDD) items that are in progress cannot be dragged (they are running)
|
||||||
|
if (
|
||||||
|
draggedFeature.status !== "backlog" &&
|
||||||
|
draggedFeature.status !== "waiting_approval" &&
|
||||||
|
draggedFeature.status !== "verified"
|
||||||
|
) {
|
||||||
|
// Only allow dragging in_progress if it's a skipTests feature and not currently running
|
||||||
|
if (!draggedFeature.skipTests || isRunningTask) {
|
||||||
|
console.log(
|
||||||
|
"[Board] Cannot drag feature - TDD feature or currently running"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetStatus: ColumnId | null = null;
|
||||||
|
|
||||||
|
// Check if we dropped on a column
|
||||||
|
const column = COLUMNS.find((c) => c.id === overId);
|
||||||
|
if (column) {
|
||||||
|
targetStatus = column.id;
|
||||||
|
} else {
|
||||||
|
// Dropped on another feature - find its column
|
||||||
|
const overFeature = features.find((f) => f.id === overId);
|
||||||
|
if (overFeature) {
|
||||||
|
targetStatus = overFeature.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetStatus) return;
|
||||||
|
|
||||||
|
// Same column, nothing to do
|
||||||
|
if (targetStatus === draggedFeature.status) return;
|
||||||
|
|
||||||
|
// Handle different drag scenarios
|
||||||
|
if (draggedFeature.status === "backlog") {
|
||||||
|
// From backlog
|
||||||
|
if (targetStatus === "in_progress") {
|
||||||
|
// Only create worktrees if the feature is enabled
|
||||||
|
let worktreePath: string | null = null;
|
||||||
|
if (useWorktrees) {
|
||||||
|
// Get or create worktree based on the feature's assigned branch
|
||||||
|
worktreePath = await getOrCreateWorktreeForFeature(draggedFeature);
|
||||||
|
if (worktreePath) {
|
||||||
|
await persistFeatureUpdate(featureId, { worktreePath });
|
||||||
|
}
|
||||||
|
// Refresh worktree selector after moving to in_progress
|
||||||
|
onWorktreeCreated?.();
|
||||||
|
}
|
||||||
|
// Use helper function to handle concurrency check and start implementation
|
||||||
|
// Pass feature with worktreePath so handleRunFeature uses the correct path
|
||||||
|
await handleStartImplementation({ ...draggedFeature, worktreePath: worktreePath || undefined });
|
||||||
|
} else {
|
||||||
|
moveFeature(featureId, targetStatus);
|
||||||
|
persistFeatureUpdate(featureId, { status: targetStatus });
|
||||||
|
}
|
||||||
|
} else if (draggedFeature.status === "waiting_approval") {
|
||||||
|
// waiting_approval features can be dragged to verified for manual verification
|
||||||
|
// NOTE: This check must come BEFORE skipTests check because waiting_approval
|
||||||
|
// features often have skipTests=true, and we want status-based handling first
|
||||||
|
if (targetStatus === "verified") {
|
||||||
|
moveFeature(featureId, "verified");
|
||||||
|
// Clear justFinishedAt timestamp when manually verifying via drag
|
||||||
|
persistFeatureUpdate(featureId, {
|
||||||
|
status: "verified",
|
||||||
|
justFinishedAt: undefined,
|
||||||
|
});
|
||||||
|
toast.success("Feature verified", {
|
||||||
|
description: `Manually verified: ${draggedFeature.description.slice(
|
||||||
|
0,
|
||||||
|
50
|
||||||
|
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||||
|
});
|
||||||
|
} else if (targetStatus === "backlog") {
|
||||||
|
// Allow moving waiting_approval cards back to backlog
|
||||||
|
moveFeature(featureId, "backlog");
|
||||||
|
// Clear justFinishedAt timestamp and worktreePath when moving back to backlog
|
||||||
|
persistFeatureUpdate(featureId, {
|
||||||
|
status: "backlog",
|
||||||
|
justFinishedAt: undefined,
|
||||||
|
worktreePath: undefined,
|
||||||
|
});
|
||||||
|
toast.info("Feature moved to backlog", {
|
||||||
|
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||||
|
0,
|
||||||
|
50
|
||||||
|
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (draggedFeature.skipTests) {
|
||||||
|
// skipTests feature being moved between in_progress and verified
|
||||||
|
if (
|
||||||
|
targetStatus === "verified" &&
|
||||||
|
draggedFeature.status === "in_progress"
|
||||||
|
) {
|
||||||
|
// Manual verify via drag
|
||||||
|
moveFeature(featureId, "verified");
|
||||||
|
persistFeatureUpdate(featureId, { status: "verified" });
|
||||||
|
toast.success("Feature verified", {
|
||||||
|
description: `Marked as verified: ${draggedFeature.description.slice(
|
||||||
|
0,
|
||||||
|
50
|
||||||
|
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
targetStatus === "waiting_approval" &&
|
||||||
|
draggedFeature.status === "verified"
|
||||||
|
) {
|
||||||
|
// Move verified feature back to waiting_approval
|
||||||
|
moveFeature(featureId, "waiting_approval");
|
||||||
|
persistFeatureUpdate(featureId, { status: "waiting_approval" });
|
||||||
|
toast.info("Feature moved back", {
|
||||||
|
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
|
||||||
|
0,
|
||||||
|
50
|
||||||
|
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||||
|
});
|
||||||
|
} else if (targetStatus === "backlog") {
|
||||||
|
// Allow moving skipTests cards back to backlog
|
||||||
|
moveFeature(featureId, "backlog");
|
||||||
|
// Clear worktreePath when moving back to backlog
|
||||||
|
persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
|
||||||
|
toast.info("Feature moved to backlog", {
|
||||||
|
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||||
|
0,
|
||||||
|
50
|
||||||
|
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (draggedFeature.status === "verified") {
|
||||||
|
// Handle verified TDD (non-skipTests) features being moved back
|
||||||
|
if (targetStatus === "waiting_approval") {
|
||||||
|
// Move verified feature back to waiting_approval
|
||||||
|
moveFeature(featureId, "waiting_approval");
|
||||||
|
persistFeatureUpdate(featureId, { status: "waiting_approval" });
|
||||||
|
toast.info("Feature moved back", {
|
||||||
|
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
|
||||||
|
0,
|
||||||
|
50
|
||||||
|
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||||
|
});
|
||||||
|
} else if (targetStatus === "backlog") {
|
||||||
|
// Allow moving verified cards back to backlog
|
||||||
|
moveFeature(featureId, "backlog");
|
||||||
|
// Clear worktreePath when moving back to backlog
|
||||||
|
persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
|
||||||
|
toast.info("Feature moved to backlog", {
|
||||||
|
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||||
|
0,
|
||||||
|
50
|
||||||
|
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
features,
|
||||||
|
runningAutoTasks,
|
||||||
|
moveFeature,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
handleStartImplementation,
|
||||||
|
getOrCreateWorktreeForFeature,
|
||||||
|
onWorktreeCreated,
|
||||||
|
useWorktrees,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeFeature,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||||
|
|
||||||
|
interface UseBoardEffectsProps {
|
||||||
|
currentProject: { path: string; id: string } | null;
|
||||||
|
specCreatingForProject: string | null;
|
||||||
|
setSpecCreatingForProject: (path: string | null) => void;
|
||||||
|
setSuggestionsCount: (count: number) => void;
|
||||||
|
setFeatureSuggestions: (suggestions: any[]) => void;
|
||||||
|
setIsGeneratingSuggestions: (generating: boolean) => void;
|
||||||
|
checkContextExists: (featureId: string) => Promise<boolean>;
|
||||||
|
features: any[];
|
||||||
|
isLoading: boolean;
|
||||||
|
setFeaturesWithContext: (set: Set<string>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBoardEffects({
|
||||||
|
currentProject,
|
||||||
|
specCreatingForProject,
|
||||||
|
setSpecCreatingForProject,
|
||||||
|
setSuggestionsCount,
|
||||||
|
setFeatureSuggestions,
|
||||||
|
setIsGeneratingSuggestions,
|
||||||
|
checkContextExists,
|
||||||
|
features,
|
||||||
|
isLoading,
|
||||||
|
setFeaturesWithContext,
|
||||||
|
}: UseBoardEffectsProps) {
|
||||||
|
const autoMode = useAutoMode();
|
||||||
|
|
||||||
|
// Make current project available globally for modal
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentProject) {
|
||||||
|
(window as any).__currentProject = currentProject;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
(window as any).__currentProject = null;
|
||||||
|
};
|
||||||
|
}, [currentProject]);
|
||||||
|
|
||||||
|
// Listen for suggestions events to update count (persists even when dialog is closed)
|
||||||
|
useEffect(() => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.suggestions) return;
|
||||||
|
|
||||||
|
const unsubscribe = api.suggestions.onEvent((event) => {
|
||||||
|
if (event.type === "suggestions_complete" && event.suggestions) {
|
||||||
|
setSuggestionsCount(event.suggestions.length);
|
||||||
|
setFeatureSuggestions(event.suggestions);
|
||||||
|
setIsGeneratingSuggestions(false);
|
||||||
|
} else if (event.type === "suggestions_error") {
|
||||||
|
setIsGeneratingSuggestions(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [setSuggestionsCount, setFeatureSuggestions, setIsGeneratingSuggestions]);
|
||||||
|
|
||||||
|
// Subscribe to spec regeneration events to clear creating state on completion
|
||||||
|
useEffect(() => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.specRegeneration) return;
|
||||||
|
|
||||||
|
const unsubscribe = api.specRegeneration.onEvent((event) => {
|
||||||
|
console.log(
|
||||||
|
"[BoardView] Spec regeneration event:",
|
||||||
|
event.type,
|
||||||
|
"for project:",
|
||||||
|
event.projectPath
|
||||||
|
);
|
||||||
|
|
||||||
|
if (event.projectPath !== specCreatingForProject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "spec_regeneration_complete") {
|
||||||
|
setSpecCreatingForProject(null);
|
||||||
|
} else if (event.type === "spec_regeneration_error") {
|
||||||
|
setSpecCreatingForProject(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [specCreatingForProject, setSpecCreatingForProject]);
|
||||||
|
|
||||||
|
// Sync running tasks from electron backend on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
const syncRunningTasks = async () => {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.autoMode?.status) return;
|
||||||
|
|
||||||
|
const status = await api.autoMode.status(currentProject.path);
|
||||||
|
if (status.success) {
|
||||||
|
const projectId = currentProject.id;
|
||||||
|
const { clearRunningTasks, addRunningTask, setAutoModeRunning } =
|
||||||
|
useAppStore.getState();
|
||||||
|
|
||||||
|
if (status.runningFeatures) {
|
||||||
|
console.log(
|
||||||
|
"[Board] Syncing running tasks from backend:",
|
||||||
|
status.runningFeatures
|
||||||
|
);
|
||||||
|
|
||||||
|
clearRunningTasks(projectId);
|
||||||
|
|
||||||
|
status.runningFeatures.forEach((featureId: string) => {
|
||||||
|
addRunningTask(projectId, featureId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAutoModeRunning =
|
||||||
|
status.autoLoopRunning ?? status.isRunning ?? false;
|
||||||
|
console.log(
|
||||||
|
"[Board] Syncing auto mode running state:",
|
||||||
|
isAutoModeRunning
|
||||||
|
);
|
||||||
|
setAutoModeRunning(projectId, isAutoModeRunning);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Failed to sync running tasks:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
syncRunningTasks();
|
||||||
|
}, [currentProject]);
|
||||||
|
|
||||||
|
// Check which features have context files
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAllContexts = async () => {
|
||||||
|
const featuresWithPotentialContext = features.filter(
|
||||||
|
(f) =>
|
||||||
|
f.status === "in_progress" ||
|
||||||
|
f.status === "waiting_approval" ||
|
||||||
|
f.status === "verified"
|
||||||
|
);
|
||||||
|
const contextChecks = await Promise.all(
|
||||||
|
featuresWithPotentialContext.map(async (f) => ({
|
||||||
|
id: f.id,
|
||||||
|
hasContext: await checkContextExists(f.id),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const newSet = new Set<string>();
|
||||||
|
contextChecks.forEach(({ id, hasContext }) => {
|
||||||
|
if (hasContext) {
|
||||||
|
newSet.add(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setFeaturesWithContext(newSet);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (features.length > 0 && !isLoading) {
|
||||||
|
checkAllContexts();
|
||||||
|
}
|
||||||
|
}, [features, isLoading, checkContextExists, setFeaturesWithContext]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
import { useAppStore, Feature } from "@/store/app-store";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface UseBoardFeaturesProps {
|
||||||
|
currentProject: { path: string; id: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||||
|
const { features, setFeatures } = useAppStore();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Track previous project path to detect project switches
|
||||||
|
const prevProjectPathRef = useRef<string | null>(null);
|
||||||
|
const isInitialLoadRef = useRef(true);
|
||||||
|
const isSwitchingProjectRef = useRef(false);
|
||||||
|
|
||||||
|
// Load features using features API
|
||||||
|
// IMPORTANT: Do NOT add 'features' to dependency array - it would cause infinite reload loop
|
||||||
|
const loadFeatures = useCallback(async () => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
const currentPath = currentProject.path;
|
||||||
|
const previousPath = prevProjectPathRef.current;
|
||||||
|
const isProjectSwitch =
|
||||||
|
previousPath !== null && currentPath !== previousPath;
|
||||||
|
|
||||||
|
// Get cached features from store (without adding to dependencies)
|
||||||
|
const cachedFeatures = useAppStore.getState().features;
|
||||||
|
|
||||||
|
// If project switched, mark it but don't clear features yet
|
||||||
|
// We'll clear after successful API load to prevent data loss
|
||||||
|
if (isProjectSwitch) {
|
||||||
|
console.log(
|
||||||
|
`[BoardView] Project switch detected: ${previousPath} -> ${currentPath}`
|
||||||
|
);
|
||||||
|
isSwitchingProjectRef.current = true;
|
||||||
|
isInitialLoadRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the ref to track current project
|
||||||
|
prevProjectPathRef.current = currentPath;
|
||||||
|
|
||||||
|
// Only show loading spinner on initial load to prevent board flash during reloads
|
||||||
|
if (isInitialLoadRef.current) {
|
||||||
|
setIsLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.features) {
|
||||||
|
console.error("[BoardView] Features API not available");
|
||||||
|
// Keep cached features if API is unavailable
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.features.getAll(currentProject.path);
|
||||||
|
|
||||||
|
if (result.success && result.features) {
|
||||||
|
const featuresWithIds = result.features.map(
|
||||||
|
(f: any, index: number) => ({
|
||||||
|
...f,
|
||||||
|
id: f.id || `feature-${index}-${Date.now()}`,
|
||||||
|
status: f.status || "backlog",
|
||||||
|
startedAt: f.startedAt, // Preserve startedAt timestamp
|
||||||
|
// Ensure model and thinkingLevel are set for backward compatibility
|
||||||
|
model: f.model || "opus",
|
||||||
|
thinkingLevel: f.thinkingLevel || "none",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Successfully loaded features - now safe to set them
|
||||||
|
setFeatures(featuresWithIds);
|
||||||
|
|
||||||
|
// Only clear categories on project switch AFTER successful load
|
||||||
|
if (isProjectSwitch) {
|
||||||
|
setPersistedCategories([]);
|
||||||
|
}
|
||||||
|
} else if (!result.success && result.error) {
|
||||||
|
console.error("[BoardView] API returned error:", result.error);
|
||||||
|
// If it's a new project or the error indicates no features found,
|
||||||
|
// that's expected - start with empty array
|
||||||
|
if (isProjectSwitch) {
|
||||||
|
setFeatures([]);
|
||||||
|
setPersistedCategories([]);
|
||||||
|
}
|
||||||
|
// Otherwise keep cached features
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load features:", error);
|
||||||
|
// On error, keep existing cached features for the current project
|
||||||
|
// Only clear on project switch if we have no features from server
|
||||||
|
if (isProjectSwitch && cachedFeatures.length === 0) {
|
||||||
|
setFeatures([]);
|
||||||
|
setPersistedCategories([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
isInitialLoadRef.current = false;
|
||||||
|
isSwitchingProjectRef.current = false;
|
||||||
|
}
|
||||||
|
}, [currentProject, setFeatures]);
|
||||||
|
|
||||||
|
// Load persisted categories from file
|
||||||
|
const loadCategories = useCallback(async () => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.readFile(
|
||||||
|
`${currentProject.path}/.automaker/categories.json`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success && result.content) {
|
||||||
|
const parsed = JSON.parse(result.content);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
setPersistedCategories(parsed);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// File doesn't exist, ensure categories are cleared
|
||||||
|
setPersistedCategories([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load categories:", error);
|
||||||
|
// If file doesn't exist, ensure categories are cleared
|
||||||
|
setPersistedCategories([]);
|
||||||
|
}
|
||||||
|
}, [currentProject]);
|
||||||
|
|
||||||
|
// Save a new category to the persisted categories file
|
||||||
|
const saveCategory = useCallback(
|
||||||
|
async (category: string) => {
|
||||||
|
if (!currentProject || !category.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
|
||||||
|
// Read existing categories
|
||||||
|
let categories: string[] = [...persistedCategories];
|
||||||
|
|
||||||
|
// Add new category if it doesn't exist
|
||||||
|
if (!categories.includes(category)) {
|
||||||
|
categories.push(category);
|
||||||
|
categories.sort(); // Keep sorted
|
||||||
|
|
||||||
|
// Write back to file
|
||||||
|
await api.writeFile(
|
||||||
|
`${currentProject.path}/.automaker/categories.json`,
|
||||||
|
JSON.stringify(categories, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
setPersistedCategories(categories);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save category:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, persistedCategories]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subscribe to spec regeneration complete events to refresh kanban board
|
||||||
|
useEffect(() => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.specRegeneration) return;
|
||||||
|
|
||||||
|
const unsubscribe = api.specRegeneration.onEvent((event) => {
|
||||||
|
// Refresh the kanban board when spec regeneration completes for the current project
|
||||||
|
if (
|
||||||
|
event.type === "spec_regeneration_complete" &&
|
||||||
|
currentProject &&
|
||||||
|
event.projectPath === currentProject.path
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
"[BoardView] Spec regeneration complete, refreshing features"
|
||||||
|
);
|
||||||
|
loadFeatures();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [currentProject, loadFeatures]);
|
||||||
|
|
||||||
|
// Listen for auto mode feature completion and errors to reload features
|
||||||
|
useEffect(() => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.autoMode || !currentProject) return;
|
||||||
|
|
||||||
|
const { removeRunningTask } = useAppStore.getState();
|
||||||
|
const projectId = currentProject.id;
|
||||||
|
|
||||||
|
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||||
|
// Use event's projectPath or projectId if available, otherwise use current project
|
||||||
|
// Board view only reacts to events for the currently selected project
|
||||||
|
const eventProjectId =
|
||||||
|
("projectId" in event && event.projectId) || projectId;
|
||||||
|
|
||||||
|
if (event.type === "auto_mode_feature_complete") {
|
||||||
|
// Reload features when a feature is completed
|
||||||
|
console.log("[Board] Feature completed, reloading features...");
|
||||||
|
loadFeatures();
|
||||||
|
// Play ding sound when feature is done (unless muted)
|
||||||
|
const { muteDoneSound } = useAppStore.getState();
|
||||||
|
if (!muteDoneSound) {
|
||||||
|
const audio = new Audio("/sounds/ding.mp3");
|
||||||
|
audio
|
||||||
|
.play()
|
||||||
|
.catch((err) => console.warn("Could not play ding sound:", err));
|
||||||
|
}
|
||||||
|
} else if (event.type === "plan_approval_required") {
|
||||||
|
// Reload features when plan is generated and requires approval
|
||||||
|
// This ensures the feature card shows the "Approve Plan" button
|
||||||
|
console.log("[Board] Plan approval required, reloading features...");
|
||||||
|
loadFeatures();
|
||||||
|
} else if (event.type === "auto_mode_error") {
|
||||||
|
// Reload features when an error occurs (feature moved to waiting_approval)
|
||||||
|
console.log(
|
||||||
|
"[Board] Feature error, reloading features...",
|
||||||
|
event.error
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove from running tasks so it moves to the correct column
|
||||||
|
if (event.featureId) {
|
||||||
|
removeRunningTask(eventProjectId, event.featureId);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFeatures();
|
||||||
|
|
||||||
|
// Check for authentication errors and show a more helpful message
|
||||||
|
const isAuthError =
|
||||||
|
event.errorType === "authentication" ||
|
||||||
|
(event.error &&
|
||||||
|
(event.error.includes("Authentication failed") ||
|
||||||
|
event.error.includes("Invalid API key")));
|
||||||
|
|
||||||
|
if (isAuthError) {
|
||||||
|
toast.error("Authentication Failed", {
|
||||||
|
description:
|
||||||
|
"Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.",
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error("Agent encountered an error", {
|
||||||
|
description: event.error || "Check the logs for details",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [loadFeatures, currentProject]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFeatures();
|
||||||
|
}, [loadFeatures]);
|
||||||
|
|
||||||
|
// Load persisted categories on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadCategories();
|
||||||
|
}, [loadCategories]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
features,
|
||||||
|
isLoading,
|
||||||
|
persistedCategories,
|
||||||
|
loadFeatures,
|
||||||
|
loadCategories,
|
||||||
|
saveCategory,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useMemo, useRef, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
useKeyboardShortcuts,
|
||||||
|
useKeyboardShortcutsConfig,
|
||||||
|
KeyboardShortcut,
|
||||||
|
} from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { Feature } from "@/store/app-store";
|
||||||
|
|
||||||
|
interface UseBoardKeyboardShortcutsProps {
|
||||||
|
features: Feature[];
|
||||||
|
runningAutoTasks: string[];
|
||||||
|
onAddFeature: () => void;
|
||||||
|
onStartNextFeatures: () => void;
|
||||||
|
onViewOutput: (feature: Feature) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBoardKeyboardShortcuts({
|
||||||
|
features,
|
||||||
|
runningAutoTasks,
|
||||||
|
onAddFeature,
|
||||||
|
onStartNextFeatures,
|
||||||
|
onViewOutput,
|
||||||
|
}: UseBoardKeyboardShortcutsProps) {
|
||||||
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
|
|
||||||
|
// Get in-progress features for keyboard shortcuts (memoized for shortcuts)
|
||||||
|
const inProgressFeaturesForShortcuts = useMemo(() => {
|
||||||
|
return features.filter((f) => {
|
||||||
|
const isRunning = runningAutoTasks.includes(f.id);
|
||||||
|
return isRunning || f.status === "in_progress";
|
||||||
|
});
|
||||||
|
}, [features, runningAutoTasks]);
|
||||||
|
|
||||||
|
// Ref to hold the start next callback (to avoid dependency issues)
|
||||||
|
const startNextFeaturesRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
|
// Update ref when callback changes
|
||||||
|
useEffect(() => {
|
||||||
|
startNextFeaturesRef.current = onStartNextFeatures;
|
||||||
|
}, [onStartNextFeatures]);
|
||||||
|
|
||||||
|
// Keyboard shortcuts for this view
|
||||||
|
const boardShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||||
|
const shortcutsList: KeyboardShortcut[] = [
|
||||||
|
{
|
||||||
|
key: shortcuts.addFeature,
|
||||||
|
action: onAddFeature,
|
||||||
|
description: "Add new feature",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: shortcuts.startNext,
|
||||||
|
action: () => startNextFeaturesRef.current(),
|
||||||
|
description: "Start next features from backlog",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add shortcuts for in-progress cards (1-9 and 0 for 10th)
|
||||||
|
inProgressFeaturesForShortcuts.slice(0, 10).forEach((feature, index) => {
|
||||||
|
// Keys 1-9 for first 9 cards, 0 for 10th card
|
||||||
|
const key = index === 9 ? "0" : String(index + 1);
|
||||||
|
shortcutsList.push({
|
||||||
|
key,
|
||||||
|
action: () => {
|
||||||
|
onViewOutput(feature);
|
||||||
|
},
|
||||||
|
description: `View output for in-progress card ${index + 1}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return shortcutsList;
|
||||||
|
}, [inProgressFeaturesForShortcuts, shortcuts, onAddFeature, onViewOutput]);
|
||||||
|
|
||||||
|
useKeyboardShortcuts(boardShortcuts);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inProgressFeaturesForShortcuts,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { Feature } from "@/store/app-store";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
|
||||||
|
interface UseBoardPersistenceProps {
|
||||||
|
currentProject: { path: string; id: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBoardPersistence({
|
||||||
|
currentProject,
|
||||||
|
}: UseBoardPersistenceProps) {
|
||||||
|
const { updateFeature } = useAppStore();
|
||||||
|
|
||||||
|
// Persist feature update to API (replaces saveFeatures)
|
||||||
|
const persistFeatureUpdate = useCallback(
|
||||||
|
async (featureId: string, updates: Partial<Feature>) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.features) {
|
||||||
|
console.error("[BoardView] Features API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.features.update(
|
||||||
|
currentProject.path,
|
||||||
|
featureId,
|
||||||
|
updates
|
||||||
|
);
|
||||||
|
if (result.success && result.feature) {
|
||||||
|
updateFeature(result.feature.id, result.feature);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to persist feature update:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, updateFeature]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persist feature creation to API
|
||||||
|
const persistFeatureCreate = useCallback(
|
||||||
|
async (feature: Feature) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.features) {
|
||||||
|
console.error("[BoardView] Features API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.features.create(currentProject.path, feature);
|
||||||
|
if (result.success && result.feature) {
|
||||||
|
updateFeature(result.feature.id, result.feature);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to persist feature creation:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, updateFeature]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persist feature deletion to API
|
||||||
|
const persistFeatureDelete = useCallback(
|
||||||
|
async (featureId: string) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.features) {
|
||||||
|
console.error("[BoardView] Features API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.features.delete(currentProject.path, featureId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to persist feature deletion:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
persistFeatureCreate,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
persistFeatureDelete,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { Feature } from "@/store/app-store";
|
||||||
|
import {
|
||||||
|
FeatureImagePath as DescriptionImagePath,
|
||||||
|
ImagePreviewMap,
|
||||||
|
} from "@/components/ui/description-image-dropzone";
|
||||||
|
|
||||||
|
export function useFollowUpState() {
|
||||||
|
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
|
||||||
|
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
|
||||||
|
const [followUpPrompt, setFollowUpPrompt] = useState("");
|
||||||
|
const [followUpImagePaths, setFollowUpImagePaths] = useState<DescriptionImagePath[]>([]);
|
||||||
|
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(() => new Map());
|
||||||
|
|
||||||
|
const resetFollowUpState = useCallback(() => {
|
||||||
|
setShowFollowUpDialog(false);
|
||||||
|
setFollowUpFeature(null);
|
||||||
|
setFollowUpPrompt("");
|
||||||
|
setFollowUpImagePaths([]);
|
||||||
|
setFollowUpPreviewMap(new Map());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFollowUpDialogChange = useCallback((open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
resetFollowUpState();
|
||||||
|
} else {
|
||||||
|
setShowFollowUpDialog(open);
|
||||||
|
}
|
||||||
|
}, [resetFollowUpState]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
showFollowUpDialog,
|
||||||
|
followUpFeature,
|
||||||
|
followUpPrompt,
|
||||||
|
followUpImagePaths,
|
||||||
|
followUpPreviewMap,
|
||||||
|
// Setters
|
||||||
|
setShowFollowUpDialog,
|
||||||
|
setFollowUpFeature,
|
||||||
|
setFollowUpPrompt,
|
||||||
|
setFollowUpImagePaths,
|
||||||
|
setFollowUpPreviewMap,
|
||||||
|
// Helpers
|
||||||
|
resetFollowUpState,
|
||||||
|
handleFollowUpDialogChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import type { FeatureSuggestion } from "@/lib/electron";
|
||||||
|
|
||||||
|
export function useSuggestionsState() {
|
||||||
|
const [showSuggestionsDialog, setShowSuggestionsDialog] = useState(false);
|
||||||
|
const [suggestionsCount, setSuggestionsCount] = useState(0);
|
||||||
|
const [featureSuggestions, setFeatureSuggestions] = useState<FeatureSuggestion[]>([]);
|
||||||
|
const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false);
|
||||||
|
|
||||||
|
const updateSuggestions = useCallback((suggestions: FeatureSuggestion[]) => {
|
||||||
|
setFeatureSuggestions(suggestions);
|
||||||
|
setSuggestionsCount(suggestions.length);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeSuggestionsDialog = useCallback(() => {
|
||||||
|
setShowSuggestionsDialog(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
showSuggestionsDialog,
|
||||||
|
suggestionsCount,
|
||||||
|
featureSuggestions,
|
||||||
|
isGeneratingSuggestions,
|
||||||
|
// Setters
|
||||||
|
setShowSuggestionsDialog,
|
||||||
|
setSuggestionsCount,
|
||||||
|
setFeatureSuggestions,
|
||||||
|
setIsGeneratingSuggestions,
|
||||||
|
// Helpers
|
||||||
|
updateSuggestions,
|
||||||
|
closeSuggestionsDialog,
|
||||||
|
};
|
||||||
|
}
|
||||||
285
apps/app/src/components/views/board-view/kanban-board.tsx
Normal file
285
apps/app/src/components/views/board-view/kanban-board.tsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { KanbanColumn, KanbanCard } from "./components";
|
||||||
|
import { Feature, useAppStore } from "@/store/app-store";
|
||||||
|
import { FastForward, Lightbulb, Trash2, GitBranch } from "lucide-react";
|
||||||
|
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { COLUMNS, ColumnId } from "./constants";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface KanbanBoardProps {
|
||||||
|
sensors: any;
|
||||||
|
collisionDetectionStrategy: (args: any) => any;
|
||||||
|
onDragStart: (event: any) => void;
|
||||||
|
onDragEnd: (event: any) => void;
|
||||||
|
activeFeature: Feature | null;
|
||||||
|
getColumnFeatures: (columnId: ColumnId) => Feature[];
|
||||||
|
backgroundImageStyle: React.CSSProperties;
|
||||||
|
backgroundSettings: {
|
||||||
|
columnOpacity: number;
|
||||||
|
columnBorderEnabled: boolean;
|
||||||
|
hideScrollbar: boolean;
|
||||||
|
cardOpacity: number;
|
||||||
|
cardGlassmorphism: boolean;
|
||||||
|
cardBorderEnabled: boolean;
|
||||||
|
cardBorderOpacity: number;
|
||||||
|
};
|
||||||
|
onEdit: (feature: Feature) => void;
|
||||||
|
onDelete: (featureId: string) => void;
|
||||||
|
onViewOutput: (feature: Feature) => void;
|
||||||
|
onVerify: (feature: Feature) => void;
|
||||||
|
onResume: (feature: Feature) => void;
|
||||||
|
onForceStop: (feature: Feature) => void;
|
||||||
|
onManualVerify: (feature: Feature) => void;
|
||||||
|
onMoveBackToInProgress: (feature: Feature) => void;
|
||||||
|
onFollowUp: (feature: Feature) => void;
|
||||||
|
onCommit: (feature: Feature) => void;
|
||||||
|
onComplete: (feature: Feature) => void;
|
||||||
|
onImplement: (feature: Feature) => void;
|
||||||
|
onViewPlan: (feature: Feature) => void;
|
||||||
|
onApprovePlan: (feature: Feature) => void;
|
||||||
|
featuresWithContext: Set<string>;
|
||||||
|
runningAutoTasks: string[];
|
||||||
|
shortcuts: ReturnType<typeof useKeyboardShortcutsConfig>;
|
||||||
|
onStartNextFeatures: () => void;
|
||||||
|
onShowSuggestions: () => void;
|
||||||
|
suggestionsCount: number;
|
||||||
|
onDeleteAllVerified: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KanbanBoard({
|
||||||
|
sensors,
|
||||||
|
collisionDetectionStrategy,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
activeFeature,
|
||||||
|
getColumnFeatures,
|
||||||
|
backgroundImageStyle,
|
||||||
|
backgroundSettings,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onViewOutput,
|
||||||
|
onVerify,
|
||||||
|
onResume,
|
||||||
|
onForceStop,
|
||||||
|
onManualVerify,
|
||||||
|
onMoveBackToInProgress,
|
||||||
|
onFollowUp,
|
||||||
|
onCommit,
|
||||||
|
onComplete,
|
||||||
|
onImplement,
|
||||||
|
onViewPlan,
|
||||||
|
onApprovePlan,
|
||||||
|
featuresWithContext,
|
||||||
|
runningAutoTasks,
|
||||||
|
shortcuts,
|
||||||
|
onStartNextFeatures,
|
||||||
|
onShowSuggestions,
|
||||||
|
suggestionsCount,
|
||||||
|
onDeleteAllVerified,
|
||||||
|
}: KanbanBoardProps) {
|
||||||
|
const { getEffectiveTheme } = useAppStore();
|
||||||
|
const effectiveTheme = getEffectiveTheme();
|
||||||
|
const isCleanTheme = effectiveTheme === "clean";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex-1 overflow-x-auto relative",
|
||||||
|
isCleanTheme ? "custom-scrollbar px-8 pb-8" : "px-4 pb-4"
|
||||||
|
)}
|
||||||
|
style={backgroundImageStyle}
|
||||||
|
>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={collisionDetectionStrategy}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-full min-w-max",
|
||||||
|
isCleanTheme ? "gap-6 items-start" : "gap-5 py-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{COLUMNS.map((column) => {
|
||||||
|
const columnFeatures = getColumnFeatures(column.id);
|
||||||
|
|
||||||
|
// Clean Theme Header Actions
|
||||||
|
let headerAction;
|
||||||
|
if (isCleanTheme) {
|
||||||
|
if (column.id === "backlog") {
|
||||||
|
headerAction = (
|
||||||
|
<div className="flex items-center gap-1.5 opacity-40">
|
||||||
|
<Lightbulb className="w-3.5 h-3.5 text-yellow-500" />
|
||||||
|
<GitBranch className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span className="mono text-[9px] text-cyan-400 font-bold">Mabe 6</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (column.id === "verified" && columnFeatures.length > 0) {
|
||||||
|
headerAction = (
|
||||||
|
<button
|
||||||
|
className="ml-2 text-[10px] text-rose-500 flex items-center gap-1 hover:underline font-black transition"
|
||||||
|
onClick={onDeleteAllVerified}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" /> Delete All
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Standard Theme Header Actions
|
||||||
|
if (column.id === "verified" && columnFeatures.length > 0) {
|
||||||
|
headerAction = (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={onDeleteAllVerified}
|
||||||
|
data-testid="delete-all-verified-button"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3 mr-1" />
|
||||||
|
Delete All
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
} else if (column.id === "backlog") {
|
||||||
|
headerAction = (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
|
||||||
|
onClick={onShowSuggestions}
|
||||||
|
title="Feature Suggestions"
|
||||||
|
data-testid="feature-suggestions-button"
|
||||||
|
>
|
||||||
|
<Lightbulb className="w-3.5 h-3.5" />
|
||||||
|
{suggestionsCount > 0 && (
|
||||||
|
<span
|
||||||
|
className="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-mono rounded-full bg-yellow-500 text-black flex items-center justify-center"
|
||||||
|
data-testid="suggestions-count"
|
||||||
|
>
|
||||||
|
{suggestionsCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{columnFeatures.length > 0 && (
|
||||||
|
<HotkeyButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
|
||||||
|
onClick={onStartNextFeatures}
|
||||||
|
hotkey={shortcuts.startNext}
|
||||||
|
hotkeyActive={false}
|
||||||
|
data-testid="start-next-button"
|
||||||
|
>
|
||||||
|
<FastForward className="w-3 h-3 mr-1" />
|
||||||
|
Make
|
||||||
|
</HotkeyButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KanbanColumn
|
||||||
|
key={column.id}
|
||||||
|
id={column.id}
|
||||||
|
title={column.title}
|
||||||
|
colorClass={column.colorClass}
|
||||||
|
count={columnFeatures.length}
|
||||||
|
opacity={backgroundSettings.columnOpacity}
|
||||||
|
showBorder={backgroundSettings.columnBorderEnabled}
|
||||||
|
hideScrollbar={backgroundSettings.hideScrollbar}
|
||||||
|
headerAction={headerAction}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={columnFeatures.map((f) => f.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{columnFeatures.map((feature, index) => {
|
||||||
|
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
||||||
|
let shortcutKey: string | undefined;
|
||||||
|
if (column.id === "in_progress" && index < 10) {
|
||||||
|
shortcutKey =
|
||||||
|
index === 9 ? "0" : String(index + 1);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<KanbanCard
|
||||||
|
key={feature.id}
|
||||||
|
feature={feature}
|
||||||
|
onEdit={() => onEdit(feature)}
|
||||||
|
onDelete={() => onDelete(feature.id)}
|
||||||
|
onViewOutput={() => onViewOutput(feature)}
|
||||||
|
onVerify={() => onVerify(feature)}
|
||||||
|
onResume={() => onResume(feature)}
|
||||||
|
onForceStop={() => onForceStop(feature)}
|
||||||
|
onManualVerify={() => onManualVerify(feature)}
|
||||||
|
onMoveBackToInProgress={() =>
|
||||||
|
onMoveBackToInProgress(feature)
|
||||||
|
}
|
||||||
|
onFollowUp={() => onFollowUp(feature)}
|
||||||
|
onCommit={() => onCommit(feature)}
|
||||||
|
onComplete={() => onComplete(feature)}
|
||||||
|
onImplement={() => onImplement(feature)}
|
||||||
|
onViewPlan={() => onViewPlan(feature)}
|
||||||
|
onApprovePlan={() => onApprovePlan(feature)}
|
||||||
|
hasContext={featuresWithContext.has(feature.id)}
|
||||||
|
isCurrentAutoTask={runningAutoTasks.includes(
|
||||||
|
feature.id
|
||||||
|
)}
|
||||||
|
shortcutKey={shortcutKey}
|
||||||
|
opacity={backgroundSettings.cardOpacity}
|
||||||
|
glassmorphism={
|
||||||
|
backgroundSettings.cardGlassmorphism
|
||||||
|
}
|
||||||
|
cardBorderEnabled={
|
||||||
|
backgroundSettings.cardBorderEnabled
|
||||||
|
}
|
||||||
|
cardBorderOpacity={
|
||||||
|
backgroundSettings.cardBorderOpacity
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SortableContext>
|
||||||
|
</KanbanColumn>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragOverlay
|
||||||
|
dropAnimation={{
|
||||||
|
duration: 200,
|
||||||
|
easing: "cubic-bezier(0.18, 0.67, 0.6, 1.22)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeFeature && (
|
||||||
|
<Card className="w-72 rotate-2 shadow-2xl shadow-black/25 border-primary/50 bg-card/95 backdrop-blur-sm transition-transform">
|
||||||
|
<CardHeader className="p-3">
|
||||||
|
<CardTitle className="text-sm font-medium line-clamp-2">
|
||||||
|
{activeFeature.description}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs text-muted-foreground">
|
||||||
|
{activeFeature.category}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
apps/app/src/components/views/board-view/shared/index.ts
Normal file
7
apps/app/src/components/views/board-view/shared/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export * from "./model-constants";
|
||||||
|
export * from "./model-selector";
|
||||||
|
export * from "./thinking-level-selector";
|
||||||
|
export * from "./profile-quick-select";
|
||||||
|
export * from "./testing-tab-content";
|
||||||
|
export * from "./priority-selector";
|
||||||
|
export * from "./planning-mode-selector";
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { AgentModel, ThinkingLevel } from "@/store/app-store";
|
||||||
|
import {
|
||||||
|
Brain,
|
||||||
|
Zap,
|
||||||
|
Scale,
|
||||||
|
Cpu,
|
||||||
|
Rocket,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export type ModelOption = {
|
||||||
|
id: AgentModel;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
badge?: string;
|
||||||
|
provider: "claude";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CLAUDE_MODELS: ModelOption[] = [
|
||||||
|
{
|
||||||
|
id: "haiku",
|
||||||
|
label: "Claude Haiku",
|
||||||
|
description: "Fast and efficient for simple tasks.",
|
||||||
|
badge: "Speed",
|
||||||
|
provider: "claude",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sonnet",
|
||||||
|
label: "Claude Sonnet",
|
||||||
|
description: "Balanced performance with strong reasoning.",
|
||||||
|
badge: "Balanced",
|
||||||
|
provider: "claude",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "opus",
|
||||||
|
label: "Claude Opus",
|
||||||
|
description: "Most capable model for complex work.",
|
||||||
|
badge: "Premium",
|
||||||
|
provider: "claude",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const THINKING_LEVELS: ThinkingLevel[] = [
|
||||||
|
"none",
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high",
|
||||||
|
"ultrathink",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
|
||||||
|
none: "None",
|
||||||
|
low: "Low",
|
||||||
|
medium: "Med",
|
||||||
|
high: "High",
|
||||||
|
ultrathink: "Ultra",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Profile icon mapping
|
||||||
|
export const PROFILE_ICONS: Record<
|
||||||
|
string,
|
||||||
|
React.ComponentType<{ className?: string }>
|
||||||
|
> = {
|
||||||
|
Brain,
|
||||||
|
Zap,
|
||||||
|
Scale,
|
||||||
|
Cpu,
|
||||||
|
Rocket,
|
||||||
|
Sparkles,
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Brain } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { AgentModel } from "@/store/app-store";
|
||||||
|
import { CLAUDE_MODELS, ModelOption } from "./model-constants";
|
||||||
|
|
||||||
|
interface ModelSelectorProps {
|
||||||
|
selectedModel: AgentModel;
|
||||||
|
onModelSelect: (model: AgentModel) => void;
|
||||||
|
testIdPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSelector({
|
||||||
|
selectedModel,
|
||||||
|
onModelSelect,
|
||||||
|
testIdPrefix = "model-select",
|
||||||
|
}: ModelSelectorProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Brain className="w-4 h-4 text-primary" />
|
||||||
|
Claude (SDK)
|
||||||
|
</Label>
|
||||||
|
<span className="text-[11px] px-2 py-0.5 rounded-full border border-primary/40 text-primary">
|
||||||
|
Native
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{CLAUDE_MODELS.map((option) => {
|
||||||
|
const isSelected = selectedModel === option.id;
|
||||||
|
const shortName = option.label.replace("Claude ", "");
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onModelSelect(option.id)}
|
||||||
|
title={option.description}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 min-w-[80px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "bg-background hover:bg-accent border-input"
|
||||||
|
)}
|
||||||
|
data-testid={`${testIdPrefix}-${option.id}`}
|
||||||
|
>
|
||||||
|
{shortName}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Zap, ClipboardList, FileText, ScrollText,
|
||||||
|
Loader2, Check, Eye, RefreshCw, Sparkles
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { PlanSpec } from "@/store/app-store";
|
||||||
|
|
||||||
|
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
||||||
|
|
||||||
|
// Re-export for backwards compatibility
|
||||||
|
export type { ParsedTask, PlanSpec } from "@/store/app-store";
|
||||||
|
|
||||||
|
interface PlanningModeSelectorProps {
|
||||||
|
mode: PlanningMode;
|
||||||
|
onModeChange: (mode: PlanningMode) => void;
|
||||||
|
requireApproval?: boolean;
|
||||||
|
onRequireApprovalChange?: (require: boolean) => void;
|
||||||
|
planSpec?: PlanSpec;
|
||||||
|
onGenerateSpec?: () => void;
|
||||||
|
onApproveSpec?: () => void;
|
||||||
|
onRejectSpec?: () => void;
|
||||||
|
onViewSpec?: () => void;
|
||||||
|
isGenerating?: boolean;
|
||||||
|
featureDescription?: string; // For auto-generation context
|
||||||
|
testIdPrefix?: string;
|
||||||
|
compact?: boolean; // For use in dialogs vs settings
|
||||||
|
}
|
||||||
|
|
||||||
|
const modes = [
|
||||||
|
{
|
||||||
|
value: 'skip' as const,
|
||||||
|
label: 'Skip',
|
||||||
|
description: 'Direct implementation, no upfront planning',
|
||||||
|
icon: Zap,
|
||||||
|
color: 'text-emerald-500',
|
||||||
|
bgColor: 'bg-emerald-500/10',
|
||||||
|
borderColor: 'border-emerald-500/30',
|
||||||
|
badge: 'Default',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'lite' as const,
|
||||||
|
label: 'Lite',
|
||||||
|
description: 'Think through approach, create task list',
|
||||||
|
icon: ClipboardList,
|
||||||
|
color: 'text-blue-500',
|
||||||
|
bgColor: 'bg-blue-500/10',
|
||||||
|
borderColor: 'border-blue-500/30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'spec' as const,
|
||||||
|
label: 'Spec',
|
||||||
|
description: 'Generate spec with acceptance criteria',
|
||||||
|
icon: FileText,
|
||||||
|
color: 'text-purple-500',
|
||||||
|
bgColor: 'bg-purple-500/10',
|
||||||
|
borderColor: 'border-purple-500/30',
|
||||||
|
badge: 'Approval Required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'full' as const,
|
||||||
|
label: 'Full',
|
||||||
|
description: 'Comprehensive spec with phased plan',
|
||||||
|
icon: ScrollText,
|
||||||
|
color: 'text-amber-500',
|
||||||
|
bgColor: 'bg-amber-500/10',
|
||||||
|
borderColor: 'border-amber-500/30',
|
||||||
|
badge: 'Approval Required',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PlanningModeSelector({
|
||||||
|
mode,
|
||||||
|
onModeChange,
|
||||||
|
requireApproval,
|
||||||
|
onRequireApprovalChange,
|
||||||
|
planSpec,
|
||||||
|
onGenerateSpec,
|
||||||
|
onApproveSpec,
|
||||||
|
onRejectSpec,
|
||||||
|
onViewSpec,
|
||||||
|
isGenerating = false,
|
||||||
|
featureDescription,
|
||||||
|
testIdPrefix = 'planning',
|
||||||
|
compact = false,
|
||||||
|
}: PlanningModeSelectorProps) {
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
const selectedMode = modes.find(m => m.value === mode);
|
||||||
|
const requiresApproval = mode === 'spec' || mode === 'full';
|
||||||
|
const canGenerate = requiresApproval && featureDescription?.trim() && !isGenerating;
|
||||||
|
const hasSpec = planSpec && planSpec.content;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header with icon */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={cn(
|
||||||
|
"w-8 h-8 rounded-lg flex items-center justify-center",
|
||||||
|
selectedMode?.bgColor || "bg-muted"
|
||||||
|
)}>
|
||||||
|
{selectedMode && <selectedMode.icon className={cn("h-4 w-4", selectedMode.color)} />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">Planning Mode</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Choose how much upfront planning before implementation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick action buttons when spec/full mode */}
|
||||||
|
{requiresApproval && hasSpec && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onViewSpec}
|
||||||
|
className="h-7 px-2"
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5 mr-1" />
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode Selection Cards */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid gap-2",
|
||||||
|
compact ? "grid-cols-2" : "grid-cols-2 sm:grid-cols-4"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{modes.map((m) => {
|
||||||
|
const isSelected = mode === m.value;
|
||||||
|
const Icon = m.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onModeChange(m.value)}
|
||||||
|
data-testid={`${testIdPrefix}-mode-${m.value}`}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-2 p-3 rounded-xl cursor-pointer transition-all duration-200",
|
||||||
|
"border-2 hover:border-primary/50",
|
||||||
|
isSelected
|
||||||
|
? cn("border-primary", m.bgColor)
|
||||||
|
: "border-border/50 bg-card/50 hover:bg-accent/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"w-10 h-10 rounded-full flex items-center justify-center transition-colors",
|
||||||
|
isSelected ? m.bgColor : "bg-muted"
|
||||||
|
)}>
|
||||||
|
<Icon className={cn(
|
||||||
|
"h-5 w-5 transition-colors",
|
||||||
|
isSelected ? m.color : "text-muted-foreground"
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<span className={cn(
|
||||||
|
"font-medium text-sm",
|
||||||
|
isSelected ? "text-foreground" : "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{m.label}
|
||||||
|
</span>
|
||||||
|
{m.badge && (
|
||||||
|
<span className={cn(
|
||||||
|
"text-[9px] px-1 py-0.5 rounded font-medium",
|
||||||
|
m.badge === 'Default'
|
||||||
|
? "bg-emerald-500/15 text-emerald-500"
|
||||||
|
: "bg-amber-500/15 text-amber-500"
|
||||||
|
)}>
|
||||||
|
{m.badge === 'Default' ? 'Default' : 'Review'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!compact && (
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-0.5 line-clamp-2">
|
||||||
|
{m.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Require Approval Checkbox - Only show when mode !== 'skip' */}
|
||||||
|
{mode !== 'skip' && onRequireApprovalChange && (
|
||||||
|
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
|
||||||
|
<Checkbox
|
||||||
|
id="require-approval"
|
||||||
|
checked={requireApproval}
|
||||||
|
onCheckedChange={(checked) => onRequireApprovalChange(checked === true)}
|
||||||
|
data-testid={`${testIdPrefix}-require-approval-checkbox`}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="require-approval"
|
||||||
|
className="text-sm text-muted-foreground cursor-pointer"
|
||||||
|
>
|
||||||
|
Manually approve plan before implementation
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Spec Preview/Actions Panel - Only for spec/full modes */}
|
||||||
|
{requiresApproval && (
|
||||||
|
<div className={cn(
|
||||||
|
"rounded-xl border transition-all duration-300",
|
||||||
|
planSpec?.status === 'approved'
|
||||||
|
? "border-emerald-500/30 bg-emerald-500/5"
|
||||||
|
: planSpec?.status === 'generated'
|
||||||
|
? "border-amber-500/30 bg-amber-500/5"
|
||||||
|
: "border-border/50 bg-muted/30"
|
||||||
|
)}>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{/* Status indicator */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
|
<span className="text-sm text-muted-foreground">Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}...</span>
|
||||||
|
</>
|
||||||
|
) : planSpec?.status === 'approved' ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4 text-emerald-500" />
|
||||||
|
<span className="text-sm text-emerald-500 font-medium">Spec Approved</span>
|
||||||
|
</>
|
||||||
|
) : planSpec?.status === 'generated' ? (
|
||||||
|
<>
|
||||||
|
<Eye className="h-4 w-4 text-amber-500" />
|
||||||
|
<span className="text-sm text-amber-500 font-medium">Spec Ready for Review</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Spec will be generated when feature starts
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-generate toggle area */}
|
||||||
|
{!planSpec?.status && canGenerate && onGenerateSpec && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onGenerateSpec}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="h-7"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Pre-generate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spec content preview */}
|
||||||
|
{hasSpec && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowPreview(!showPreview)}
|
||||||
|
className="w-full justify-between h-8 px-2"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{showPreview ? 'Hide Preview' : 'Show Preview'}
|
||||||
|
</span>
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showPreview && (
|
||||||
|
<div className="rounded-lg bg-background/80 border border-border/50 p-3 max-h-48 overflow-y-auto">
|
||||||
|
<pre className="text-xs text-muted-foreground whitespace-pre-wrap font-mono">
|
||||||
|
{planSpec.content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons when spec is generated */}
|
||||||
|
{planSpec?.status === 'generated' && (
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-border/30">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRejectSpec}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Request Changes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onApproveSpec}
|
||||||
|
className="flex-1 bg-emerald-500 hover:bg-emerald-600 text-white"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Approve Spec
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Regenerate option when approved */}
|
||||||
|
{planSpec?.status === 'approved' && onGenerateSpec && (
|
||||||
|
<div className="flex items-center justify-end pt-2 border-t border-border/30">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onGenerateSpec}
|
||||||
|
className="h-7"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Regenerate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info text for non-approval modes */}
|
||||||
|
{!requiresApproval && (
|
||||||
|
<p className="text-xs text-muted-foreground bg-muted/30 rounded-lg p-3">
|
||||||
|
{mode === 'skip'
|
||||||
|
? "The agent will start implementing immediately without creating a plan or spec."
|
||||||
|
: "The agent will create a planning outline before implementing, but won't wait for approval."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface PrioritySelectorProps {
|
||||||
|
selectedPriority: number;
|
||||||
|
onPrioritySelect: (priority: number) => void;
|
||||||
|
testIdPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrioritySelector({
|
||||||
|
selectedPriority,
|
||||||
|
onPrioritySelect,
|
||||||
|
testIdPrefix = "priority",
|
||||||
|
}: PrioritySelectorProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Priority</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPrioritySelect(1)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||||
|
selectedPriority === 1
|
||||||
|
? "bg-red-500/20 text-red-500 border-2 border-red-500/50"
|
||||||
|
: "bg-muted/50 text-muted-foreground border border-border hover:bg-muted"
|
||||||
|
)}
|
||||||
|
data-testid={`${testIdPrefix}-high-button`}
|
||||||
|
>
|
||||||
|
High
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPrioritySelect(2)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||||
|
selectedPriority === 2
|
||||||
|
? "bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50"
|
||||||
|
: "bg-muted/50 text-muted-foreground border border-border hover:bg-muted"
|
||||||
|
)}
|
||||||
|
data-testid={`${testIdPrefix}-medium-button`}
|
||||||
|
>
|
||||||
|
Medium
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPrioritySelect(3)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||||
|
selectedPriority === 3
|
||||||
|
? "bg-blue-500/20 text-blue-500 border-2 border-blue-500/50"
|
||||||
|
: "bg-muted/50 text-muted-foreground border border-border hover:bg-muted"
|
||||||
|
)}
|
||||||
|
data-testid={`${testIdPrefix}-low-button`}
|
||||||
|
>
|
||||||
|
Low
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Brain, UserCircle } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { AgentModel, ThinkingLevel, AIProfile } from "@/store/app-store";
|
||||||
|
import { PROFILE_ICONS } from "./model-constants";
|
||||||
|
|
||||||
|
interface ProfileQuickSelectProps {
|
||||||
|
profiles: AIProfile[];
|
||||||
|
selectedModel: AgentModel;
|
||||||
|
selectedThinkingLevel: ThinkingLevel;
|
||||||
|
onSelect: (model: AgentModel, thinkingLevel: ThinkingLevel) => void;
|
||||||
|
testIdPrefix?: string;
|
||||||
|
showManageLink?: boolean;
|
||||||
|
onManageLinkClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileQuickSelect({
|
||||||
|
profiles,
|
||||||
|
selectedModel,
|
||||||
|
selectedThinkingLevel,
|
||||||
|
onSelect,
|
||||||
|
testIdPrefix = "profile-quick-select",
|
||||||
|
showManageLink = false,
|
||||||
|
onManageLinkClick,
|
||||||
|
}: ProfileQuickSelectProps) {
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<UserCircle className="w-4 h-4 text-brand-500" />
|
||||||
|
Quick Select Profile
|
||||||
|
</Label>
|
||||||
|
<span className="text-[11px] px-2 py-0.5 rounded-full border border-brand-500/40 text-brand-500">
|
||||||
|
Presets
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{profiles.slice(0, 6).map((profile) => {
|
||||||
|
const IconComponent = profile.icon
|
||||||
|
? PROFILE_ICONS[profile.icon]
|
||||||
|
: Brain;
|
||||||
|
const isSelected =
|
||||||
|
selectedModel === profile.model &&
|
||||||
|
selectedThinkingLevel === profile.thinkingLevel;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={profile.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(profile.model, profile.thinkingLevel)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 p-2 rounded-lg border text-left transition-all",
|
||||||
|
isSelected
|
||||||
|
? "bg-brand-500/10 border-brand-500 text-foreground"
|
||||||
|
: "bg-background hover:bg-accent border-input"
|
||||||
|
)}
|
||||||
|
data-testid={`${testIdPrefix}-${profile.id}`}
|
||||||
|
>
|
||||||
|
<div className="w-7 h-7 rounded flex items-center justify-center shrink-0 bg-primary/10">
|
||||||
|
{IconComponent && (
|
||||||
|
<IconComponent className="w-4 h-4 text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium truncate">{profile.name}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground truncate">
|
||||||
|
{profile.model}
|
||||||
|
{profile.thinkingLevel !== "none" &&
|
||||||
|
` + ${profile.thinkingLevel}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Or customize below.
|
||||||
|
{showManageLink && onManageLinkClick && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
Manage profiles in{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onManageLinkClick}
|
||||||
|
className="text-brand-500 hover:underline"
|
||||||
|
>
|
||||||
|
AI Profiles
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { FlaskConical, Plus } from "lucide-react";
|
||||||
|
|
||||||
|
interface TestingTabContentProps {
|
||||||
|
skipTests: boolean;
|
||||||
|
onSkipTestsChange: (skipTests: boolean) => void;
|
||||||
|
steps: string[];
|
||||||
|
onStepsChange: (steps: string[]) => void;
|
||||||
|
testIdPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TestingTabContent({
|
||||||
|
skipTests,
|
||||||
|
onSkipTestsChange,
|
||||||
|
steps,
|
||||||
|
onStepsChange,
|
||||||
|
testIdPrefix = "",
|
||||||
|
}: TestingTabContentProps) {
|
||||||
|
const checkboxId = testIdPrefix ? `${testIdPrefix}-skip-tests` : "skip-tests";
|
||||||
|
|
||||||
|
const handleStepChange = (index: number, value: string) => {
|
||||||
|
const newSteps = [...steps];
|
||||||
|
newSteps[index] = value;
|
||||||
|
onStepsChange(newSteps);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddStep = () => {
|
||||||
|
onStepsChange([...steps, ""]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={checkboxId}
|
||||||
|
checked={!skipTests}
|
||||||
|
onCheckedChange={(checked) => onSkipTestsChange(checked !== true)}
|
||||||
|
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}skip-tests-checkbox`}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor={checkboxId} className="text-sm cursor-pointer">
|
||||||
|
Enable automated testing
|
||||||
|
</Label>
|
||||||
|
<FlaskConical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
When enabled, this feature will use automated TDD. When disabled, it
|
||||||
|
will require manual verification.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Verification Steps - Only shown when skipTests is enabled */}
|
||||||
|
{skipTests && (
|
||||||
|
<div className="space-y-2 pt-2 border-t border-border">
|
||||||
|
<Label>Verification Steps</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
Add manual steps to verify this feature works correctly.
|
||||||
|
</p>
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<Input
|
||||||
|
key={index}
|
||||||
|
value={step}
|
||||||
|
placeholder={`Verification step ${index + 1}`}
|
||||||
|
onChange={(e) => handleStepChange(index, e.target.value)}
|
||||||
|
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}feature-step-${index}${testIdPrefix ? "" : "-input"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddStep}
|
||||||
|
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}add-step-button`}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Verification Step
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Brain } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ThinkingLevel } from "@/store/app-store";
|
||||||
|
import { THINKING_LEVELS, THINKING_LEVEL_LABELS } from "./model-constants";
|
||||||
|
|
||||||
|
interface ThinkingLevelSelectorProps {
|
||||||
|
selectedLevel: ThinkingLevel;
|
||||||
|
onLevelSelect: (level: ThinkingLevel) => void;
|
||||||
|
testIdPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThinkingLevelSelector({
|
||||||
|
selectedLevel,
|
||||||
|
onLevelSelect,
|
||||||
|
testIdPrefix = "thinking-level",
|
||||||
|
}: ThinkingLevelSelectorProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 pt-2 border-t border-border">
|
||||||
|
<Label className="flex items-center gap-2 text-sm">
|
||||||
|
<Brain className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
Thinking Level
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{THINKING_LEVELS.map((level) => (
|
||||||
|
<button
|
||||||
|
key={level}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onLevelSelect(level)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]",
|
||||||
|
selectedLevel === level
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "bg-background hover:bg-accent border-input"
|
||||||
|
)}
|
||||||
|
data-testid={`${testIdPrefix}-${level}`}
|
||||||
|
>
|
||||||
|
{THINKING_LEVEL_LABELS[level]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Higher levels give more time to reason through complex problems.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
GitBranch,
|
||||||
|
RefreshCw,
|
||||||
|
GitBranchPlus,
|
||||||
|
Check,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { WorktreeInfo, BranchInfo } from "../types";
|
||||||
|
|
||||||
|
interface BranchSwitchDropdownProps {
|
||||||
|
worktree: WorktreeInfo;
|
||||||
|
isSelected: boolean;
|
||||||
|
branches: BranchInfo[];
|
||||||
|
filteredBranches: BranchInfo[];
|
||||||
|
branchFilter: string;
|
||||||
|
isLoadingBranches: boolean;
|
||||||
|
isSwitching: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onFilterChange: (value: string) => void;
|
||||||
|
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
|
||||||
|
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BranchSwitchDropdown({
|
||||||
|
worktree,
|
||||||
|
isSelected,
|
||||||
|
filteredBranches,
|
||||||
|
branchFilter,
|
||||||
|
isLoadingBranches,
|
||||||
|
isSwitching,
|
||||||
|
onOpenChange,
|
||||||
|
onFilterChange,
|
||||||
|
onSwitchBranch,
|
||||||
|
onCreateBranch,
|
||||||
|
}: BranchSwitchDropdownProps) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu onOpenChange={onOpenChange}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-7 p-0 rounded-none border-r-0",
|
||||||
|
isSelected && "bg-primary text-primary-foreground",
|
||||||
|
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||||
|
)}
|
||||||
|
title="Switch branch"
|
||||||
|
>
|
||||||
|
<GitBranch className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-64">
|
||||||
|
<DropdownMenuLabel className="text-xs">Switch Branch</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="px-2 py-1.5">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Filter branches..."
|
||||||
|
value={branchFilter}
|
||||||
|
onChange={(e) => onFilterChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
onKeyUp={(e) => e.stopPropagation()}
|
||||||
|
onKeyPress={(e) => e.stopPropagation()}
|
||||||
|
className="h-7 pl-7 text-xs"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="max-h-[250px] overflow-y-auto">
|
||||||
|
{isLoadingBranches ? (
|
||||||
|
<DropdownMenuItem disabled className="text-xs">
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
|
||||||
|
Loading branches...
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : filteredBranches.length === 0 ? (
|
||||||
|
<DropdownMenuItem disabled className="text-xs">
|
||||||
|
{branchFilter ? "No matching branches" : "No branches found"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
filteredBranches.map((branch) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={branch.name}
|
||||||
|
onClick={() => onSwitchBranch(worktree, branch.name)}
|
||||||
|
disabled={isSwitching || branch.name === worktree.branch}
|
||||||
|
className="text-xs font-mono"
|
||||||
|
>
|
||||||
|
{branch.name === worktree.branch ? (
|
||||||
|
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<span className="w-3.5 mr-2 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{branch.name}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onCreateBranch(worktree)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<GitBranchPlus className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Create New Branch...
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { BranchSwitchDropdown } from "./branch-switch-dropdown";
|
||||||
|
export { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
|
||||||
|
export { WorktreeTab } from "./worktree-tab";
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Trash2,
|
||||||
|
MoreHorizontal,
|
||||||
|
GitCommit,
|
||||||
|
GitPullRequest,
|
||||||
|
ExternalLink,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
Play,
|
||||||
|
Square,
|
||||||
|
Globe,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { WorktreeInfo, DevServerInfo } from "../types";
|
||||||
|
|
||||||
|
interface WorktreeActionsDropdownProps {
|
||||||
|
worktree: WorktreeInfo;
|
||||||
|
isSelected: boolean;
|
||||||
|
defaultEditorName: string;
|
||||||
|
aheadCount: number;
|
||||||
|
behindCount: number;
|
||||||
|
isPulling: boolean;
|
||||||
|
isPushing: boolean;
|
||||||
|
isStartingDevServer: boolean;
|
||||||
|
isDevServerRunning: boolean;
|
||||||
|
devServerInfo?: DevServerInfo;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onPull: (worktree: WorktreeInfo) => void;
|
||||||
|
onPush: (worktree: WorktreeInfo) => void;
|
||||||
|
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
||||||
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
|
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||||
|
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||||
|
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorktreeActionsDropdown({
|
||||||
|
worktree,
|
||||||
|
isSelected,
|
||||||
|
defaultEditorName,
|
||||||
|
aheadCount,
|
||||||
|
behindCount,
|
||||||
|
isPulling,
|
||||||
|
isPushing,
|
||||||
|
isStartingDevServer,
|
||||||
|
isDevServerRunning,
|
||||||
|
devServerInfo,
|
||||||
|
onOpenChange,
|
||||||
|
onPull,
|
||||||
|
onPush,
|
||||||
|
onOpenInEditor,
|
||||||
|
onCommit,
|
||||||
|
onCreatePR,
|
||||||
|
onDeleteWorktree,
|
||||||
|
onStartDevServer,
|
||||||
|
onStopDevServer,
|
||||||
|
onOpenDevServerUrl,
|
||||||
|
}: WorktreeActionsDropdownProps) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu onOpenChange={onOpenChange}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-7 p-0 rounded-l-none",
|
||||||
|
isSelected && "bg-primary text-primary-foreground",
|
||||||
|
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
|
{isDevServerRunning ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
Dev Server Running (:{devServerInfo?.port})
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onOpenDevServerUrl(worktree)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Globe className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Open in Browser
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onStopDevServer(worktree)}
|
||||||
|
className="text-xs text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Square className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Stop Dev Server
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onStartDevServer(worktree)}
|
||||||
|
disabled={isStartingDevServer}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Play
|
||||||
|
className={cn(
|
||||||
|
"w-3.5 h-3.5 mr-2",
|
||||||
|
isStartingDevServer && "animate-pulse"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onPull(worktree)}
|
||||||
|
disabled={isPulling}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Download
|
||||||
|
className={cn("w-3.5 h-3.5 mr-2", isPulling && "animate-pulse")}
|
||||||
|
/>
|
||||||
|
{isPulling ? "Pulling..." : "Pull"}
|
||||||
|
{behindCount > 0 && (
|
||||||
|
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||||
|
{behindCount} behind
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onPush(worktree)}
|
||||||
|
disabled={isPushing || aheadCount === 0}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Upload
|
||||||
|
className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")}
|
||||||
|
/>
|
||||||
|
{isPushing ? "Pushing..." : "Push"}
|
||||||
|
{aheadCount > 0 && (
|
||||||
|
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||||
|
{aheadCount} ahead
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onOpenInEditor(worktree)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Open in {defaultEditorName}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{worktree.hasChanges && (
|
||||||
|
<DropdownMenuItem onClick={() => onCommit(worktree)} className="text-xs">
|
||||||
|
<GitCommit className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Commit Changes
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{(worktree.branch !== "main" || worktree.hasChanges) && (
|
||||||
|
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
|
||||||
|
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Create Pull Request
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{!worktree.isMain && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onDeleteWorktree(worktree)}
|
||||||
|
className="text-xs text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Delete Worktree
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { RefreshCw, Globe, Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { WorktreeInfo, BranchInfo, DevServerInfo } from "../types";
|
||||||
|
import { BranchSwitchDropdown } from "./branch-switch-dropdown";
|
||||||
|
import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
|
||||||
|
|
||||||
|
interface WorktreeTabProps {
|
||||||
|
worktree: WorktreeInfo;
|
||||||
|
isSelected: boolean;
|
||||||
|
isRunning: boolean;
|
||||||
|
isActivating: boolean;
|
||||||
|
isDevServerRunning: boolean;
|
||||||
|
devServerInfo?: DevServerInfo;
|
||||||
|
defaultEditorName: string;
|
||||||
|
branches: BranchInfo[];
|
||||||
|
filteredBranches: BranchInfo[];
|
||||||
|
branchFilter: string;
|
||||||
|
isLoadingBranches: boolean;
|
||||||
|
isSwitching: boolean;
|
||||||
|
isPulling: boolean;
|
||||||
|
isPushing: boolean;
|
||||||
|
isStartingDevServer: boolean;
|
||||||
|
aheadCount: number;
|
||||||
|
behindCount: number;
|
||||||
|
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
||||||
|
onBranchDropdownOpenChange: (open: boolean) => void;
|
||||||
|
onActionsDropdownOpenChange: (open: boolean) => void;
|
||||||
|
onBranchFilterChange: (value: string) => void;
|
||||||
|
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
|
||||||
|
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||||
|
onPull: (worktree: WorktreeInfo) => void;
|
||||||
|
onPush: (worktree: WorktreeInfo) => void;
|
||||||
|
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
||||||
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
|
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||||
|
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||||
|
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorktreeTab({
|
||||||
|
worktree,
|
||||||
|
isSelected,
|
||||||
|
isRunning,
|
||||||
|
isActivating,
|
||||||
|
isDevServerRunning,
|
||||||
|
devServerInfo,
|
||||||
|
defaultEditorName,
|
||||||
|
branches,
|
||||||
|
filteredBranches,
|
||||||
|
branchFilter,
|
||||||
|
isLoadingBranches,
|
||||||
|
isSwitching,
|
||||||
|
isPulling,
|
||||||
|
isPushing,
|
||||||
|
isStartingDevServer,
|
||||||
|
aheadCount,
|
||||||
|
behindCount,
|
||||||
|
onSelectWorktree,
|
||||||
|
onBranchDropdownOpenChange,
|
||||||
|
onActionsDropdownOpenChange,
|
||||||
|
onBranchFilterChange,
|
||||||
|
onSwitchBranch,
|
||||||
|
onCreateBranch,
|
||||||
|
onPull,
|
||||||
|
onPush,
|
||||||
|
onOpenInEditor,
|
||||||
|
onCommit,
|
||||||
|
onCreatePR,
|
||||||
|
onDeleteWorktree,
|
||||||
|
onStartDevServer,
|
||||||
|
onStopDevServer,
|
||||||
|
onOpenDevServerUrl,
|
||||||
|
}: WorktreeTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
{worktree.isMain ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none",
|
||||||
|
isSelected && "bg-primary text-primary-foreground",
|
||||||
|
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||||
|
)}
|
||||||
|
onClick={() => onSelectWorktree(worktree)}
|
||||||
|
disabled={isActivating}
|
||||||
|
title="Click to preview main"
|
||||||
|
>
|
||||||
|
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
{isActivating && !isRunning && (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||||
|
)}
|
||||||
|
{worktree.branch}
|
||||||
|
{worktree.hasChanges && (
|
||||||
|
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||||
|
{worktree.changedFilesCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<BranchSwitchDropdown
|
||||||
|
worktree={worktree}
|
||||||
|
isSelected={isSelected}
|
||||||
|
branches={branches}
|
||||||
|
filteredBranches={filteredBranches}
|
||||||
|
branchFilter={branchFilter}
|
||||||
|
isLoadingBranches={isLoadingBranches}
|
||||||
|
isSwitching={isSwitching}
|
||||||
|
onOpenChange={onBranchDropdownOpenChange}
|
||||||
|
onFilterChange={onBranchFilterChange}
|
||||||
|
onSwitchBranch={onSwitchBranch}
|
||||||
|
onCreateBranch={onCreateBranch}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0",
|
||||||
|
isSelected && "bg-primary text-primary-foreground",
|
||||||
|
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
||||||
|
!worktree.hasWorktree && !isSelected && "opacity-70"
|
||||||
|
)}
|
||||||
|
onClick={() => onSelectWorktree(worktree)}
|
||||||
|
disabled={isActivating}
|
||||||
|
title={
|
||||||
|
worktree.hasWorktree
|
||||||
|
? "Click to switch to this worktree's branch"
|
||||||
|
: "Click to switch to this branch"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
{isActivating && !isRunning && (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||||
|
)}
|
||||||
|
{worktree.branch}
|
||||||
|
{worktree.hasChanges && (
|
||||||
|
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||||
|
{worktree.changedFilesCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDevServerRunning && (
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-7 p-0 rounded-none border-r-0",
|
||||||
|
isSelected && "bg-primary text-primary-foreground",
|
||||||
|
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
||||||
|
"text-green-500"
|
||||||
|
)}
|
||||||
|
onClick={() => onOpenDevServerUrl(worktree)}
|
||||||
|
title={`Open dev server (port ${devServerInfo?.port})`}
|
||||||
|
>
|
||||||
|
<Globe className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<WorktreeActionsDropdown
|
||||||
|
worktree={worktree}
|
||||||
|
isSelected={isSelected}
|
||||||
|
defaultEditorName={defaultEditorName}
|
||||||
|
aheadCount={aheadCount}
|
||||||
|
behindCount={behindCount}
|
||||||
|
isPulling={isPulling}
|
||||||
|
isPushing={isPushing}
|
||||||
|
isStartingDevServer={isStartingDevServer}
|
||||||
|
isDevServerRunning={isDevServerRunning}
|
||||||
|
devServerInfo={devServerInfo}
|
||||||
|
onOpenChange={onActionsDropdownOpenChange}
|
||||||
|
onPull={onPull}
|
||||||
|
onPush={onPush}
|
||||||
|
onOpenInEditor={onOpenInEditor}
|
||||||
|
onCommit={onCommit}
|
||||||
|
onCreatePR={onCreatePR}
|
||||||
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
|
onStartDevServer={onStartDevServer}
|
||||||
|
onStopDevServer={onStopDevServer}
|
||||||
|
onOpenDevServerUrl={onOpenDevServerUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { useWorktrees } from "./use-worktrees";
|
||||||
|
export { useDevServers } from "./use-dev-servers";
|
||||||
|
export { useBranches } from "./use-branches";
|
||||||
|
export { useWorktreeActions } from "./use-worktree-actions";
|
||||||
|
export { useDefaultEditor } from "./use-default-editor";
|
||||||
|
export { useRunningFeatures } from "./use-running-features";
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import type { BranchInfo } from "../types";
|
||||||
|
|
||||||
|
export function useBranches() {
|
||||||
|
const [branches, setBranches] = useState<BranchInfo[]>([]);
|
||||||
|
const [aheadCount, setAheadCount] = useState(0);
|
||||||
|
const [behindCount, setBehindCount] = useState(0);
|
||||||
|
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||||
|
const [branchFilter, setBranchFilter] = useState("");
|
||||||
|
|
||||||
|
const fetchBranches = useCallback(async (worktreePath: string) => {
|
||||||
|
setIsLoadingBranches(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.listBranches) {
|
||||||
|
console.warn("List branches API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await api.worktree.listBranches(worktreePath);
|
||||||
|
if (result.success && result.result) {
|
||||||
|
setBranches(result.result.branches);
|
||||||
|
setAheadCount(result.result.aheadCount || 0);
|
||||||
|
setBehindCount(result.result.behindCount || 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch branches:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingBranches(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetBranchFilter = useCallback(() => {
|
||||||
|
setBranchFilter("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredBranches = branches.filter((b) =>
|
||||||
|
b.name.toLowerCase().includes(branchFilter.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
branches,
|
||||||
|
filteredBranches,
|
||||||
|
aheadCount,
|
||||||
|
behindCount,
|
||||||
|
isLoadingBranches,
|
||||||
|
branchFilter,
|
||||||
|
setBranchFilter,
|
||||||
|
resetBranchFilter,
|
||||||
|
fetchBranches,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
|
||||||
|
export function useDefaultEditor() {
|
||||||
|
const [defaultEditorName, setDefaultEditorName] = useState<string>("Editor");
|
||||||
|
|
||||||
|
const fetchDefaultEditor = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.getDefaultEditor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await api.worktree.getDefaultEditor();
|
||||||
|
if (result.success && result.result?.editorName) {
|
||||||
|
setDefaultEditorName(result.result.editorName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch default editor:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDefaultEditor();
|
||||||
|
}, [fetchDefaultEditor]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaultEditorName,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { normalizePath } from "@/lib/utils";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { DevServerInfo, WorktreeInfo } from "../types";
|
||||||
|
|
||||||
|
interface UseDevServersOptions {
|
||||||
|
projectPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||||
|
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
|
||||||
|
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchDevServers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.listDevServers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await api.worktree.listDevServers();
|
||||||
|
if (result.success && result.result?.servers) {
|
||||||
|
const serversMap = new Map<string, DevServerInfo>();
|
||||||
|
for (const server of result.result.servers) {
|
||||||
|
serversMap.set(server.worktreePath, server);
|
||||||
|
}
|
||||||
|
setRunningDevServers(serversMap);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch dev servers:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDevServers();
|
||||||
|
}, [fetchDevServers]);
|
||||||
|
|
||||||
|
const getWorktreeKey = useCallback(
|
||||||
|
(worktree: WorktreeInfo) => {
|
||||||
|
const path = worktree.isMain ? projectPath : worktree.path;
|
||||||
|
return path ? normalizePath(path) : path;
|
||||||
|
},
|
||||||
|
[projectPath]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStartDevServer = useCallback(
|
||||||
|
async (worktree: WorktreeInfo) => {
|
||||||
|
if (isStartingDevServer) return;
|
||||||
|
setIsStartingDevServer(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.startDevServer) {
|
||||||
|
toast.error("Start dev server API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = worktree.isMain ? projectPath : worktree.path;
|
||||||
|
const result = await api.worktree.startDevServer(projectPath, targetPath);
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
setRunningDevServers((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(normalizePath(targetPath), {
|
||||||
|
worktreePath: result.result!.worktreePath,
|
||||||
|
port: result.result!.port,
|
||||||
|
url: result.result!.url,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
toast.success(`Dev server started on port ${result.result.port}`);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Failed to start dev server");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Start dev server failed:", error);
|
||||||
|
toast.error("Failed to start dev server");
|
||||||
|
} finally {
|
||||||
|
setIsStartingDevServer(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isStartingDevServer, projectPath]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStopDevServer = useCallback(
|
||||||
|
async (worktree: WorktreeInfo) => {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.stopDevServer) {
|
||||||
|
toast.error("Stop dev server API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = worktree.isMain ? projectPath : worktree.path;
|
||||||
|
const result = await api.worktree.stopDevServer(targetPath);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setRunningDevServers((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(normalizePath(targetPath));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
toast.success(result.result?.message || "Dev server stopped");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Failed to stop dev server");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Stop dev server failed:", error);
|
||||||
|
toast.error("Failed to stop dev server");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectPath]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenDevServerUrl = useCallback(
|
||||||
|
(worktree: WorktreeInfo) => {
|
||||||
|
const targetPath = worktree.isMain ? projectPath : worktree.path;
|
||||||
|
const serverInfo = runningDevServers.get(targetPath);
|
||||||
|
if (serverInfo) {
|
||||||
|
window.open(serverInfo.url, "_blank");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectPath, runningDevServers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isDevServerRunning = useCallback(
|
||||||
|
(worktree: WorktreeInfo) => {
|
||||||
|
return runningDevServers.has(getWorktreeKey(worktree));
|
||||||
|
},
|
||||||
|
[runningDevServers, getWorktreeKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getDevServerInfo = useCallback(
|
||||||
|
(worktree: WorktreeInfo) => {
|
||||||
|
return runningDevServers.get(getWorktreeKey(worktree));
|
||||||
|
},
|
||||||
|
[runningDevServers, getWorktreeKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isStartingDevServer,
|
||||||
|
runningDevServers,
|
||||||
|
getWorktreeKey,
|
||||||
|
isDevServerRunning,
|
||||||
|
getDevServerInfo,
|
||||||
|
handleStartDevServer,
|
||||||
|
handleStopDevServer,
|
||||||
|
handleOpenDevServerUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { pathsEqual } from "@/lib/utils";
|
||||||
|
import type { WorktreeInfo, FeatureInfo } from "../types";
|
||||||
|
|
||||||
|
interface UseRunningFeaturesOptions {
|
||||||
|
projectPath: string;
|
||||||
|
runningFeatureIds: string[];
|
||||||
|
features: FeatureInfo[];
|
||||||
|
getWorktreeKey: (worktree: WorktreeInfo) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRunningFeatures({
|
||||||
|
projectPath,
|
||||||
|
runningFeatureIds,
|
||||||
|
features,
|
||||||
|
getWorktreeKey,
|
||||||
|
}: UseRunningFeaturesOptions) {
|
||||||
|
const hasRunningFeatures = useCallback(
|
||||||
|
(worktree: WorktreeInfo) => {
|
||||||
|
if (runningFeatureIds.length === 0) return false;
|
||||||
|
|
||||||
|
const worktreeKey = getWorktreeKey(worktree);
|
||||||
|
|
||||||
|
return runningFeatureIds.some((featureId) => {
|
||||||
|
const feature = features.find((f) => f.id === featureId);
|
||||||
|
if (!feature) return false;
|
||||||
|
|
||||||
|
if (feature.worktreePath) {
|
||||||
|
if (worktree.isMain) {
|
||||||
|
return pathsEqual(feature.worktreePath, projectPath);
|
||||||
|
}
|
||||||
|
return pathsEqual(feature.worktreePath, worktreeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feature.branchName) {
|
||||||
|
return worktree.branch === feature.branchName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return worktree.isMain;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[runningFeatureIds, features, projectPath, getWorktreeKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasRunningFeatures,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { WorktreeInfo } from "../types";
|
||||||
|
|
||||||
|
interface UseWorktreeActionsOptions {
|
||||||
|
fetchWorktrees: () => Promise<void>;
|
||||||
|
fetchBranches: (worktreePath: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorktreeActions({
|
||||||
|
fetchWorktrees,
|
||||||
|
fetchBranches,
|
||||||
|
}: UseWorktreeActionsOptions) {
|
||||||
|
const [isPulling, setIsPulling] = useState(false);
|
||||||
|
const [isPushing, setIsPushing] = useState(false);
|
||||||
|
const [isSwitching, setIsSwitching] = useState(false);
|
||||||
|
const [isActivating, setIsActivating] = useState(false);
|
||||||
|
|
||||||
|
const handleSwitchBranch = useCallback(
|
||||||
|
async (worktree: WorktreeInfo, branchName: string) => {
|
||||||
|
if (isSwitching || branchName === worktree.branch) return;
|
||||||
|
setIsSwitching(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.switchBranch) {
|
||||||
|
toast.error("Switch branch API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await api.worktree.switchBranch(worktree.path, branchName);
|
||||||
|
if (result.success && result.result) {
|
||||||
|
toast.success(result.result.message);
|
||||||
|
fetchWorktrees();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Failed to switch branch");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Switch branch failed:", error);
|
||||||
|
toast.error("Failed to switch branch");
|
||||||
|
} finally {
|
||||||
|
setIsSwitching(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isSwitching, fetchWorktrees]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePull = useCallback(
|
||||||
|
async (worktree: WorktreeInfo) => {
|
||||||
|
if (isPulling) return;
|
||||||
|
setIsPulling(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.pull) {
|
||||||
|
toast.error("Pull API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await api.worktree.pull(worktree.path);
|
||||||
|
if (result.success && result.result) {
|
||||||
|
toast.success(result.result.message);
|
||||||
|
fetchWorktrees();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Failed to pull latest changes");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Pull failed:", error);
|
||||||
|
toast.error("Failed to pull latest changes");
|
||||||
|
} finally {
|
||||||
|
setIsPulling(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isPulling, fetchWorktrees]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePush = useCallback(
|
||||||
|
async (worktree: WorktreeInfo) => {
|
||||||
|
if (isPushing) return;
|
||||||
|
setIsPushing(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.push) {
|
||||||
|
toast.error("Push API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await api.worktree.push(worktree.path);
|
||||||
|
if (result.success && result.result) {
|
||||||
|
toast.success(result.result.message);
|
||||||
|
fetchBranches(worktree.path);
|
||||||
|
fetchWorktrees();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Failed to push changes");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Push failed:", error);
|
||||||
|
toast.error("Failed to push changes");
|
||||||
|
} finally {
|
||||||
|
setIsPushing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isPushing, fetchBranches, fetchWorktrees]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo) => {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.openInEditor) {
|
||||||
|
console.warn("Open in editor API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await api.worktree.openInEditor(worktree.path);
|
||||||
|
if (result.success && result.result) {
|
||||||
|
toast.success(result.result.message);
|
||||||
|
} else if (result.error) {
|
||||||
|
toast.error(result.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Open in editor failed:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPulling,
|
||||||
|
isPushing,
|
||||||
|
isSwitching,
|
||||||
|
isActivating,
|
||||||
|
setIsActivating,
|
||||||
|
handleSwitchBranch,
|
||||||
|
handlePull,
|
||||||
|
handlePush,
|
||||||
|
handleOpenInEditor,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { pathsEqual } from "@/lib/utils";
|
||||||
|
import type { WorktreeInfo } from "../types";
|
||||||
|
|
||||||
|
interface UseWorktreesOptions {
|
||||||
|
projectPath: string;
|
||||||
|
refreshTrigger?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOptions) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
|
||||||
|
|
||||||
|
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
|
||||||
|
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
|
||||||
|
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
|
||||||
|
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
|
||||||
|
|
||||||
|
const fetchWorktrees = useCallback(async () => {
|
||||||
|
if (!projectPath) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.listAll) {
|
||||||
|
console.warn("Worktree API not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await api.worktree.listAll(projectPath, true);
|
||||||
|
if (result.success && result.worktrees) {
|
||||||
|
setWorktrees(result.worktrees);
|
||||||
|
setWorktreesInStore(projectPath, result.worktrees);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch worktrees:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectPath, setWorktreesInStore]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchWorktrees();
|
||||||
|
}, [fetchWorktrees]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (refreshTrigger > 0) {
|
||||||
|
fetchWorktrees();
|
||||||
|
}
|
||||||
|
}, [refreshTrigger, fetchWorktrees]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (worktrees.length > 0) {
|
||||||
|
const currentPath = currentWorktree?.path;
|
||||||
|
const currentWorktreeExists = currentPath === null
|
||||||
|
? true
|
||||||
|
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
|
||||||
|
|
||||||
|
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
|
||||||
|
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||||
|
const mainBranch = mainWorktree?.branch || "main";
|
||||||
|
setCurrentWorktree(projectPath, null, mainBranch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
|
||||||
|
|
||||||
|
const handleSelectWorktree = useCallback(
|
||||||
|
(worktree: WorktreeInfo) => {
|
||||||
|
setCurrentWorktree(
|
||||||
|
projectPath,
|
||||||
|
worktree.isMain ? null : worktree.path,
|
||||||
|
worktree.branch
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[projectPath, setCurrentWorktree]
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentWorktreePath = currentWorktree?.path ?? null;
|
||||||
|
const selectedWorktree = currentWorktreePath
|
||||||
|
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
|
||||||
|
: worktrees.find((w) => w.isMain);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading,
|
||||||
|
worktrees,
|
||||||
|
currentWorktree,
|
||||||
|
currentWorktreePath,
|
||||||
|
selectedWorktree,
|
||||||
|
useWorktreesEnabled,
|
||||||
|
fetchWorktrees,
|
||||||
|
handleSelectWorktree,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export { WorktreePanel } from "./worktree-panel";
|
||||||
|
export type {
|
||||||
|
WorktreeInfo,
|
||||||
|
BranchInfo,
|
||||||
|
DevServerInfo,
|
||||||
|
FeatureInfo,
|
||||||
|
WorktreePanelProps,
|
||||||
|
} from "./types";
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export interface WorktreeInfo {
|
||||||
|
path: string;
|
||||||
|
branch: string;
|
||||||
|
isMain: boolean;
|
||||||
|
isCurrent: boolean;
|
||||||
|
hasWorktree: boolean;
|
||||||
|
hasChanges?: boolean;
|
||||||
|
changedFilesCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BranchInfo {
|
||||||
|
name: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
isRemote: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DevServerInfo {
|
||||||
|
worktreePath: string;
|
||||||
|
port: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureInfo {
|
||||||
|
id: string;
|
||||||
|
worktreePath?: string;
|
||||||
|
branchName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreePanelProps {
|
||||||
|
projectPath: string;
|
||||||
|
onCreateWorktree: () => void;
|
||||||
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
|
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||||
|
runningFeatureIds?: string[];
|
||||||
|
features?: FeatureInfo[];
|
||||||
|
refreshTrigger?: number;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user