mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
Compare commits
143 Commits
copilot/fi
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46c3dd252f | ||
|
|
18a2ed2a44 | ||
|
|
340e76c3ed | ||
|
|
275037c73d | ||
|
|
18fa0f3066 | ||
|
|
81d631ea72 | ||
|
|
6c31f725ff | ||
|
|
f0bea76141 | ||
|
|
46933a2a81 | ||
|
|
dccb5faa4b | ||
|
|
15ae1fe147 | ||
|
|
6f82f64195 | ||
|
|
a0efa5d351 | ||
|
|
afcda98dc4 | ||
|
|
65edddbc36 | ||
|
|
0dfe5a91e7 | ||
|
|
2f75c0bec5 | ||
|
|
516f26edae | ||
|
|
50fb7ece4f | ||
|
|
9fcdd899b2 | ||
|
|
bacafd129f | ||
|
|
20d7fb1949 | ||
|
|
a192eaa20f | ||
|
|
99fe6f6497 | ||
|
|
35c6beca37 | ||
|
|
f7cb92fa9d | ||
|
|
b403d0d570 | ||
|
|
c80ae3367a | ||
|
|
c7312af3c8 | ||
|
|
f3dbc996d4 | ||
|
|
0549b8085a | ||
|
|
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 | ||
|
|
658cbb8bd6 | ||
|
|
9d17cd7d9c | ||
|
|
091c6b2737 | ||
|
|
2880314931 | ||
|
|
0102719067 | ||
|
|
a5c61b0546 | ||
|
|
480589510e |
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(dir \"C:\\Users\\Ben\\Desktop\\appdev\\git\\automaker\\apps\\app\\public\")"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
3
.github/workflows/pr-check.yml
vendored
3
.github/workflows/pr-check.yml
vendored
@@ -24,6 +24,9 @@ jobs:
|
|||||||
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
|
- name: Configure Git for HTTPS
|
||||||
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
|
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
|
||||||
# This is needed because SSH authentication isn't available in CI
|
# This is needed because SSH authentication isn't available in CI
|
||||||
|
|||||||
65
.gitignore
vendored
65
.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/
|
||||||
|
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
/logs
|
/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
|
||||||
|
|||||||
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.
@@ -45,8 +45,11 @@
|
|||||||
"@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-radio-group": "^1.3.8",
|
||||||
|
"@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-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
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",
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: undefined,
|
||||||
reporter: "html",
|
reporter: "html",
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
use: {
|
use: {
|
||||||
@@ -25,15 +28,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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Binary file not shown.
@@ -13,6 +13,9 @@
|
|||||||
@custom-variant onedark (&:is(.onedark *));
|
@custom-variant onedark (&:is(.onedark *));
|
||||||
@custom-variant synthwave (&:is(.synthwave *));
|
@custom-variant synthwave (&:is(.synthwave *));
|
||||||
@custom-variant red (&:is(.red *));
|
@custom-variant red (&:is(.red *));
|
||||||
|
@custom-variant cream (&:is(.cream *));
|
||||||
|
@custom-variant sunset (&:is(.sunset *));
|
||||||
|
@custom-variant gray (&:is(.gray *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
@@ -1220,6 +1223,252 @@
|
|||||||
--running-indicator-text: oklch(0.6 0.23 25);
|
--running-indicator-text: oklch(0.6 0.23 25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cream {
|
||||||
|
/* Cream Theme - Warm, soft, easy on the eyes */
|
||||||
|
--background: oklch(0.95 0.01 70); /* Warm cream background */
|
||||||
|
--background-50: oklch(0.95 0.01 70 / 0.5);
|
||||||
|
--background-80: oklch(0.95 0.01 70 / 0.8);
|
||||||
|
|
||||||
|
--foreground: oklch(0.25 0.02 60); /* Dark warm brown */
|
||||||
|
--foreground-secondary: oklch(0.45 0.02 60); /* Medium brown */
|
||||||
|
--foreground-muted: oklch(0.55 0.02 60); /* Light brown */
|
||||||
|
|
||||||
|
--card: oklch(0.98 0.005 70); /* Slightly lighter cream */
|
||||||
|
--card-foreground: oklch(0.25 0.02 60);
|
||||||
|
--popover: oklch(0.97 0.008 70);
|
||||||
|
--popover-foreground: oklch(0.25 0.02 60);
|
||||||
|
|
||||||
|
--primary: oklch(0.5 0.12 45); /* Warm terracotta/rust */
|
||||||
|
--primary-foreground: oklch(0.98 0.005 70);
|
||||||
|
|
||||||
|
--brand-400: oklch(0.55 0.12 45);
|
||||||
|
--brand-500: oklch(0.5 0.12 45); /* Terracotta */
|
||||||
|
--brand-600: oklch(0.45 0.13 45);
|
||||||
|
|
||||||
|
--secondary: oklch(0.88 0.02 70);
|
||||||
|
--secondary-foreground: oklch(0.25 0.02 60);
|
||||||
|
|
||||||
|
--muted: oklch(0.9 0.015 70);
|
||||||
|
--muted-foreground: oklch(0.45 0.02 60);
|
||||||
|
|
||||||
|
--accent: oklch(0.85 0.025 70);
|
||||||
|
--accent-foreground: oklch(0.25 0.02 60);
|
||||||
|
|
||||||
|
--destructive: oklch(0.55 0.22 25); /* Warm red */
|
||||||
|
|
||||||
|
--border: oklch(0.85 0.015 70);
|
||||||
|
--border-glass: oklch(0.5 0.12 45 / 0.2);
|
||||||
|
|
||||||
|
--input: oklch(0.98 0.005 70);
|
||||||
|
--ring: oklch(0.5 0.12 45);
|
||||||
|
|
||||||
|
--chart-1: oklch(0.5 0.12 45); /* Terracotta */
|
||||||
|
--chart-2: oklch(0.55 0.15 35); /* Burnt orange */
|
||||||
|
--chart-3: oklch(0.6 0.12 100); /* Olive */
|
||||||
|
--chart-4: oklch(0.5 0.15 20); /* Deep rust */
|
||||||
|
--chart-5: oklch(0.65 0.1 80); /* Golden */
|
||||||
|
|
||||||
|
--sidebar: oklch(0.93 0.012 70);
|
||||||
|
--sidebar-foreground: oklch(0.25 0.02 60);
|
||||||
|
--sidebar-primary: oklch(0.5 0.12 45);
|
||||||
|
--sidebar-primary-foreground: oklch(0.98 0.005 70);
|
||||||
|
--sidebar-accent: oklch(0.88 0.02 70);
|
||||||
|
--sidebar-accent-foreground: oklch(0.25 0.02 60);
|
||||||
|
--sidebar-border: oklch(0.85 0.015 70);
|
||||||
|
--sidebar-ring: oklch(0.5 0.12 45);
|
||||||
|
|
||||||
|
/* Action button colors - Warm earth tones */
|
||||||
|
--action-view: oklch(0.5 0.12 45); /* Terracotta */
|
||||||
|
--action-view-hover: oklch(0.45 0.13 45);
|
||||||
|
--action-followup: oklch(0.55 0.15 35); /* Burnt orange */
|
||||||
|
--action-followup-hover: oklch(0.5 0.16 35);
|
||||||
|
--action-commit: oklch(0.55 0.12 130); /* Sage green */
|
||||||
|
--action-commit-hover: oklch(0.5 0.13 130);
|
||||||
|
--action-verify: oklch(0.55 0.12 130); /* Sage green */
|
||||||
|
--action-verify-hover: oklch(0.5 0.13 130);
|
||||||
|
|
||||||
|
/* Running indicator - Terracotta */
|
||||||
|
--running-indicator: oklch(0.5 0.12 45);
|
||||||
|
--running-indicator-text: oklch(0.55 0.12 45);
|
||||||
|
|
||||||
|
/* Status colors - Cream theme */
|
||||||
|
--status-success: oklch(0.55 0.15 130);
|
||||||
|
--status-success-bg: oklch(0.55 0.15 130 / 0.15);
|
||||||
|
--status-warning: oklch(0.6 0.15 70);
|
||||||
|
--status-warning-bg: oklch(0.6 0.15 70 / 0.15);
|
||||||
|
--status-error: oklch(0.55 0.22 25);
|
||||||
|
--status-error-bg: oklch(0.55 0.22 25 / 0.15);
|
||||||
|
--status-info: oklch(0.5 0.15 230);
|
||||||
|
--status-info-bg: oklch(0.5 0.15 230 / 0.15);
|
||||||
|
--status-backlog: oklch(0.6 0.02 60);
|
||||||
|
--status-in-progress: oklch(0.6 0.15 70);
|
||||||
|
--status-waiting: oklch(0.58 0.13 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sunset {
|
||||||
|
/* Sunset Theme - Mellow oranges and soft purples */
|
||||||
|
--background: oklch(0.15 0.02 280); /* Deep twilight blue-purple */
|
||||||
|
--background-50: oklch(0.15 0.02 280 / 0.5);
|
||||||
|
--background-80: oklch(0.15 0.02 280 / 0.8);
|
||||||
|
|
||||||
|
--foreground: oklch(0.95 0.01 80); /* Warm white */
|
||||||
|
--foreground-secondary: oklch(0.75 0.02 60);
|
||||||
|
--foreground-muted: oklch(0.6 0.02 60);
|
||||||
|
|
||||||
|
--card: oklch(0.2 0.025 280);
|
||||||
|
--card-foreground: oklch(0.95 0.01 80);
|
||||||
|
--popover: oklch(0.18 0.02 280);
|
||||||
|
--popover-foreground: oklch(0.95 0.01 80);
|
||||||
|
|
||||||
|
--primary: oklch(0.68 0.18 45); /* Mellow sunset orange */
|
||||||
|
--primary-foreground: oklch(0.15 0.02 280);
|
||||||
|
|
||||||
|
--brand-400: oklch(0.72 0.17 45);
|
||||||
|
--brand-500: oklch(0.68 0.18 45); /* Soft sunset orange */
|
||||||
|
--brand-600: oklch(0.64 0.19 42);
|
||||||
|
|
||||||
|
--secondary: oklch(0.25 0.03 280);
|
||||||
|
--secondary-foreground: oklch(0.95 0.01 80);
|
||||||
|
|
||||||
|
--muted: oklch(0.27 0.03 280);
|
||||||
|
--muted-foreground: oklch(0.6 0.02 60);
|
||||||
|
|
||||||
|
--accent: oklch(0.35 0.04 310);
|
||||||
|
--accent-foreground: oklch(0.95 0.01 80);
|
||||||
|
|
||||||
|
--destructive: oklch(0.6 0.2 25); /* Muted red */
|
||||||
|
|
||||||
|
--border: oklch(0.32 0.04 280);
|
||||||
|
--border-glass: oklch(0.68 0.18 45 / 0.3);
|
||||||
|
|
||||||
|
--input: oklch(0.2 0.025 280);
|
||||||
|
--ring: oklch(0.68 0.18 45);
|
||||||
|
|
||||||
|
--chart-1: oklch(0.68 0.18 45); /* Mellow orange */
|
||||||
|
--chart-2: oklch(0.75 0.16 340); /* Soft pink sunset */
|
||||||
|
--chart-3: oklch(0.78 0.18 70); /* Soft golden */
|
||||||
|
--chart-4: oklch(0.66 0.19 42); /* Subtle coral */
|
||||||
|
--chart-5: oklch(0.72 0.14 310); /* Pastel purple */
|
||||||
|
|
||||||
|
--sidebar: oklch(0.13 0.015 280);
|
||||||
|
--sidebar-foreground: oklch(0.95 0.01 80);
|
||||||
|
--sidebar-primary: oklch(0.68 0.18 45);
|
||||||
|
--sidebar-primary-foreground: oklch(0.15 0.02 280);
|
||||||
|
--sidebar-accent: oklch(0.25 0.03 280);
|
||||||
|
--sidebar-accent-foreground: oklch(0.95 0.01 80);
|
||||||
|
--sidebar-border: oklch(0.32 0.04 280);
|
||||||
|
--sidebar-ring: oklch(0.68 0.18 45);
|
||||||
|
|
||||||
|
/* Action button colors - Mellow sunset palette */
|
||||||
|
--action-view: oklch(0.68 0.18 45); /* Mellow orange */
|
||||||
|
--action-view-hover: oklch(0.64 0.19 42);
|
||||||
|
--action-followup: oklch(0.75 0.16 340); /* Soft pink */
|
||||||
|
--action-followup-hover: oklch(0.7 0.17 340);
|
||||||
|
--action-commit: oklch(0.65 0.16 140); /* Soft green */
|
||||||
|
--action-commit-hover: oklch(0.6 0.17 140);
|
||||||
|
--action-verify: oklch(0.65 0.16 140); /* Soft green */
|
||||||
|
--action-verify-hover: oklch(0.6 0.17 140);
|
||||||
|
|
||||||
|
/* Running indicator - Mellow orange */
|
||||||
|
--running-indicator: oklch(0.68 0.18 45);
|
||||||
|
--running-indicator-text: oklch(0.72 0.17 45);
|
||||||
|
|
||||||
|
/* Status colors - Sunset theme */
|
||||||
|
--status-success: oklch(0.65 0.16 140);
|
||||||
|
--status-success-bg: oklch(0.65 0.16 140 / 0.2);
|
||||||
|
--status-warning: oklch(0.78 0.18 70);
|
||||||
|
--status-warning-bg: oklch(0.78 0.18 70 / 0.2);
|
||||||
|
--status-error: oklch(0.65 0.2 25);
|
||||||
|
--status-error-bg: oklch(0.65 0.2 25 / 0.2);
|
||||||
|
--status-info: oklch(0.75 0.16 340);
|
||||||
|
--status-info-bg: oklch(0.75 0.16 340 / 0.2);
|
||||||
|
--status-backlog: oklch(0.65 0.02 280);
|
||||||
|
--status-in-progress: oklch(0.78 0.18 70);
|
||||||
|
--status-waiting: oklch(0.72 0.17 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gray {
|
||||||
|
/* Gray Theme - Modern, minimal gray scheme inspired by Cursor */
|
||||||
|
--background: oklch(0.2 0.005 250); /* Medium-dark neutral gray */
|
||||||
|
--background-50: oklch(0.2 0.005 250 / 0.5);
|
||||||
|
--background-80: oklch(0.2 0.005 250 / 0.8);
|
||||||
|
|
||||||
|
--foreground: oklch(0.9 0.005 250); /* Light gray */
|
||||||
|
--foreground-secondary: oklch(0.65 0.005 250);
|
||||||
|
--foreground-muted: oklch(0.5 0.005 250);
|
||||||
|
|
||||||
|
--card: oklch(0.24 0.005 250);
|
||||||
|
--card-foreground: oklch(0.9 0.005 250);
|
||||||
|
--popover: oklch(0.22 0.005 250);
|
||||||
|
--popover-foreground: oklch(0.9 0.005 250);
|
||||||
|
|
||||||
|
--primary: oklch(0.6 0.08 250); /* Subtle blue-gray */
|
||||||
|
--primary-foreground: oklch(0.95 0.005 250);
|
||||||
|
|
||||||
|
--brand-400: oklch(0.65 0.08 250);
|
||||||
|
--brand-500: oklch(0.6 0.08 250); /* Blue-gray */
|
||||||
|
--brand-600: oklch(0.55 0.09 250);
|
||||||
|
|
||||||
|
--secondary: oklch(0.28 0.005 250);
|
||||||
|
--secondary-foreground: oklch(0.9 0.005 250);
|
||||||
|
|
||||||
|
--muted: oklch(0.3 0.005 250);
|
||||||
|
--muted-foreground: oklch(0.6 0.005 250);
|
||||||
|
|
||||||
|
--accent: oklch(0.35 0.01 250);
|
||||||
|
--accent-foreground: oklch(0.9 0.005 250);
|
||||||
|
|
||||||
|
--destructive: oklch(0.6 0.2 25); /* Muted red */
|
||||||
|
|
||||||
|
--border: oklch(0.32 0.005 250);
|
||||||
|
--border-glass: oklch(0.6 0.08 250 / 0.2);
|
||||||
|
|
||||||
|
--input: oklch(0.24 0.005 250);
|
||||||
|
--ring: oklch(0.6 0.08 250);
|
||||||
|
|
||||||
|
--chart-1: oklch(0.6 0.08 250); /* Blue-gray */
|
||||||
|
--chart-2: oklch(0.65 0.1 210); /* Cyan */
|
||||||
|
--chart-3: oklch(0.7 0.12 160); /* Teal */
|
||||||
|
--chart-4: oklch(0.65 0.1 280); /* Purple */
|
||||||
|
--chart-5: oklch(0.7 0.08 300); /* Violet */
|
||||||
|
|
||||||
|
--sidebar: oklch(0.18 0.005 250);
|
||||||
|
--sidebar-foreground: oklch(0.9 0.005 250);
|
||||||
|
--sidebar-primary: oklch(0.6 0.08 250);
|
||||||
|
--sidebar-primary-foreground: oklch(0.95 0.005 250);
|
||||||
|
--sidebar-accent: oklch(0.28 0.005 250);
|
||||||
|
--sidebar-accent-foreground: oklch(0.9 0.005 250);
|
||||||
|
--sidebar-border: oklch(0.32 0.005 250);
|
||||||
|
--sidebar-ring: oklch(0.6 0.08 250);
|
||||||
|
|
||||||
|
/* Action button colors - Subtle modern colors */
|
||||||
|
--action-view: oklch(0.6 0.08 250); /* Blue-gray */
|
||||||
|
--action-view-hover: oklch(0.55 0.09 250);
|
||||||
|
--action-followup: oklch(0.65 0.1 210); /* Cyan */
|
||||||
|
--action-followup-hover: oklch(0.6 0.11 210);
|
||||||
|
--action-commit: oklch(0.65 0.12 150); /* Teal-green */
|
||||||
|
--action-commit-hover: oklch(0.6 0.13 150);
|
||||||
|
--action-verify: oklch(0.65 0.12 150); /* Teal-green */
|
||||||
|
--action-verify-hover: oklch(0.6 0.13 150);
|
||||||
|
|
||||||
|
/* Running indicator - Blue-gray */
|
||||||
|
--running-indicator: oklch(0.6 0.08 250);
|
||||||
|
--running-indicator-text: oklch(0.65 0.08 250);
|
||||||
|
|
||||||
|
/* Status colors - Gray theme */
|
||||||
|
--status-success: oklch(0.65 0.12 150);
|
||||||
|
--status-success-bg: oklch(0.65 0.12 150 / 0.2);
|
||||||
|
--status-warning: oklch(0.7 0.15 70);
|
||||||
|
--status-warning-bg: oklch(0.7 0.15 70 / 0.2);
|
||||||
|
--status-error: oklch(0.6 0.2 25);
|
||||||
|
--status-error-bg: oklch(0.6 0.2 25 / 0.2);
|
||||||
|
--status-info: oklch(0.65 0.1 210);
|
||||||
|
--status-info-bg: oklch(0.65 0.1 210 / 0.2);
|
||||||
|
--status-backlog: oklch(0.6 0.005 250);
|
||||||
|
--status-in-progress: oklch(0.7 0.15 70);
|
||||||
|
--status-waiting: oklch(0.68 0.1 220);
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
@@ -1255,12 +1504,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar for dark themes */
|
/* Custom scrollbar for dark themes */
|
||||||
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red) ::-webkit-scrollbar {
|
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red, .sunset, .gray) ::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red) ::-webkit-scrollbar-track {
|
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red, .sunset, .gray) ::-webkit-scrollbar-track {
|
||||||
background: var(--muted);
|
background: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1296,6 +1545,62 @@
|
|||||||
background: oklch(0.15 0.05 25);
|
background: oklch(0.15 0.05 25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cream theme scrollbar */
|
||||||
|
.cream ::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cream ::-webkit-scrollbar-thumb,
|
||||||
|
.cream .scrollbar-visible::-webkit-scrollbar-thumb {
|
||||||
|
background: oklch(0.7 0.03 60);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cream ::-webkit-scrollbar-thumb:hover,
|
||||||
|
.cream .scrollbar-visible::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: oklch(0.6 0.04 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cream ::-webkit-scrollbar-track,
|
||||||
|
.cream .scrollbar-visible::-webkit-scrollbar-track {
|
||||||
|
background: oklch(0.9 0.015 70);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sunset theme scrollbar */
|
||||||
|
.sunset ::-webkit-scrollbar-thumb,
|
||||||
|
.sunset .scrollbar-visible::-webkit-scrollbar-thumb {
|
||||||
|
background: oklch(0.5 0.14 45);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sunset ::-webkit-scrollbar-thumb:hover,
|
||||||
|
.sunset .scrollbar-visible::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: oklch(0.58 0.16 45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sunset ::-webkit-scrollbar-track,
|
||||||
|
.sunset .scrollbar-visible::-webkit-scrollbar-track {
|
||||||
|
background: oklch(0.18 0.03 280);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gray theme scrollbar */
|
||||||
|
.gray ::-webkit-scrollbar-thumb,
|
||||||
|
.gray .scrollbar-visible::-webkit-scrollbar-thumb {
|
||||||
|
background: oklch(0.4 0.01 250);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gray ::-webkit-scrollbar-thumb:hover,
|
||||||
|
.gray .scrollbar-visible::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: oklch(0.5 0.02 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gray ::-webkit-scrollbar-track,
|
||||||
|
.gray .scrollbar-visible::-webkit-scrollbar-track {
|
||||||
|
background: oklch(0.25 0.005 250);
|
||||||
|
}
|
||||||
|
|
||||||
/* Always visible scrollbar for file diffs and code blocks */
|
/* Always visible scrollbar for file diffs and code blocks */
|
||||||
.scrollbar-visible {
|
.scrollbar-visible {
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
|
|||||||
@@ -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,22 @@ function HomeContent() {
|
|||||||
"catppuccin",
|
"catppuccin",
|
||||||
"onedark",
|
"onedark",
|
||||||
"synthwave",
|
"synthwave",
|
||||||
"red"
|
"red",
|
||||||
);
|
"cream",
|
||||||
|
"sunset",
|
||||||
|
"gray",
|
||||||
|
];
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -27,6 +22,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 = [
|
||||||
@@ -115,7 +111,10 @@ export function SessionManager({
|
|||||||
new Set()
|
new Set()
|
||||||
);
|
);
|
||||||
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[]) => {
|
||||||
@@ -232,11 +231,7 @@ export function SessionManager({
|
|||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!editingName.trim() || !api?.sessions) return;
|
if (!editingName.trim() || !api?.sessions) return;
|
||||||
|
|
||||||
const result = await api.sessions.update(
|
const result = await api.sessions.update(sessionId, editingName, undefined);
|
||||||
sessionId,
|
|
||||||
editingName,
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setEditingSessionId(null);
|
setEditingSessionId(null);
|
||||||
@@ -314,6 +309,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 +411,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 +599,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
226
apps/app/src/components/ui/autocomplete.tsx
Normal file
226
apps/app/src/components/ui/autocomplete.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
"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;
|
||||||
|
error?: 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,
|
||||||
|
error = 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",
|
||||||
|
error && "border-destructive focus-visible:ring-destructive",
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
apps/app/src/components/ui/branch-autocomplete.tsx
Normal file
68
apps/app/src/components/ui/branch-autocomplete.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"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[];
|
||||||
|
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
"data-testid"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BranchAutocomplete({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
branches,
|
||||||
|
branchCardCounts,
|
||||||
|
placeholder = "Select a branch...",
|
||||||
|
className,
|
||||||
|
disabled = false,
|
||||||
|
error = 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) => {
|
||||||
|
const cardCount = branchCardCounts?.[branch];
|
||||||
|
// Show card count if available, otherwise show "default" for main branch only
|
||||||
|
const badge = branchCardCounts !== undefined
|
||||||
|
? String(cardCount ?? 0)
|
||||||
|
: branch === "main"
|
||||||
|
? "default"
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: branch,
|
||||||
|
label: branch,
|
||||||
|
badge,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [branches, branchCardCounts]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={branchOptions}
|
||||||
|
placeholder={placeholder}
|
||||||
|
searchPlaceholder="Search or type new branch..."
|
||||||
|
emptyMessage="No branches found."
|
||||||
|
className={className}
|
||||||
|
disabled={disabled}
|
||||||
|
error={error}
|
||||||
|
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,23 @@ interface CheckboxProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElemen
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CheckboxRoot = CheckboxPrimitive.Root as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLButtonElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
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>(
|
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||||
({ className, onCheckedChange, ...props }, ref) => (
|
({ className, onCheckedChange, children: _children, ...props }, ref) => (
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxRoot
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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",
|
||||||
@@ -29,12 +43,12 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
|||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator
|
<CheckboxIndicator
|
||||||
className={cn("flex items-center justify-center text-current")}
|
className={cn("flex items-center justify-center text-current")}
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxIndicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxRoot>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -49,16 +87,18 @@ function DialogOverlay({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogContent({
|
export type DialogContentProps = Omit<
|
||||||
className,
|
React.ComponentProps<typeof DialogPrimitive.Content>,
|
||||||
children,
|
"ref"
|
||||||
showCloseButton = true,
|
> & {
|
||||||
compact = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}) {
|
};
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
DialogContentProps
|
||||||
|
>(({ className, children, showCloseButton = true, compact = false, ...props }, ref) => {
|
||||||
// Check if className contains a custom max-width
|
// Check if className contains a custom max-width
|
||||||
const hasCustomMaxWidth =
|
const hasCustomMaxWidth =
|
||||||
typeof className === "string" && className.includes("max-w-");
|
typeof className === "string" && className.includes("max-w-");
|
||||||
@@ -66,7 +106,8 @@ function DialogContent({
|
|||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogContentPrimitive
|
||||||
|
ref={ref}
|
||||||
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 +132,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,12 +146,14 @@ function DialogContent({
|
|||||||
>
|
>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogClosePrimitive>
|
||||||
)}
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogContentPrimitive>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
DialogContent.displayName = "DialogContent";
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
@@ -137,27 +180,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}
|
||||||
|
|||||||
46
apps/app/src/components/ui/radio-group.tsx
Normal file
46
apps/app/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||||
|
import { Circle } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem };
|
||||||
|
|
||||||
|
|
||||||
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,
|
||||||
|
};
|
||||||
@@ -4,6 +4,33 @@ 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";
|
||||||
|
|
||||||
|
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||||
|
const SliderRootPrimitive = SliderPrimitive.Root as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLSpanElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const SliderTrackPrimitive = SliderPrimitive.Track as React.ForwardRefExoticComponent<
|
||||||
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Track> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
} & React.RefAttributes<HTMLSpanElement>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const SliderRangePrimitive = SliderPrimitive.Range as React.ForwardRefExoticComponent<
|
||||||
|
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"> {
|
interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "defaultValue" | "dir"> {
|
||||||
value?: number[];
|
value?: number[];
|
||||||
defaultValue?: number[];
|
defaultValue?: number[];
|
||||||
@@ -21,7 +48,7 @@ interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "defau
|
|||||||
|
|
||||||
const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
|
const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<SliderPrimitive.Root
|
<SliderRootPrimitive
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full touch-none select-none items-center",
|
"relative flex w-full touch-none select-none items-center",
|
||||||
@@ -29,11 +56,11 @@ const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SliderPrimitive.Track className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
|
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
|
||||||
<SliderPrimitive.Range className="slider-range absolute h-full bg-primary" />
|
<SliderRangePrimitive className="slider-range absolute h-full bg-primary" />
|
||||||
</SliderPrimitive.Track>
|
</SliderTrackPrimitive>
|
||||||
<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" />
|
<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" />
|
||||||
</SliderPrimitive.Root>
|
</SliderRootPrimitive>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||||
|
|||||||
31
apps/app/src/components/ui/switch.tsx
Normal file
31
apps/app/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-5 w-5 rounded-full bg-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
|
|
||||||
|
|
||||||
@@ -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
137
apps/app/src/components/views/board-view/board-controls.tsx
Normal file
137
apps/app/src/components/views/board-view/board-controls.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { ImageIcon, Archive, Minimize2, Square, Maximize2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (!isMounted) return null;
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
apps/app/src/components/views/board-view/board-header.tsx
Normal file
95
apps/app/src/components/views/board-view/board-header.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Plus, Users } from "lucide-react";
|
||||||
|
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
|
||||||
|
interface BoardHeaderProps {
|
||||||
|
projectName: string;
|
||||||
|
maxConcurrency: number;
|
||||||
|
onConcurrencyChange: (value: number) => void;
|
||||||
|
isAutoModeRunning: boolean;
|
||||||
|
onAutoModeToggle: (enabled: boolean) => void;
|
||||||
|
onAddFeature: () => void;
|
||||||
|
addFeatureShortcut: KeyboardShortcut;
|
||||||
|
isMounted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoardHeader({
|
||||||
|
projectName,
|
||||||
|
maxConcurrency,
|
||||||
|
onConcurrencyChange,
|
||||||
|
isAutoModeRunning,
|
||||||
|
onAutoModeToggle,
|
||||||
|
onAddFeature,
|
||||||
|
addFeatureShortcut,
|
||||||
|
isMounted,
|
||||||
|
}: BoardHeaderProps) {
|
||||||
|
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 && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
|
||||||
|
<Label
|
||||||
|
htmlFor="auto-mode-toggle"
|
||||||
|
className="text-sm font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
Auto Mode
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="auto-mode-toggle"
|
||||||
|
checked={isAutoModeRunning}
|
||||||
|
onCheckedChange={onAutoModeToggle}
|
||||||
|
data-testid="auto-mode-toggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Search, X, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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";
|
||||||
@@ -52,16 +52,16 @@ import {
|
|||||||
MoreVertical,
|
MoreVertical,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Undo2,
|
|
||||||
GitMerge,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Brain,
|
Brain,
|
||||||
Wand2,
|
Wand2,
|
||||||
Archive,
|
Archive,
|
||||||
|
Lock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CountUpTimer } from "@/components/ui/count-up-timer";
|
import { CountUpTimer } from "@/components/ui/count-up-timer";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { getBlockingDependencies } from "@/lib/dependency-resolver";
|
||||||
import {
|
import {
|
||||||
parseAgentContext,
|
parseAgentContext,
|
||||||
AgentTaskInfo,
|
AgentTaskInfo,
|
||||||
@@ -84,8 +84,8 @@ function formatThinkingLevel(level: ThinkingLevel | undefined): string {
|
|||||||
const labels: Record<ThinkingLevel, string> = {
|
const labels: Record<ThinkingLevel, string> = {
|
||||||
none: "",
|
none: "",
|
||||||
low: "Low",
|
low: "Low",
|
||||||
medium: "Med",
|
medium: "Med", //
|
||||||
high: "High",
|
high: "High", //
|
||||||
ultrathink: "Ultra",
|
ultrathink: "Ultra",
|
||||||
};
|
};
|
||||||
return labels[level];
|
return labels[level];
|
||||||
@@ -103,10 +103,10 @@ interface KanbanCardProps {
|
|||||||
onMoveBackToInProgress?: () => void;
|
onMoveBackToInProgress?: () => void;
|
||||||
onFollowUp?: () => void;
|
onFollowUp?: () => void;
|
||||||
onCommit?: () => void;
|
onCommit?: () => void;
|
||||||
onRevert?: () => void;
|
|
||||||
onMerge?: () => void;
|
|
||||||
onImplement?: () => void;
|
onImplement?: () => void;
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
|
onViewPlan?: () => void;
|
||||||
|
onApprovePlan?: () => void;
|
||||||
hasContext?: boolean;
|
hasContext?: boolean;
|
||||||
isCurrentAutoTask?: boolean;
|
isCurrentAutoTask?: boolean;
|
||||||
shortcutKey?: string;
|
shortcutKey?: string;
|
||||||
@@ -130,10 +130,10 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
onMoveBackToInProgress,
|
onMoveBackToInProgress,
|
||||||
onFollowUp,
|
onFollowUp,
|
||||||
onCommit,
|
onCommit,
|
||||||
onRevert,
|
|
||||||
onMerge,
|
|
||||||
onImplement,
|
onImplement,
|
||||||
onComplete,
|
onComplete,
|
||||||
|
onViewPlan,
|
||||||
|
onApprovePlan,
|
||||||
hasContext,
|
hasContext,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
shortcutKey,
|
shortcutKey,
|
||||||
@@ -146,13 +146,18 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
}: KanbanCardProps) {
|
}: KanbanCardProps) {
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||||
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
|
|
||||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||||
const { kanbanCardDetailLevel } = useAppStore();
|
const { kanbanCardDetailLevel, enableDependencyBlocking, features, useWorktrees } = useAppStore();
|
||||||
|
|
||||||
const hasWorktree = !!feature.branchName;
|
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
||||||
|
const blockingDependencies = useMemo(() => {
|
||||||
|
if (!enableDependencyBlocking || feature.status !== "backlog") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return getBlockingDependencies(feature, features);
|
||||||
|
}, [enableDependencyBlocking, feature, features]);
|
||||||
|
|
||||||
const showSteps =
|
const showSteps =
|
||||||
kanbanCardDetailLevel === "standard" ||
|
kanbanCardDetailLevel === "standard" ||
|
||||||
@@ -256,7 +261,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
feature.status === "backlog" ||
|
feature.status === "backlog" ||
|
||||||
feature.status === "waiting_approval" ||
|
feature.status === "waiting_approval" ||
|
||||||
feature.status === "verified" ||
|
feature.status === "verified" ||
|
||||||
(feature.skipTests && !isCurrentAutoTask);
|
(feature.status === "in_progress" && !isCurrentAutoTask);
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
@@ -330,23 +335,67 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Skip Tests (Manual) indicator badge */}
|
{/* Priority badge */}
|
||||||
{feature.skipTests && !feature.error && (
|
{feature.priority && (
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
|
"absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
|
||||||
"top-2 left-2",
|
"top-2 left-2 min-w-[36px]",
|
||||||
"bg-[var(--status-warning-bg)] border border-[var(--status-warning)]/40 text-[var(--status-warning)]"
|
feature.priority === 1 &&
|
||||||
|
"bg-red-500/20 text-red-500 border-2 border-red-500/50",
|
||||||
|
feature.priority === 2 &&
|
||||||
|
"bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50",
|
||||||
|
feature.priority === 3 &&
|
||||||
|
"bg-blue-500/20 text-blue-500 border-2 border-blue-500/50"
|
||||||
)}
|
)}
|
||||||
data-testid={`skip-tests-badge-${feature.id}`}
|
data-testid={`priority-badge-${feature.id}`}
|
||||||
>
|
>
|
||||||
<Hand className="w-3 h-3" />
|
{feature.priority === 1 ? "H" : feature.priority === 2 ? "M" : "L"}
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" className="text-xs">
|
<TooltipContent side="right" className="text-xs">
|
||||||
|
<p>
|
||||||
|
{feature.priority === 1
|
||||||
|
? "High Priority"
|
||||||
|
: feature.priority === 2
|
||||||
|
? "Medium Priority"
|
||||||
|
: "Low Priority"}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Category text next to priority badge */}
|
||||||
|
{feature.priority && (
|
||||||
|
<div className="absolute top-2 left-[54px] right-12 z-10 flex items-center h-[32px]">
|
||||||
|
<span className="text-[11px] text-muted-foreground/70 font-medium truncate">
|
||||||
|
{feature.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skip Tests (Manual) indicator badge - positioned at top right */}
|
||||||
|
{feature.skipTests && !feature.error && feature.status === "backlog" && (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
|
||||||
|
"min-w-[36px]",
|
||||||
|
"top-2 right-2",
|
||||||
|
"bg-[var(--status-warning-bg)] border-2 border-[var(--status-warning)]/50 text-[var(--status-warning)]"
|
||||||
|
)}
|
||||||
|
data-testid={`skip-tests-badge-${feature.id}`}
|
||||||
|
>
|
||||||
|
<Hand className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left" className="text-xs">
|
||||||
<p>Manual verification required</p>
|
<p>Manual verification required</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -360,13 +409,14 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
|
"absolute px-2 py-1 text-[11px] font-medium rounded-md flex items-center justify-center z-10",
|
||||||
"top-2 left-2",
|
"min-w-[36px]",
|
||||||
|
feature.priority ? "top-11 left-2" : "top-2 left-2",
|
||||||
"bg-[var(--status-error-bg)] border border-[var(--status-error)]/40 text-[var(--status-error)]"
|
"bg-[var(--status-error-bg)] border border-[var(--status-error)]/40 text-[var(--status-error)]"
|
||||||
)}
|
)}
|
||||||
data-testid={`error-badge-${feature.id}`}
|
data-testid={`error-badge-${feature.id}`}
|
||||||
>
|
>
|
||||||
<AlertCircle className="w-3 h-3" />
|
<AlertCircle className="w-3.5 h-3.5" />
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" className="text-xs max-w-[250px]">
|
<TooltipContent side="right" className="text-xs max-w-[250px]">
|
||||||
@@ -376,12 +426,42 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Blocked by dependencies badge - positioned at top right */}
|
||||||
|
{blockingDependencies.length > 0 && !feature.error && !feature.skipTests && feature.status === "backlog" && (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
|
||||||
|
"min-w-[36px]",
|
||||||
|
"top-2 right-2",
|
||||||
|
"bg-orange-500/20 border-2 border-orange-500/50 text-orange-500"
|
||||||
|
)}
|
||||||
|
data-testid={`blocked-badge-${feature.id}`}
|
||||||
|
>
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left" className="text-xs max-w-[250px]">
|
||||||
|
<p className="font-medium mb-1">Blocked by {blockingDependencies.length} incomplete {blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{blockingDependencies.map(depId => {
|
||||||
|
const dep = features.find(f => f.id === depId);
|
||||||
|
return dep?.description || depId;
|
||||||
|
}).join(', ')}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Just Finished indicator badge */}
|
{/* Just Finished indicator badge */}
|
||||||
{isJustFinished && (
|
{isJustFinished && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
|
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
|
||||||
feature.skipTests ? "top-8 left-2" : "top-2 left-2",
|
feature.priority ? "top-11 left-2" : "top-2 left-2",
|
||||||
"bg-[var(--status-success-bg)] border border-[var(--status-success)]/40 text-[var(--status-success)]",
|
"bg-[var(--status-success-bg)] border border-[var(--status-success)]/40 text-[var(--status-success)]",
|
||||||
"animate-pulse"
|
"animate-pulse"
|
||||||
)}
|
)}
|
||||||
@@ -392,40 +472,13 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Branch badge */}
|
|
||||||
{hasWorktree && !isCurrentAutoTask && (
|
|
||||||
<TooltipProvider delayDuration={300}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10 cursor-default",
|
|
||||||
"bg-[var(--status-info-bg)] border border-[var(--status-info)]/40 text-[var(--status-info)]",
|
|
||||||
feature.error || feature.skipTests || isJustFinished
|
|
||||||
? "top-8 left-2"
|
|
||||||
: "top-2 left-2"
|
|
||||||
)}
|
|
||||||
data-testid={`branch-badge-${feature.id}`}
|
|
||||||
>
|
|
||||||
<GitBranch className="w-3 h-3 shrink-0" />
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" className="max-w-[300px]">
|
|
||||||
<p className="font-mono text-xs break-all">
|
|
||||||
{feature.branchName}
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CardHeader
|
<CardHeader
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 pb-2 block",
|
"p-3 pb-2 block",
|
||||||
(feature.skipTests || feature.error || isJustFinished) && "pt-10",
|
feature.priority && "pt-12",
|
||||||
hasWorktree &&
|
!feature.priority &&
|
||||||
(feature.skipTests || feature.error || isJustFinished) &&
|
(feature.skipTests || feature.error || isJustFinished) &&
|
||||||
"pt-14"
|
"pt-10"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isCurrentAutoTask && (
|
{isCurrentAutoTask && (
|
||||||
@@ -443,7 +496,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isCurrentAutoTask && feature.status === "backlog" && (
|
{!isCurrentAutoTask && feature.status === "backlog" && (
|
||||||
<div className="absolute top-2 right-2">
|
<div className="absolute bottom-1 right-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -462,43 +515,110 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
{!isCurrentAutoTask &&
|
{!isCurrentAutoTask &&
|
||||||
(feature.status === "waiting_approval" ||
|
(feature.status === "waiting_approval" ||
|
||||||
feature.status === "verified") && (
|
feature.status === "verified") && (
|
||||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
<>
|
||||||
<Button
|
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onEdit();
|
|
||||||
}}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
data-testid={`edit-${
|
|
||||||
feature.status === "waiting_approval" ? "waiting" : "verified"
|
|
||||||
}-${feature.id}`}
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Edit className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
{onViewOutput && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onViewOutput();
|
onEdit();
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
data-testid={`logs-${
|
data-testid={`edit-${
|
||||||
feature.status === "waiting_approval"
|
feature.status === "waiting_approval" ? "waiting" : "verified"
|
||||||
? "waiting"
|
|
||||||
: "verified"
|
|
||||||
}-${feature.id}`}
|
}-${feature.id}`}
|
||||||
title="Logs"
|
title="Edit"
|
||||||
>
|
>
|
||||||
<FileText className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
{onViewOutput && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewOutput();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`logs-${
|
||||||
|
feature.status === "waiting_approval"
|
||||||
|
? "waiting"
|
||||||
|
: "verified"
|
||||||
|
}-${feature.id}`}
|
||||||
|
title="Logs"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-1 right-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteClick(e);
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`delete-${
|
||||||
|
feature.status === "waiting_approval" ? "waiting" : "verified"
|
||||||
|
}-${feature.id}`}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isCurrentAutoTask && feature.status === "in_progress" && (
|
||||||
|
<>
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`menu-${feature.id}`}
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-36">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
data-testid={`edit-feature-${feature.id}`}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Edit className="w-3 h-3 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{onViewOutput && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewOutput();
|
||||||
|
}}
|
||||||
|
data-testid={`view-logs-${feature.id}`}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3 mr-2" />
|
||||||
|
View Logs
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-1 right-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -508,69 +628,13 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
handleDeleteClick(e);
|
handleDeleteClick(e);
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
data-testid={`delete-${
|
data-testid={`delete-feature-${feature.id}`}
|
||||||
feature.status === "waiting_approval" ? "waiting" : "verified"
|
|
||||||
}-${feature.id}`}
|
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
{!isCurrentAutoTask && feature.status === "in_progress" && (
|
|
||||||
<div className="absolute top-2 right-2">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
data-testid={`menu-${feature.id}`}
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-36">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onEdit();
|
|
||||||
}}
|
|
||||||
data-testid={`edit-feature-${feature.id}`}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Edit className="w-3 h-3 mr-2" />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{onViewOutput && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onViewOutput();
|
|
||||||
}}
|
|
||||||
data-testid={`view-logs-${feature.id}`}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<FileText className="w-3 h-3 mr-2" />
|
|
||||||
View Logs
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-xs text-destructive focus:text-destructive"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDeleteClick(e as unknown as React.MouseEvent);
|
|
||||||
}}
|
|
||||||
data-testid={`delete-feature-${feature.id}`}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3 mr-2" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
{isDraggable && (
|
{isDraggable && (
|
||||||
@@ -613,14 +677,26 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<CardDescription className="text-[11px] mt-1.5 truncate text-muted-foreground/70">
|
{!feature.priority && (
|
||||||
{feature.category}
|
<CardDescription className="text-[11px] mt-1.5 truncate text-muted-foreground/70">
|
||||||
</CardDescription>
|
{feature.category}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="p-3 pt-0">
|
<CardContent className="p-3 pt-0">
|
||||||
|
{/* Target Branch Display */}
|
||||||
|
{useWorktrees && feature.branchName && (
|
||||||
|
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||||
|
<GitBranch className="w-3 h-3 shrink-0" />
|
||||||
|
<span className="font-mono truncate" title={feature.branchName}>
|
||||||
|
{feature.branchName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Steps Preview */}
|
{/* Steps Preview */}
|
||||||
{showSteps && feature.steps && feature.steps.length > 0 && (
|
{showSteps && feature.steps && feature.steps.length > 0 && (
|
||||||
<div className="mb-3 space-y-1.5">
|
<div className="mb-3 space-y-1.5">
|
||||||
@@ -804,14 +880,31 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{isCurrentAutoTask && (
|
{isCurrentAutoTask && (
|
||||||
<>
|
<>
|
||||||
{onViewOutput && (
|
{/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */}
|
||||||
|
{feature.planSpec?.status === 'generated' && onApprovePlan && (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex-1 h-7 text-[11px] bg-[var(--status-info)] hover:bg-[var(--status-info)]/90"
|
className="flex-1 min-w-0 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onApprovePlan();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`approve-plan-running-${feature.id}`}
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3 mr-1 shrink-0" />
|
||||||
|
<span className="truncate">Approve Plan</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onViewOutput && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-7 text-[11px]"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onViewOutput();
|
onViewOutput();
|
||||||
@@ -819,11 +912,11 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
data-testid={`view-output-${feature.id}`}
|
data-testid={`view-output-${feature.id}`}
|
||||||
>
|
>
|
||||||
<FileText className="w-3 h-3 mr-1" />
|
<FileText className="w-3 h-3 mr-1 shrink-0" />
|
||||||
Logs
|
<span className="truncate">Logs</span>
|
||||||
{shortcutKey && (
|
{shortcutKey && (
|
||||||
<span
|
<span
|
||||||
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-white/20"
|
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
|
||||||
data-testid={`shortcut-key-${feature.id}`}
|
data-testid={`shortcut-key-${feature.id}`}
|
||||||
>
|
>
|
||||||
{shortcutKey}
|
{shortcutKey}
|
||||||
@@ -835,7 +928,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-[11px] px-2"
|
className="h-7 text-[11px] px-2 shrink-0"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onForceStop();
|
onForceStop();
|
||||||
@@ -850,6 +943,23 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
)}
|
)}
|
||||||
{!isCurrentAutoTask && feature.status === "in_progress" && (
|
{!isCurrentAutoTask && feature.status === "in_progress" && (
|
||||||
<>
|
<>
|
||||||
|
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
|
||||||
|
{feature.planSpec?.status === 'generated' && onApprovePlan && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onApprovePlan();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`approve-plan-${feature.id}`}
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3 mr-1" />
|
||||||
|
Approve Plan
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{feature.skipTests && onManualVerify ? (
|
{feature.skipTests && onManualVerify ? (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -898,7 +1008,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
) : null}
|
) : null}
|
||||||
{onViewOutput && !feature.skipTests && (
|
{onViewOutput && !feature.skipTests && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-[11px] px-2"
|
className="h-7 text-[11px] px-2"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -915,7 +1025,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
)}
|
)}
|
||||||
{!isCurrentAutoTask && feature.status === "verified" && (
|
{!isCurrentAutoTask && feature.status === "verified" && (
|
||||||
<>
|
<>
|
||||||
{/* Logs button - styled like Refine */}
|
{/* Logs button */}
|
||||||
{onViewOutput && (
|
{onViewOutput && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -953,30 +1063,6 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
)}
|
)}
|
||||||
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
|
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
|
||||||
<>
|
<>
|
||||||
{hasWorktree && onRevert && (
|
|
||||||
<TooltipProvider delayDuration={300}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-7 p-0 text-[var(--status-error)] hover:text-[var(--status-error)] hover:bg-[var(--status-error-bg)] shrink-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsRevertDialogOpen(true);
|
|
||||||
}}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
data-testid={`revert-${feature.id}`}
|
|
||||||
>
|
|
||||||
<Undo2 className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" className="text-xs">
|
|
||||||
<p>Revert changes</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
{/* Refine prompt button */}
|
{/* Refine prompt button */}
|
||||||
{onFollowUp && (
|
{onFollowUp && (
|
||||||
<Button
|
<Button
|
||||||
@@ -994,24 +1080,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<span className="truncate">Refine</span>
|
<span className="truncate">Refine</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{hasWorktree && onMerge && (
|
{onCommit && (
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1 h-7 text-[11px] bg-[var(--status-info)] hover:bg-[var(--status-info)]/90 min-w-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onMerge();
|
|
||||||
}}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
data-testid={`merge-${feature.id}`}
|
|
||||||
title="Merge changes into main branch"
|
|
||||||
>
|
|
||||||
<GitMerge className="w-3 h-3 mr-1 shrink-0" />
|
|
||||||
<span className="truncate">Merge</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!hasWorktree && onCommit && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1045,6 +1114,22 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<Edit className="w-3 h-3 mr-1" />
|
<Edit className="w-3 h-3 mr-1" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
|
{feature.planSpec?.content && onViewPlan && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs px-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewPlan();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`view-plan-${feature.id}`}
|
||||||
|
title="View Plan"
|
||||||
|
>
|
||||||
|
<Eye className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{onImplement && (
|
{onImplement && (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -1120,54 +1205,6 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Revert Confirmation Dialog */}
|
|
||||||
<Dialog open={isRevertDialogOpen} onOpenChange={setIsRevertDialogOpen}>
|
|
||||||
<DialogContent data-testid="revert-confirmation-dialog">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2 text-[var(--status-error)]">
|
|
||||||
<Undo2 className="w-5 h-5" />
|
|
||||||
Revert Changes
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
This will discard all changes made by the agent and move the
|
|
||||||
feature back to the backlog.
|
|
||||||
{feature.branchName && (
|
|
||||||
<span className="block mt-2 font-medium">
|
|
||||||
Branch{" "}
|
|
||||||
<code className="bg-muted px-1.5 py-0.5 rounded text-[11px]">
|
|
||||||
{feature.branchName}
|
|
||||||
</code>{" "}
|
|
||||||
will be deleted.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="block mt-2 text-[var(--status-error)] font-medium">
|
|
||||||
This action cannot be undone.
|
|
||||||
</span>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setIsRevertDialogOpen(false)}
|
|
||||||
data-testid="cancel-revert-button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => {
|
|
||||||
setIsRevertDialogOpen(false);
|
|
||||||
onRevert?.();
|
|
||||||
}}
|
|
||||||
data-testid="confirm-revert-button"
|
|
||||||
>
|
|
||||||
<Undo2 className="w-4 h-4 mr-2" />
|
|
||||||
Revert Changes
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
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,544 @@
|
|||||||
|
"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 {
|
||||||
|
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,
|
||||||
|
BranchSelector,
|
||||||
|
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; // Can be empty string to use current branch
|
||||||
|
priority: number;
|
||||||
|
planningMode: PlanningMode;
|
||||||
|
requirePlanApproval: boolean;
|
||||||
|
}) => void;
|
||||||
|
categorySuggestions: string[];
|
||||||
|
branchSuggestions: string[];
|
||||||
|
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
||||||
|
defaultSkipTests: boolean;
|
||||||
|
defaultBranch?: string;
|
||||||
|
currentBranch?: string;
|
||||||
|
isMaximized: boolean;
|
||||||
|
showProfilesOnly: boolean;
|
||||||
|
aiProfiles: AIProfile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddFeatureDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onAdd,
|
||||||
|
categorySuggestions,
|
||||||
|
branchSuggestions,
|
||||||
|
branchCardCounts,
|
||||||
|
defaultSkipTests,
|
||||||
|
defaultBranch = "main",
|
||||||
|
currentBranch,
|
||||||
|
isMaximized,
|
||||||
|
showProfilesOnly,
|
||||||
|
aiProfiles,
|
||||||
|
}: AddFeatureDialogProps) {
|
||||||
|
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
|
||||||
|
const [newFeature, setNewFeature] = useState({
|
||||||
|
category: "",
|
||||||
|
description: "",
|
||||||
|
steps: [""],
|
||||||
|
images: [] as FeatureImage[],
|
||||||
|
imagePaths: [] as DescriptionImagePath[],
|
||||||
|
skipTests: false,
|
||||||
|
model: "opus" as AgentModel,
|
||||||
|
thinkingLevel: "none" as ThinkingLevel,
|
||||||
|
branchName: "",
|
||||||
|
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 || "",
|
||||||
|
}));
|
||||||
|
setUseCurrentBranch(true);
|
||||||
|
setPlanningMode(defaultPlanningMode);
|
||||||
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
open,
|
||||||
|
defaultSkipTests,
|
||||||
|
defaultBranch,
|
||||||
|
defaultPlanningMode,
|
||||||
|
defaultRequirePlanApproval,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!newFeature.description.trim()) {
|
||||||
|
setDescriptionError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate branch selection when "other branch" is selected
|
||||||
|
if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
|
||||||
|
toast.error("Please select a branch name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = newFeature.category || "Uncategorized";
|
||||||
|
const selectedModel = newFeature.model;
|
||||||
|
const normalizedThinking = modelSupportsThinking(selectedModel)
|
||||||
|
? newFeature.thinkingLevel
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
// Use current branch if toggle is on
|
||||||
|
// If currentBranch is provided (non-primary worktree), use it
|
||||||
|
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
|
||||||
|
const finalBranchName = useCurrentBranch
|
||||||
|
? currentBranch || ""
|
||||||
|
: newFeature.branchName || "";
|
||||||
|
|
||||||
|
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: finalBranchName,
|
||||||
|
priority: newFeature.priority,
|
||||||
|
planningMode,
|
||||||
|
requirePlanApproval,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setNewFeature({
|
||||||
|
category: "",
|
||||||
|
description: "",
|
||||||
|
steps: [""],
|
||||||
|
images: [],
|
||||||
|
imagePaths: [],
|
||||||
|
skipTests: defaultSkipTests,
|
||||||
|
model: "opus",
|
||||||
|
priority: 2,
|
||||||
|
thinkingLevel: "none",
|
||||||
|
branchName: "",
|
||||||
|
});
|
||||||
|
setUseCurrentBranch(true);
|
||||||
|
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 && (
|
||||||
|
<BranchSelector
|
||||||
|
useCurrentBranch={useCurrentBranch}
|
||||||
|
onUseCurrentBranchChange={setUseCurrentBranch}
|
||||||
|
branchName={newFeature.branchName}
|
||||||
|
onBranchNameChange={(value) =>
|
||||||
|
setNewFeature({ ...newFeature, branchName: value })
|
||||||
|
}
|
||||||
|
branchSuggestions={branchSuggestions}
|
||||||
|
branchCardCounts={branchCardCounts}
|
||||||
|
currentBranch={currentBranch}
|
||||||
|
testIdPrefix="feature"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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"
|
||||||
|
disabled={
|
||||||
|
useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
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,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Archive } from "lucide-react";
|
||||||
|
|
||||||
|
interface ArchiveAllVerifiedDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
verifiedCount: number;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArchiveAllVerifiedDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
verifiedCount,
|
||||||
|
onConfirm,
|
||||||
|
}: ArchiveAllVerifiedDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent data-testid="archive-all-verified-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Archive All Verified Features</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to archive all verified features? They will be
|
||||||
|
moved to the archive box.
|
||||||
|
{verifiedCount > 0 && (
|
||||||
|
<span className="block mt-2 text-yellow-500">
|
||||||
|
{verifiedCount} feature(s) will be archived.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="default" onClick={onConfirm} data-testid="confirm-archive-all-verified">
|
||||||
|
<Archive className="w-4 h-4 mr-2" />
|
||||||
|
Archive All
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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,390 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } 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);
|
||||||
|
// Track whether an operation completed that warrants a refresh
|
||||||
|
const operationCompletedRef = useRef(false);
|
||||||
|
|
||||||
|
// Reset state when dialog opens or worktree changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
// Reset form fields
|
||||||
|
setTitle("");
|
||||||
|
setBody("");
|
||||||
|
setCommitMessage("");
|
||||||
|
setBaseBranch("main");
|
||||||
|
setIsDraft(false);
|
||||||
|
setError(null);
|
||||||
|
// Also reset result states when opening for a new worktree
|
||||||
|
// This prevents showing stale PR URLs from previous worktrees
|
||||||
|
setPrUrl(null);
|
||||||
|
setBrowserUrl(null);
|
||||||
|
setShowBrowserFallback(false);
|
||||||
|
// Reset operation tracking
|
||||||
|
operationCompletedRef.current = false;
|
||||||
|
} else {
|
||||||
|
// Reset everything when dialog closes
|
||||||
|
setTitle("");
|
||||||
|
setBody("");
|
||||||
|
setCommitMessage("");
|
||||||
|
setBaseBranch("main");
|
||||||
|
setIsDraft(false);
|
||||||
|
setError(null);
|
||||||
|
setPrUrl(null);
|
||||||
|
setBrowserUrl(null);
|
||||||
|
setShowBrowserFallback(false);
|
||||||
|
operationCompletedRef.current = 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);
|
||||||
|
// Mark operation as completed for refresh on close
|
||||||
|
operationCompletedRef.current = true;
|
||||||
|
toast.success("Pull request created!", {
|
||||||
|
description: `PR created from ${result.result.branch}`,
|
||||||
|
action: {
|
||||||
|
label: "View PR",
|
||||||
|
onClick: () => window.open(result.result!.prUrl!, "_blank"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Don't call onCreated() here - keep dialog open to show success message
|
||||||
|
// onCreated() will be called when user closes the dialog
|
||||||
|
} 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);
|
||||||
|
// Mark operation as completed - branch was pushed successfully
|
||||||
|
operationCompletedRef.current = 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);
|
||||||
|
// Mark operation as completed - branch was pushed even though PR creation failed
|
||||||
|
operationCompletedRef.current = 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 = () => {
|
||||||
|
// Only call onCreated() if an actual operation completed
|
||||||
|
// This prevents unnecessary refreshes when user cancels
|
||||||
|
if (operationCompletedRef.current) {
|
||||||
|
onCreated();
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
// State reset is handled by useEffect when open becomes false
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div className="flex gap-2 justify-center">
|
||||||
|
<Button
|
||||||
|
onClick={() => window.open(prUrl, "_blank")}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
View Pull Request
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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,173 @@
|
|||||||
|
"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, FileWarning } 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;
|
||||||
|
/** Number of features assigned to this worktree's branch */
|
||||||
|
affectedFeatureCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteWorktreeDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
projectPath,
|
||||||
|
worktree,
|
||||||
|
onDeleted,
|
||||||
|
affectedFeatureCount = 0,
|
||||||
|
}: 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>
|
||||||
|
|
||||||
|
{affectedFeatureCount > 0 && (
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20 mt-2">
|
||||||
|
<FileWarning className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-orange-500 text-sm">
|
||||||
|
{affectedFeatureCount} feature{affectedFeatureCount !== 1 ? "s" : ""}{" "}
|
||||||
|
{affectedFeatureCount !== 1 ? "are" : "is"} assigned to this
|
||||||
|
branch. {affectedFeatureCount !== 1 ? "They" : "It"} will be
|
||||||
|
unassigned and moved to the main worktree.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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,551 @@
|
|||||||
|
"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 {
|
||||||
|
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,
|
||||||
|
BranchSelector,
|
||||||
|
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; // Can be empty string to use current branch
|
||||||
|
priority: number;
|
||||||
|
planningMode: PlanningMode;
|
||||||
|
requirePlanApproval: boolean;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
categorySuggestions: string[];
|
||||||
|
branchSuggestions: string[];
|
||||||
|
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
||||||
|
currentBranch?: string;
|
||||||
|
isMaximized: boolean;
|
||||||
|
showProfilesOnly: boolean;
|
||||||
|
aiProfiles: AIProfile[];
|
||||||
|
allFeatures: Feature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditFeatureDialog({
|
||||||
|
feature,
|
||||||
|
onClose,
|
||||||
|
onUpdate,
|
||||||
|
categorySuggestions,
|
||||||
|
branchSuggestions,
|
||||||
|
branchCardCounts,
|
||||||
|
currentBranch,
|
||||||
|
isMaximized,
|
||||||
|
showProfilesOnly,
|
||||||
|
aiProfiles,
|
||||||
|
allFeatures,
|
||||||
|
}: EditFeatureDialogProps) {
|
||||||
|
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
||||||
|
const [useCurrentBranch, setUseCurrentBranch] = useState(() => {
|
||||||
|
// If feature has no branchName, default to using current branch
|
||||||
|
return !feature?.branchName;
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
// If feature has no branchName, default to using current branch
|
||||||
|
setUseCurrentBranch(!feature.branchName);
|
||||||
|
} else {
|
||||||
|
setEditFeaturePreviewMap(new Map());
|
||||||
|
setShowEditAdvancedOptions(false);
|
||||||
|
}
|
||||||
|
}, [feature]);
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
if (!editingFeature) return;
|
||||||
|
|
||||||
|
// Validate branch selection when "other branch" is selected and branch selector is enabled
|
||||||
|
const isBranchSelectorEnabled = editingFeature.status === "backlog";
|
||||||
|
if (
|
||||||
|
useWorktrees &&
|
||||||
|
isBranchSelectorEnabled &&
|
||||||
|
!useCurrentBranch &&
|
||||||
|
!editingFeature.branchName?.trim()
|
||||||
|
) {
|
||||||
|
toast.error("Please select a branch name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
|
||||||
|
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
|
||||||
|
selectedModel
|
||||||
|
)
|
||||||
|
? editingFeature.thinkingLevel ?? "none"
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
// Use current branch if toggle is on
|
||||||
|
// If currentBranch is provided (non-primary worktree), use it
|
||||||
|
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
|
||||||
|
const finalBranchName = useCurrentBranch
|
||||||
|
? (currentBranch || "")
|
||||||
|
: editingFeature.branchName || "";
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
category: editingFeature.category,
|
||||||
|
description: editingFeature.description,
|
||||||
|
steps: editingFeature.steps,
|
||||||
|
skipTests: editingFeature.skipTests ?? false,
|
||||||
|
model: selectedModel,
|
||||||
|
thinkingLevel: normalizedThinking,
|
||||||
|
imagePaths: editingFeature.imagePaths ?? [],
|
||||||
|
branchName: finalBranchName,
|
||||||
|
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 && (
|
||||||
|
<BranchSelector
|
||||||
|
useCurrentBranch={useCurrentBranch}
|
||||||
|
onUseCurrentBranchChange={setUseCurrentBranch}
|
||||||
|
branchName={editingFeature.branchName ?? ""}
|
||||||
|
onBranchNameChange={(value) =>
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
branchName: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
branchSuggestions={branchSuggestions}
|
||||||
|
branchCardCounts={branchCardCounts}
|
||||||
|
currentBranch={currentBranch}
|
||||||
|
disabled={editingFeature.status !== "backlog"}
|
||||||
|
testIdPrefix="edit-feature"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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"
|
||||||
|
disabled={
|
||||||
|
useWorktrees &&
|
||||||
|
editingFeature.status === "backlog" &&
|
||||||
|
!useCurrentBranch &&
|
||||||
|
!editingFeature.branchName?.trim()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
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 { ArchiveAllVerifiedDialog } from "./archive-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,896 @@
|
|||||||
|
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,
|
||||||
|
isPrimaryWorktreeBranch,
|
||||||
|
getPrimaryWorktreeBranch,
|
||||||
|
} = useAppStore();
|
||||||
|
const autoMode = useAutoMode();
|
||||||
|
|
||||||
|
// Worktrees are created when adding/editing features with a branch name
|
||||||
|
// This ensures the worktree exists before the feature starts execution
|
||||||
|
|
||||||
|
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;
|
||||||
|
}) => {
|
||||||
|
// Empty string means "unassigned" (show only on primary worktree) - convert to undefined
|
||||||
|
// Non-empty string is the actual branch name (for non-primary worktrees)
|
||||||
|
const finalBranchName = featureData.branchName || undefined;
|
||||||
|
|
||||||
|
// If worktrees enabled and a branch is specified, create the worktree now
|
||||||
|
// This ensures the worktree exists before the feature starts
|
||||||
|
if (useWorktrees && finalBranchName && currentProject) {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api?.worktree?.create) {
|
||||||
|
const result = await api.worktree.create(
|
||||||
|
currentProject.path,
|
||||||
|
finalBranchName
|
||||||
|
);
|
||||||
|
if (result.success) {
|
||||||
|
console.log(
|
||||||
|
`[Board] Worktree for branch "${finalBranchName}" ${
|
||||||
|
result.worktree?.isNew ? "created" : "already exists"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
// Refresh worktree list in UI
|
||||||
|
onWorktreeCreated?.();
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
|
||||||
|
result.error
|
||||||
|
);
|
||||||
|
toast.error("Failed to create worktree", {
|
||||||
|
description: result.error || "An error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Error creating worktree:", error);
|
||||||
|
toast.error("Failed to create worktree", {
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : "An error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFeatureData = {
|
||||||
|
...featureData,
|
||||||
|
status: "backlog" as const,
|
||||||
|
branchName: finalBranchName,
|
||||||
|
};
|
||||||
|
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, currentProject, 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;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const finalBranchName = updates.branchName || undefined;
|
||||||
|
|
||||||
|
// If worktrees enabled and a branch is specified, create the worktree now
|
||||||
|
// This ensures the worktree exists before the feature starts
|
||||||
|
if (useWorktrees && finalBranchName && currentProject) {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api?.worktree?.create) {
|
||||||
|
const result = await api.worktree.create(
|
||||||
|
currentProject.path,
|
||||||
|
finalBranchName
|
||||||
|
);
|
||||||
|
if (result.success) {
|
||||||
|
console.log(
|
||||||
|
`[Board] Worktree for branch "${finalBranchName}" ${
|
||||||
|
result.worktree?.isNew ? "created" : "already exists"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
// Refresh worktree list in UI
|
||||||
|
onWorktreeCreated?.();
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
|
||||||
|
result.error
|
||||||
|
);
|
||||||
|
toast.error("Failed to create worktree", {
|
||||||
|
description: result.error || "An error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Error creating worktree:", error);
|
||||||
|
toast.error("Failed to create worktree", {
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : "An error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalUpdates = {
|
||||||
|
...updates,
|
||||||
|
branchName: finalBranchName,
|
||||||
|
};
|
||||||
|
|
||||||
|
updateFeature(featureId, finalUpdates);
|
||||||
|
persistFeatureUpdate(featureId, finalUpdates);
|
||||||
|
if (updates.category) {
|
||||||
|
saveCategory(updates.category);
|
||||||
|
}
|
||||||
|
setEditingFeature(null);
|
||||||
|
},
|
||||||
|
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, useWorktrees, currentProject, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server derives workDir from feature.branchName at execution time
|
||||||
|
const result = await api.autoMode.runFeature(
|
||||||
|
currentProject.path,
|
||||||
|
feature.id,
|
||||||
|
useWorktrees
|
||||||
|
// No worktreePath - server derives from feature.branchName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log(
|
||||||
|
"[Board] Feature run started successfully, branch:",
|
||||||
|
feature.branchName || "default"
|
||||||
|
);
|
||||||
|
} 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 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);
|
||||||
|
// Server derives workDir from feature.branchName at execution time
|
||||||
|
api.autoMode
|
||||||
|
.followUpFeature(
|
||||||
|
currentProject.path,
|
||||||
|
followUpFeature.id,
|
||||||
|
followUpPrompt,
|
||||||
|
imagePaths
|
||||||
|
// No worktreePath - server derives from feature.branchName
|
||||||
|
)
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server derives workDir from feature.branchName
|
||||||
|
const result = await api.autoMode.commitFeature(
|
||||||
|
currentProject.path,
|
||||||
|
feature.id
|
||||||
|
// No worktreePath - server derives from feature.branchName
|
||||||
|
);
|
||||||
|
|
||||||
|
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 primaryBranch = projectPath
|
||||||
|
? getPrimaryWorktreeBranch(projectPath)
|
||||||
|
: null;
|
||||||
|
const backlogFeatures = features.filter((f) => {
|
||||||
|
if (f.status !== "backlog") return false;
|
||||||
|
|
||||||
|
// Determine the feature's branch (default to primary branch if not set)
|
||||||
|
const featureBranch = f.branchName || primaryBranch || "main";
|
||||||
|
|
||||||
|
// If no worktree is selected (currentWorktreeBranch is null or matches primary),
|
||||||
|
// show features with no branch or primary branch
|
||||||
|
if (
|
||||||
|
!currentWorktreeBranch ||
|
||||||
|
(projectPath &&
|
||||||
|
isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch))
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
!f.branchName ||
|
||||||
|
(projectPath && isPrimaryWorktreeBranch(projectPath, featureBranch))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
const isOnPrimaryBranch =
|
||||||
|
!currentWorktreeBranch ||
|
||||||
|
(projectPath &&
|
||||||
|
isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch));
|
||||||
|
toast.info("Backlog empty", {
|
||||||
|
description: !isOnPrimaryBranch
|
||||||
|
? `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)
|
||||||
|
// Features with blocking dependencies are sorted to the end
|
||||||
|
const sortedBacklog = [...backlogFeatures].sort((a, b) => {
|
||||||
|
const aBlocked = enableDependencyBlocking
|
||||||
|
? getBlockingDependencies(a, features).length > 0
|
||||||
|
: false;
|
||||||
|
const bBlocked = enableDependencyBlocking
|
||||||
|
? getBlockingDependencies(b, features).length > 0
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// Blocked features go to the end
|
||||||
|
if (aBlocked && !bBlocked) return 1;
|
||||||
|
if (!aBlocked && bBlocked) return -1;
|
||||||
|
|
||||||
|
// Within same blocked/unblocked group, sort by priority
|
||||||
|
return (a.priority || 999) - (b.priority || 999);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the first feature without blocking dependencies
|
||||||
|
const featureToStart = sortedBacklog.find((f) => {
|
||||||
|
if (!enableDependencyBlocking) return true;
|
||||||
|
return getBlockingDependencies(f, features).length === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!featureToStart) {
|
||||||
|
toast.info("No eligible features", {
|
||||||
|
description:
|
||||||
|
"All backlog features have unmet dependencies. Complete their dependencies first.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start only one feature per keypress (user must press again for next)
|
||||||
|
// Simplified: No worktree creation on client - server derives workDir from feature.branchName
|
||||||
|
await handleStartImplementation(featureToStart);
|
||||||
|
}, [
|
||||||
|
features,
|
||||||
|
runningAutoTasks,
|
||||||
|
handleStartImplementation,
|
||||||
|
currentWorktreeBranch,
|
||||||
|
projectPath,
|
||||||
|
isPrimaryWorktreeBranch,
|
||||||
|
getPrimaryWorktreeBranch,
|
||||||
|
enableDependencyBlocking,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleArchiveAllVerified = 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 archive:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Archive the feature by setting status to completed
|
||||||
|
const updates = {
|
||||||
|
status: "completed" as const,
|
||||||
|
};
|
||||||
|
updateFeature(feature.id, updates);
|
||||||
|
persistFeatureUpdate(feature.id, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("All verified features archived", {
|
||||||
|
description: `Archived ${verifiedFeatures.length} feature(s).`,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
features,
|
||||||
|
runningAutoTasks,
|
||||||
|
autoMode,
|
||||||
|
updateFeature,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleAddFeature,
|
||||||
|
handleUpdateFeature,
|
||||||
|
handleDeleteFeature,
|
||||||
|
handleStartImplementation,
|
||||||
|
handleVerifyFeature,
|
||||||
|
handleResumeFeature,
|
||||||
|
handleManualVerify,
|
||||||
|
handleMoveBackToInProgress,
|
||||||
|
handleOpenFollowUp,
|
||||||
|
handleSendFollowUp,
|
||||||
|
handleCommitFeature,
|
||||||
|
handleMergeFeature,
|
||||||
|
handleCompleteFeature,
|
||||||
|
handleUnarchiveFeature,
|
||||||
|
handleViewOutput,
|
||||||
|
handleOutputModalNumberKeyPress,
|
||||||
|
handleForceStopFeature,
|
||||||
|
handleStartNextFeatures,
|
||||||
|
handleArchiveAllVerified,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,158 @@
|
|||||||
|
import { useMemo, useCallback } from "react";
|
||||||
|
import { Feature, useAppStore } from "@/store/app-store";
|
||||||
|
import { resolveDependencies, getBlockingDependencies } from "@/lib/dependency-resolver";
|
||||||
|
|
||||||
|
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 by branchName
|
||||||
|
// Features without branchName are considered unassigned (show only on primary worktree)
|
||||||
|
const featureBranch = f.branchName;
|
||||||
|
|
||||||
|
let matchesWorktree: boolean;
|
||||||
|
if (!featureBranch) {
|
||||||
|
// No branch assigned - show only on primary worktree
|
||||||
|
const isViewingPrimary = currentWorktreePath === null;
|
||||||
|
matchesWorktree = isViewingPrimary;
|
||||||
|
} 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 primary worktree's branch.
|
||||||
|
matchesWorktree = projectPath
|
||||||
|
? useAppStore.getState().isPrimaryWorktreeBranch(projectPath, featureBranch)
|
||||||
|
: false;
|
||||||
|
} else {
|
||||||
|
// 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
|
||||||
|
if (matchesWorktree) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Get all features to check blocking dependencies against
|
||||||
|
const allFeatures = features;
|
||||||
|
const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking;
|
||||||
|
|
||||||
|
// Sort blocked features to the end of the backlog
|
||||||
|
// This keeps the dependency order within each group (unblocked/blocked)
|
||||||
|
if (enableDependencyBlocking) {
|
||||||
|
const unblocked: Feature[] = [];
|
||||||
|
const blocked: Feature[] = [];
|
||||||
|
|
||||||
|
for (const f of orderedFeatures) {
|
||||||
|
if (getBlockingDependencies(f, allFeatures).length > 0) {
|
||||||
|
blocked.push(f);
|
||||||
|
} else {
|
||||||
|
unblocked.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map.backlog = [...unblocked, ...blocked];
|
||||||
|
} else {
|
||||||
|
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,230 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBoardDragDrop({
|
||||||
|
features,
|
||||||
|
currentProject,
|
||||||
|
runningAutoTasks,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
handleStartImplementation,
|
||||||
|
}: UseBoardDragDropProps) {
|
||||||
|
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||||
|
const { moveFeature } = useAppStore();
|
||||||
|
|
||||||
|
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
|
||||||
|
// at execution time based on feature.branchName
|
||||||
|
|
||||||
|
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)
|
||||||
|
// - in_progress items can be dragged (but not if they're currently running)
|
||||||
|
// - Non-skipTests (TDD) items that are in progress cannot be dragged if they are running
|
||||||
|
if (draggedFeature.status === "in_progress") {
|
||||||
|
// Only allow dragging in_progress if it's not currently running
|
||||||
|
if (isRunningTask) {
|
||||||
|
console.log(
|
||||||
|
"[Board] Cannot drag feature - 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
|
||||||
|
// Note: Worktrees are created server-side at execution time based on feature.branchName
|
||||||
|
if (draggedFeature.status === "backlog") {
|
||||||
|
// From backlog
|
||||||
|
if (targetStatus === "in_progress") {
|
||||||
|
// Use helper function to handle concurrency check and start implementation
|
||||||
|
// Server will derive workDir from feature.branchName
|
||||||
|
await handleStartImplementation(draggedFeature);
|
||||||
|
} 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 when moving back to backlog
|
||||||
|
persistFeatureUpdate(featureId, {
|
||||||
|
status: "backlog",
|
||||||
|
justFinishedAt: undefined,
|
||||||
|
});
|
||||||
|
toast.info("Feature moved to backlog", {
|
||||||
|
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||||
|
0,
|
||||||
|
50
|
||||||
|
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (draggedFeature.status === "in_progress") {
|
||||||
|
// Handle in_progress features being moved
|
||||||
|
if (targetStatus === "backlog") {
|
||||||
|
// Allow moving in_progress cards back to backlog
|
||||||
|
moveFeature(featureId, "backlog");
|
||||||
|
persistFeatureUpdate(featureId, { status: "backlog" });
|
||||||
|
toast.info("Feature moved to backlog", {
|
||||||
|
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||||
|
0,
|
||||||
|
50
|
||||||
|
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
targetStatus === "verified" &&
|
||||||
|
draggedFeature.skipTests
|
||||||
|
) {
|
||||||
|
// Manual verify via drag (only for skipTests features)
|
||||||
|
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 (draggedFeature.skipTests) {
|
||||||
|
// skipTests feature being moved between verified and waiting_approval
|
||||||
|
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 (from verified)
|
||||||
|
moveFeature(featureId, "backlog");
|
||||||
|
persistFeatureUpdate(featureId, { status: "backlog" });
|
||||||
|
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");
|
||||||
|
persistFeatureUpdate(featureId, { status: "backlog" });
|
||||||
|
toast.info("Feature moved to backlog", {
|
||||||
|
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||||
|
0,
|
||||||
|
50
|
||||||
|
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
features,
|
||||||
|
runningAutoTasks,
|
||||||
|
moveFeature,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
handleStartImplementation,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeFeature,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// 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 } = 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
244
apps/app/src/components/views/board-view/kanban-board.tsx
Normal file
244
apps/app/src/components/views/board-view/kanban-board.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
"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 } from "@/store/app-store";
|
||||||
|
import { FastForward, Lightbulb, Archive } from "lucide-react";
|
||||||
|
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { COLUMNS, ColumnId } from "./constants";
|
||||||
|
|
||||||
|
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;
|
||||||
|
onArchiveAllVerified: () => 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,
|
||||||
|
onArchiveAllVerified,
|
||||||
|
}: KanbanBoardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex-1 overflow-x-auto px-4 pb-4 relative"
|
||||||
|
style={backgroundImageStyle}
|
||||||
|
>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={collisionDetectionStrategy}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
>
|
||||||
|
<div className="flex gap-5 h-full min-w-max py-1">
|
||||||
|
{COLUMNS.map((column) => {
|
||||||
|
const columnFeatures = getColumnFeatures(column.id);
|
||||||
|
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={
|
||||||
|
column.id === "verified" &&
|
||||||
|
columnFeatures.length > 0 ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={onArchiveAllVerified}
|
||||||
|
data-testid="archive-all-verified-button"
|
||||||
|
>
|
||||||
|
<Archive className="w-3 h-3 mr-1" />
|
||||||
|
Archive All
|
||||||
|
</Button>
|
||||||
|
) : column.id === "backlog" ? (
|
||||||
|
<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>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
|
||||||
|
interface BranchSelectorProps {
|
||||||
|
useCurrentBranch: boolean;
|
||||||
|
onUseCurrentBranchChange: (useCurrent: boolean) => void;
|
||||||
|
branchName: string;
|
||||||
|
onBranchNameChange: (branchName: string) => void;
|
||||||
|
branchSuggestions: string[];
|
||||||
|
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
||||||
|
currentBranch?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
testIdPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BranchSelector({
|
||||||
|
useCurrentBranch,
|
||||||
|
onUseCurrentBranchChange,
|
||||||
|
branchName,
|
||||||
|
onBranchNameChange,
|
||||||
|
branchSuggestions,
|
||||||
|
branchCardCounts,
|
||||||
|
currentBranch,
|
||||||
|
disabled = false,
|
||||||
|
testIdPrefix = "branch",
|
||||||
|
}: BranchSelectorProps) {
|
||||||
|
// Validate: if "other branch" is selected, branch name is required
|
||||||
|
const isBranchRequired = !useCurrentBranch;
|
||||||
|
const hasError = isBranchRequired && !branchName.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label id={`${testIdPrefix}-label`}>Target Branch</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={useCurrentBranch ? "current" : "other"}
|
||||||
|
onValueChange={(value: string) => onUseCurrentBranchChange(value === "current")}
|
||||||
|
disabled={disabled}
|
||||||
|
data-testid={`${testIdPrefix}-radio-group`}
|
||||||
|
aria-labelledby={`${testIdPrefix}-label`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="current" id={`${testIdPrefix}-current`} />
|
||||||
|
<Label
|
||||||
|
htmlFor={`${testIdPrefix}-current`}
|
||||||
|
className="font-normal cursor-pointer"
|
||||||
|
>
|
||||||
|
Use current selected branch
|
||||||
|
{currentBranch && (
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
({currentBranch})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="other" id={`${testIdPrefix}-other`} />
|
||||||
|
<Label
|
||||||
|
htmlFor={`${testIdPrefix}-other`}
|
||||||
|
className="font-normal cursor-pointer"
|
||||||
|
>
|
||||||
|
Other branch
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
{!useCurrentBranch && (
|
||||||
|
<div className="ml-6 space-y-1">
|
||||||
|
<BranchAutocomplete
|
||||||
|
value={branchName}
|
||||||
|
onChange={onBranchNameChange}
|
||||||
|
branches={branchSuggestions}
|
||||||
|
branchCardCounts={branchCardCounts}
|
||||||
|
placeholder="Select or create branch..."
|
||||||
|
data-testid={`${testIdPrefix}-input`}
|
||||||
|
disabled={disabled}
|
||||||
|
error={hasError}
|
||||||
|
/>
|
||||||
|
{hasError && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
Branch name is required when "Other branch" is selected.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{disabled ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Branch cannot be changed after work has started.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{useCurrentBranch
|
||||||
|
? "Work will be done in the currently selected branch. A worktree will be created if needed."
|
||||||
|
: "Work will be done in this branch. A worktree will be created if needed."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
8
apps/app/src/components/views/board-view/shared/index.ts
Normal file
8
apps/app/src/components/views/board-view/shared/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
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 "./branch-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,195 @@
|
|||||||
|
"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>
|
||||||
|
)}
|
||||||
|
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
|
||||||
|
{(!worktree.isMain || 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,194 @@
|
|||||||
|
"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;
|
||||||
|
cardCount?: number; // Number of unarchived cards for this branch
|
||||||
|
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,
|
||||||
|
cardCount,
|
||||||
|
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}
|
||||||
|
{cardCount !== undefined && cardCount > 0 && (
|
||||||
|
<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">
|
||||||
|
{cardCount}
|
||||||
|
</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}
|
||||||
|
{cardCount !== undefined && cardCount > 0 && (
|
||||||
|
<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">
|
||||||
|
{cardCount}
|
||||||
|
</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,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import type { WorktreeInfo, FeatureInfo } from "../types";
|
||||||
|
|
||||||
|
interface UseRunningFeaturesOptions {
|
||||||
|
runningFeatureIds: string[];
|
||||||
|
features: FeatureInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRunningFeatures({
|
||||||
|
runningFeatureIds,
|
||||||
|
features,
|
||||||
|
}: UseRunningFeaturesOptions) {
|
||||||
|
const hasRunningFeatures = useCallback(
|
||||||
|
(worktree: WorktreeInfo) => {
|
||||||
|
if (runningFeatureIds.length === 0) return false;
|
||||||
|
|
||||||
|
return runningFeatureIds.some((featureId) => {
|
||||||
|
const feature = features.find((f) => f.id === featureId);
|
||||||
|
if (!feature) return false;
|
||||||
|
|
||||||
|
// Match by branchName only (worktreePath is no longer stored)
|
||||||
|
if (feature.branchName) {
|
||||||
|
return worktree.branch === feature.branchName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No branch assigned - belongs to main worktree
|
||||||
|
return worktree.isMain;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[runningFeatureIds, features]
|
||||||
|
);
|
||||||
|
|
||||||
|
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<Array<{ path: string; branch: string }> | undefined>;
|
||||||
|
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,105 @@
|
|||||||
|
"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;
|
||||||
|
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktrees }: 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);
|
||||||
|
}
|
||||||
|
// Return removed worktrees so they can be handled by the caller
|
||||||
|
return result.removedWorktrees;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch worktrees:", error);
|
||||||
|
return undefined;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectPath, setWorktreesInStore]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchWorktrees();
|
||||||
|
}, [fetchWorktrees]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (refreshTrigger > 0) {
|
||||||
|
fetchWorktrees().then((removedWorktrees) => {
|
||||||
|
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||||
|
onRemovedWorktrees(removedWorktrees);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]);
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
// Find the primary worktree and get its branch name
|
||||||
|
// Fallback to "main" only if worktrees haven't loaded yet
|
||||||
|
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,40 @@
|
|||||||
|
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;
|
||||||
|
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;
|
||||||
|
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
||||||
|
runningFeatureIds?: string[];
|
||||||
|
features?: FeatureInfo[];
|
||||||
|
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
||||||
|
refreshTrigger?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { GitBranch, Plus, RefreshCw } from "lucide-react";
|
||||||
|
import { cn, pathsEqual } from "@/lib/utils";
|
||||||
|
import type { WorktreePanelProps, WorktreeInfo } from "./types";
|
||||||
|
import {
|
||||||
|
useWorktrees,
|
||||||
|
useDevServers,
|
||||||
|
useBranches,
|
||||||
|
useWorktreeActions,
|
||||||
|
useDefaultEditor,
|
||||||
|
useRunningFeatures,
|
||||||
|
} from "./hooks";
|
||||||
|
import { WorktreeTab } from "./components";
|
||||||
|
|
||||||
|
export function WorktreePanel({
|
||||||
|
projectPath,
|
||||||
|
onCreateWorktree,
|
||||||
|
onDeleteWorktree,
|
||||||
|
onCommit,
|
||||||
|
onCreatePR,
|
||||||
|
onCreateBranch,
|
||||||
|
onRemovedWorktrees,
|
||||||
|
runningFeatureIds = [],
|
||||||
|
features = [],
|
||||||
|
branchCardCounts,
|
||||||
|
refreshTrigger = 0,
|
||||||
|
}: WorktreePanelProps) {
|
||||||
|
const {
|
||||||
|
isLoading,
|
||||||
|
worktrees,
|
||||||
|
currentWorktree,
|
||||||
|
currentWorktreePath,
|
||||||
|
useWorktreesEnabled,
|
||||||
|
fetchWorktrees,
|
||||||
|
handleSelectWorktree,
|
||||||
|
} = useWorktrees({ projectPath, refreshTrigger, onRemovedWorktrees });
|
||||||
|
|
||||||
|
const {
|
||||||
|
isStartingDevServer,
|
||||||
|
getWorktreeKey,
|
||||||
|
isDevServerRunning,
|
||||||
|
getDevServerInfo,
|
||||||
|
handleStartDevServer,
|
||||||
|
handleStopDevServer,
|
||||||
|
handleOpenDevServerUrl,
|
||||||
|
} = useDevServers({ projectPath });
|
||||||
|
|
||||||
|
const {
|
||||||
|
branches,
|
||||||
|
filteredBranches,
|
||||||
|
aheadCount,
|
||||||
|
behindCount,
|
||||||
|
isLoadingBranches,
|
||||||
|
branchFilter,
|
||||||
|
setBranchFilter,
|
||||||
|
resetBranchFilter,
|
||||||
|
fetchBranches,
|
||||||
|
} = useBranches();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isPulling,
|
||||||
|
isPushing,
|
||||||
|
isSwitching,
|
||||||
|
isActivating,
|
||||||
|
handleSwitchBranch,
|
||||||
|
handlePull,
|
||||||
|
handlePush,
|
||||||
|
handleOpenInEditor,
|
||||||
|
} = useWorktreeActions({
|
||||||
|
fetchWorktrees,
|
||||||
|
fetchBranches,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { defaultEditorName } = useDefaultEditor();
|
||||||
|
|
||||||
|
const { hasRunningFeatures } = useRunningFeatures({
|
||||||
|
runningFeatureIds,
|
||||||
|
features,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isWorktreeSelected = (worktree: WorktreeInfo) => {
|
||||||
|
return worktree.isMain
|
||||||
|
? currentWorktree === null ||
|
||||||
|
currentWorktree === undefined ||
|
||||||
|
currentWorktree.path === null
|
||||||
|
: pathsEqual(worktree.path, currentWorktreePath);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBranchDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
|
||||||
|
if (open) {
|
||||||
|
fetchBranches(worktree.path);
|
||||||
|
resetBranchFilter();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
|
||||||
|
if (open) {
|
||||||
|
fetchBranches(worktree.path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!useWorktreesEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
||||||
|
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{worktrees.map((worktree) => {
|
||||||
|
const cardCount = branchCardCounts?.[worktree.branch];
|
||||||
|
return (
|
||||||
|
<WorktreeTab
|
||||||
|
key={worktree.path}
|
||||||
|
worktree={worktree}
|
||||||
|
cardCount={cardCount}
|
||||||
|
isSelected={isWorktreeSelected(worktree)}
|
||||||
|
isRunning={hasRunningFeatures(worktree)}
|
||||||
|
isActivating={isActivating}
|
||||||
|
isDevServerRunning={isDevServerRunning(worktree)}
|
||||||
|
devServerInfo={getDevServerInfo(worktree)}
|
||||||
|
defaultEditorName={defaultEditorName}
|
||||||
|
branches={branches}
|
||||||
|
filteredBranches={filteredBranches}
|
||||||
|
branchFilter={branchFilter}
|
||||||
|
isLoadingBranches={isLoadingBranches}
|
||||||
|
isSwitching={isSwitching}
|
||||||
|
isPulling={isPulling}
|
||||||
|
isPushing={isPushing}
|
||||||
|
isStartingDevServer={isStartingDevServer}
|
||||||
|
aheadCount={aheadCount}
|
||||||
|
behindCount={behindCount}
|
||||||
|
onSelectWorktree={handleSelectWorktree}
|
||||||
|
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||||
|
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||||
|
onBranchFilterChange={setBranchFilter}
|
||||||
|
onSwitchBranch={handleSwitchBranch}
|
||||||
|
onCreateBranch={onCreateBranch}
|
||||||
|
onPull={handlePull}
|
||||||
|
onPush={handlePush}
|
||||||
|
onOpenInEditor={handleOpenInEditor}
|
||||||
|
onCommit={onCommit}
|
||||||
|
onCreatePR={onCreatePR}
|
||||||
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
|
onStartDevServer={handleStartDevServer}
|
||||||
|
onStopDevServer={handleStopDevServer}
|
||||||
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={onCreateWorktree}
|
||||||
|
title="Create new worktree"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={async () => {
|
||||||
|
const removedWorktrees = await fetchWorktrees();
|
||||||
|
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||||
|
onRemovedWorktrees(removedWorktrees);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
title="Refresh worktrees"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
Eye,
|
Eye,
|
||||||
|
Pencil,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useKeyboardShortcuts,
|
useKeyboardShortcuts,
|
||||||
@@ -56,6 +57,8 @@ export function ContextView() {
|
|||||||
const [editedContent, setEditedContent] = useState("");
|
const [editedContent, setEditedContent] = useState("");
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||||
|
const [renameFileName, setRenameFileName] = useState("");
|
||||||
const [newFileName, setNewFileName] = useState("");
|
const [newFileName, setNewFileName] = useState("");
|
||||||
const [newFileType, setNewFileType] = useState<"text" | "image">("text");
|
const [newFileType, setNewFileType] = useState<"text" | "image">("text");
|
||||||
const [uploadedImageData, setUploadedImageData] = useState<string | null>(
|
const [uploadedImageData, setUploadedImageData] = useState<string | null>(
|
||||||
@@ -210,15 +213,19 @@ export function ContextView() {
|
|||||||
await api.writeFile(filePath, newFileContent);
|
await api.writeFile(filePath, newFileContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only reload files on success
|
||||||
|
await loadContextFiles();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to add file:", error);
|
||||||
|
// Optionally show error toast to user here
|
||||||
|
} finally {
|
||||||
|
// Close dialog and reset state
|
||||||
setIsAddDialogOpen(false);
|
setIsAddDialogOpen(false);
|
||||||
setNewFileName("");
|
setNewFileName("");
|
||||||
setNewFileType("text");
|
setNewFileType("text");
|
||||||
setUploadedImageData(null);
|
setUploadedImageData(null);
|
||||||
setNewFileContent("");
|
setNewFileContent("");
|
||||||
setIsDropHovering(false);
|
setIsDropHovering(false);
|
||||||
await loadContextFiles();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to add file:", error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -240,6 +247,60 @@ export function ContextView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Rename selected file
|
||||||
|
const handleRenameFile = async () => {
|
||||||
|
const contextPath = getContextPath();
|
||||||
|
if (!selectedFile || !contextPath || !renameFileName.trim()) return;
|
||||||
|
|
||||||
|
const newName = renameFileName.trim();
|
||||||
|
if (newName === selectedFile.name) {
|
||||||
|
setIsRenameDialogOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const newPath = `${contextPath}/${newName}`;
|
||||||
|
|
||||||
|
// Check if file with new name already exists
|
||||||
|
const exists = await api.exists(newPath);
|
||||||
|
if (exists) {
|
||||||
|
console.error("A file with this name already exists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read current file content
|
||||||
|
const result = await api.readFile(selectedFile.path);
|
||||||
|
if (!result.success || result.content === undefined) {
|
||||||
|
console.error("Failed to read file for rename");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to new path
|
||||||
|
await api.writeFile(newPath, result.content);
|
||||||
|
|
||||||
|
// Delete old file
|
||||||
|
await api.deleteFile(selectedFile.path);
|
||||||
|
|
||||||
|
setIsRenameDialogOpen(false);
|
||||||
|
setRenameFileName("");
|
||||||
|
|
||||||
|
// Reload files and select the renamed file
|
||||||
|
await loadContextFiles();
|
||||||
|
|
||||||
|
// Update selected file with new name and path
|
||||||
|
const renamedFile: ContextFile = {
|
||||||
|
name: newName,
|
||||||
|
type: isImageFile(newName) ? "image" : "text",
|
||||||
|
path: newPath,
|
||||||
|
content: result.content,
|
||||||
|
};
|
||||||
|
setSelectedFile(renamedFile);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to rename file:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle image upload
|
// Handle image upload
|
||||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -418,24 +479,40 @@ export function ContextView() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{contextFiles.map((file) => (
|
{contextFiles.map((file) => (
|
||||||
<button
|
<div
|
||||||
key={file.path}
|
key={file.path}
|
||||||
onClick={() => handleSelectFile(file)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors",
|
"group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors",
|
||||||
selectedFile?.path === file.path
|
selectedFile?.path === file.path
|
||||||
? "bg-primary/20 text-foreground border border-primary/30"
|
? "bg-primary/20 text-foreground border border-primary/30"
|
||||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
data-testid={`context-file-${file.name}`}
|
|
||||||
>
|
>
|
||||||
{file.type === "image" ? (
|
<button
|
||||||
<ImageIcon className="w-4 h-4 flex-shrink-0" />
|
onClick={() => handleSelectFile(file)}
|
||||||
) : (
|
className="flex-1 flex items-center gap-2 text-left min-w-0"
|
||||||
<FileText className="w-4 h-4 flex-shrink-0" />
|
data-testid={`context-file-${file.name}`}
|
||||||
)}
|
>
|
||||||
<span className="truncate text-sm">{file.name}</span>
|
{file.type === "image" ? (
|
||||||
</button>
|
<ImageIcon className="w-4 h-4 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<FileText className="w-4 h-4 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="truncate text-sm">{file.name}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setRenameFileName(file.name);
|
||||||
|
setSelectedFile(file);
|
||||||
|
setIsRenameDialogOpen(true);
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-accent rounded transition-opacity"
|
||||||
|
data-testid={`rename-context-file-${file.name}`}
|
||||||
|
>
|
||||||
|
<Pencil className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -730,6 +807,53 @@ export function ContextView() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Rename Dialog */}
|
||||||
|
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
|
||||||
|
<DialogContent data-testid="rename-context-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Rename Context File</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter a new name for "{selectedFile?.name}".
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="rename-filename">File Name</Label>
|
||||||
|
<Input
|
||||||
|
id="rename-filename"
|
||||||
|
value={renameFileName}
|
||||||
|
onChange={(e) => setRenameFileName(e.target.value)}
|
||||||
|
placeholder="Enter new filename"
|
||||||
|
data-testid="rename-file-input"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && renameFileName.trim()) {
|
||||||
|
handleRenameFile();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setIsRenameDialogOpen(false);
|
||||||
|
setRenameFileName("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleRenameFile}
|
||||||
|
disabled={!renameFileName.trim() || renameFileName === selectedFile?.name}
|
||||||
|
data-testid="confirm-rename-file"
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,3 +46,4 @@ export const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
|
|||||||
{ id: "ultrathink", label: "Ultrathink" },
|
{ id: "ultrathink", label: "Ultrathink" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export function getProviderFromModel(model: AgentModel): ModelProvider {
|
|||||||
return "claude";
|
return "claude";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,28 +2,18 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Key,
|
|
||||||
Palette,
|
|
||||||
Terminal,
|
|
||||||
FlaskConical,
|
|
||||||
Trash2,
|
|
||||||
Settings2,
|
|
||||||
Volume2,
|
|
||||||
VolumeX,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
|
|
||||||
import { useCliStatus } from "./settings-view/hooks/use-cli-status";
|
import { useCliStatus, useSettingsView } from "./settings-view/hooks";
|
||||||
import { useScrollTracking } from "@/hooks/use-scroll-tracking";
|
import { NAV_ITEMS } from "./settings-view/config/navigation";
|
||||||
import { SettingsHeader } from "./settings-view/components/settings-header";
|
import { SettingsHeader } from "./settings-view/components/settings-header";
|
||||||
import { KeyboardMapDialog } from "./settings-view/components/keyboard-map-dialog";
|
import { KeyboardMapDialog } from "./settings-view/components/keyboard-map-dialog";
|
||||||
import { DeleteProjectDialog } from "./settings-view/components/delete-project-dialog";
|
import { DeleteProjectDialog } from "./settings-view/components/delete-project-dialog";
|
||||||
import { SettingsNavigation } from "./settings-view/components/settings-navigation";
|
import { SettingsNavigation } from "./settings-view/components/settings-navigation";
|
||||||
import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section";
|
import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section";
|
||||||
import { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status";
|
import { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status";
|
||||||
|
import { AIEnhancementSection } from "./settings-view/ai-enhancement";
|
||||||
import { AppearanceSection } from "./settings-view/appearance/appearance-section";
|
import { AppearanceSection } from "./settings-view/appearance/appearance-section";
|
||||||
|
import { AudioSection } from "./settings-view/audio/audio-section";
|
||||||
import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section";
|
import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section";
|
||||||
import { FeatureDefaultsSection } from "./settings-view/feature-defaults/feature-defaults-section";
|
import { FeatureDefaultsSection } from "./settings-view/feature-defaults/feature-defaults-section";
|
||||||
import { DangerZoneSection } from "./settings-view/danger-zone/danger-zone-section";
|
import { DangerZoneSection } from "./settings-view/danger-zone/danger-zone-section";
|
||||||
@@ -33,17 +23,6 @@ import type {
|
|||||||
} from "./settings-view/shared/types";
|
} from "./settings-view/shared/types";
|
||||||
import type { Project as ElectronProject } from "@/lib/electron";
|
import type { Project as ElectronProject } from "@/lib/electron";
|
||||||
|
|
||||||
// Navigation items for the side panel
|
|
||||||
const NAV_ITEMS = [
|
|
||||||
{ id: "api-keys", label: "API Keys", icon: Key },
|
|
||||||
{ id: "claude", label: "Claude", icon: Terminal },
|
|
||||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
|
||||||
{ id: "audio", label: "Audio", icon: Volume2 },
|
|
||||||
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
|
|
||||||
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
|
|
||||||
{ id: "danger", label: "Danger Zone", icon: Trash2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SettingsView() {
|
export function SettingsView() {
|
||||||
const {
|
const {
|
||||||
theme,
|
theme,
|
||||||
@@ -51,6 +30,8 @@ export function SettingsView() {
|
|||||||
setProjectTheme,
|
setProjectTheme,
|
||||||
defaultSkipTests,
|
defaultSkipTests,
|
||||||
setDefaultSkipTests,
|
setDefaultSkipTests,
|
||||||
|
enableDependencyBlocking,
|
||||||
|
setEnableDependencyBlocking,
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
setUseWorktrees,
|
setUseWorktrees,
|
||||||
showProfilesOnly,
|
showProfilesOnly,
|
||||||
@@ -59,6 +40,10 @@ export function SettingsView() {
|
|||||||
setMuteDoneSound,
|
setMuteDoneSound,
|
||||||
currentProject,
|
currentProject,
|
||||||
moveProjectToTrash,
|
moveProjectToTrash,
|
||||||
|
defaultPlanningMode,
|
||||||
|
setDefaultPlanningMode,
|
||||||
|
defaultRequirePlanApproval,
|
||||||
|
setDefaultRequirePlanApproval,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
// Convert electron Project to settings-view Project type
|
// Convert electron Project to settings-view Project type
|
||||||
@@ -91,23 +76,78 @@ export function SettingsView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Use CLI status hook
|
// Use CLI status hook
|
||||||
const {
|
const { claudeCliStatus, isCheckingClaudeCli, handleRefreshClaudeCli } =
|
||||||
claudeCliStatus,
|
useCliStatus();
|
||||||
isCheckingClaudeCli,
|
|
||||||
handleRefreshClaudeCli,
|
|
||||||
} = useCliStatus();
|
|
||||||
|
|
||||||
// Use scroll tracking hook
|
// Use settings view navigation hook
|
||||||
const { activeSection, scrollToSection, scrollContainerRef } =
|
const { activeView, navigateTo } = useSettingsView();
|
||||||
useScrollTracking({
|
|
||||||
items: NAV_ITEMS,
|
|
||||||
filterFn: (item) => item.id !== "danger" || !!currentProject,
|
|
||||||
initialSection: "api-keys",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
||||||
|
|
||||||
|
// Render the active section based on current view
|
||||||
|
const renderActiveSection = () => {
|
||||||
|
switch (activeView) {
|
||||||
|
case "claude":
|
||||||
|
return (
|
||||||
|
<ClaudeCliStatus
|
||||||
|
status={claudeCliStatus}
|
||||||
|
isChecking={isCheckingClaudeCli}
|
||||||
|
onRefresh={handleRefreshClaudeCli}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "ai-enhancement":
|
||||||
|
return <AIEnhancementSection />;
|
||||||
|
case "appearance":
|
||||||
|
return (
|
||||||
|
<AppearanceSection
|
||||||
|
effectiveTheme={effectiveTheme}
|
||||||
|
currentProject={settingsProject}
|
||||||
|
onThemeChange={handleSetTheme}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "keyboard":
|
||||||
|
return (
|
||||||
|
<KeyboardShortcutsSection
|
||||||
|
onOpenKeyboardMap={() => setShowKeyboardMapDialog(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "audio":
|
||||||
|
return (
|
||||||
|
<AudioSection
|
||||||
|
muteDoneSound={muteDoneSound}
|
||||||
|
onMuteDoneSoundChange={setMuteDoneSound}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "defaults":
|
||||||
|
return (
|
||||||
|
<FeatureDefaultsSection
|
||||||
|
showProfilesOnly={showProfilesOnly}
|
||||||
|
defaultSkipTests={defaultSkipTests}
|
||||||
|
enableDependencyBlocking={enableDependencyBlocking}
|
||||||
|
useWorktrees={useWorktrees}
|
||||||
|
defaultPlanningMode={defaultPlanningMode}
|
||||||
|
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
||||||
|
onShowProfilesOnlyChange={setShowProfilesOnly}
|
||||||
|
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||||
|
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||||
|
onUseWorktreesChange={setUseWorktrees}
|
||||||
|
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
||||||
|
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "danger":
|
||||||
|
return (
|
||||||
|
<DangerZoneSection
|
||||||
|
project={settingsProject}
|
||||||
|
onDeleteClick={() => setShowDeleteDialog(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <ApiKeysSection />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||||
@@ -118,107 +158,17 @@ export function SettingsView() {
|
|||||||
|
|
||||||
{/* Content Area with Sidebar */}
|
{/* Content Area with Sidebar */}
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* Sticky Side Navigation */}
|
{/* Side Navigation - No longer scrolls, just switches views */}
|
||||||
<SettingsNavigation
|
<SettingsNavigation
|
||||||
navItems={NAV_ITEMS}
|
navItems={NAV_ITEMS}
|
||||||
activeSection={activeSection}
|
activeSection={activeView}
|
||||||
currentProject={currentProject}
|
currentProject={currentProject}
|
||||||
onNavigate={scrollToSection}
|
onNavigate={navigateTo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Scrollable Content */}
|
{/* Content Panel - Shows only the active section */}
|
||||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-8">
|
<div className="flex-1 overflow-y-auto p-8">
|
||||||
<div className="max-w-4xl mx-auto space-y-6 pb-96">
|
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
|
||||||
{/* API Keys Section */}
|
|
||||||
<ApiKeysSection />
|
|
||||||
|
|
||||||
{/* Claude CLI Status Section */}
|
|
||||||
{claudeCliStatus && (
|
|
||||||
<ClaudeCliStatus
|
|
||||||
status={claudeCliStatus}
|
|
||||||
isChecking={isCheckingClaudeCli}
|
|
||||||
onRefresh={handleRefreshClaudeCli}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Appearance Section */}
|
|
||||||
<AppearanceSection
|
|
||||||
effectiveTheme={effectiveTheme}
|
|
||||||
currentProject={settingsProject}
|
|
||||||
onThemeChange={handleSetTheme}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Keyboard Shortcuts Section */}
|
|
||||||
<KeyboardShortcutsSection
|
|
||||||
onOpenKeyboardMap={() => setShowKeyboardMapDialog(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Audio Section */}
|
|
||||||
<div
|
|
||||||
id="audio"
|
|
||||||
className="rounded-2xl border border-border/50 bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl shadow-sm shadow-black/5 overflow-hidden scroll-mt-6"
|
|
||||||
>
|
|
||||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
|
||||||
<Volume2 className="w-5 h-5 text-brand-500" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
|
||||||
Audio
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
|
||||||
Configure audio and notification settings.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 space-y-4">
|
|
||||||
{/* Mute Done Sound Setting */}
|
|
||||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
|
||||||
<Checkbox
|
|
||||||
id="mute-done-sound"
|
|
||||||
checked={muteDoneSound}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setMuteDoneSound(checked === true)
|
|
||||||
}
|
|
||||||
className="mt-1"
|
|
||||||
data-testid="mute-done-sound-checkbox"
|
|
||||||
/>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label
|
|
||||||
htmlFor="mute-done-sound"
|
|
||||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<VolumeX className="w-4 h-4 text-brand-500" />
|
|
||||||
Mute notification sound when agents complete
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
|
||||||
When enabled, disables the "ding" sound that
|
|
||||||
plays when an agent completes a feature. The feature
|
|
||||||
will still move to the completed column, but without
|
|
||||||
audio notification.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feature Defaults Section */}
|
|
||||||
<FeatureDefaultsSection
|
|
||||||
showProfilesOnly={showProfilesOnly}
|
|
||||||
defaultSkipTests={defaultSkipTests}
|
|
||||||
useWorktrees={useWorktrees}
|
|
||||||
onShowProfilesOnlyChange={setShowProfilesOnly}
|
|
||||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
|
||||||
onUseWorktreesChange={setUseWorktrees}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Danger Zone Section - Only show when a project is selected */}
|
|
||||||
<DangerZoneSection
|
|
||||||
project={settingsProject}
|
|
||||||
onDeleteClick={() => setShowDeleteDialog(true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Sparkles } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { CLAUDE_MODELS } from "@/components/views/board-view/shared/model-constants";
|
||||||
|
|
||||||
|
export function AIEnhancementSection() {
|
||||||
|
const { enhancementModel, setEnhancementModel } = useAppStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-2xl overflow-hidden",
|
||||||
|
"border border-border/50",
|
||||||
|
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
|
||||||
|
"shadow-sm shadow-black/5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||||
|
<Sparkles className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">AI Enhancement</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
|
Choose the model used when enhancing feature descriptions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label className="text-foreground font-medium">
|
||||||
|
Enhancement Model
|
||||||
|
</Label>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
{CLAUDE_MODELS.map(({ id, label, description, badge }) => {
|
||||||
|
const isActive = enhancementModel === id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setEnhancementModel(id)}
|
||||||
|
className={cn(
|
||||||
|
"group flex flex-col items-start gap-2 px-4 py-4 rounded-xl text-left",
|
||||||
|
"transition-all duration-200 ease-out",
|
||||||
|
isActive
|
||||||
|
? [
|
||||||
|
"bg-gradient-to-br from-brand-500/15 to-brand-600/10",
|
||||||
|
"border-2 border-brand-500/40",
|
||||||
|
"text-foreground",
|
||||||
|
"shadow-md shadow-brand-500/10",
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
"bg-accent/30 hover:bg-accent/50",
|
||||||
|
"border border-border/50 hover:border-border",
|
||||||
|
"text-muted-foreground hover:text-foreground",
|
||||||
|
"hover:shadow-sm",
|
||||||
|
],
|
||||||
|
"hover:scale-[1.02] active:scale-[0.98]"
|
||||||
|
)}
|
||||||
|
data-testid={`enhancement-model-${id}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<span className={cn(
|
||||||
|
"font-medium text-sm",
|
||||||
|
isActive ? "text-foreground" : "group-hover:text-foreground"
|
||||||
|
)}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{badge && (
|
||||||
|
<span className={cn(
|
||||||
|
"ml-auto text-xs px-2 py-0.5 rounded-full",
|
||||||
|
isActive
|
||||||
|
? "bg-brand-500/20 text-brand-500"
|
||||||
|
: "bg-accent text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground/80">
|
||||||
|
{description}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AIEnhancementSection } from "./ai-enhancement-section";
|
||||||
@@ -58,9 +58,8 @@ export function ApiKeysSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id="api-keys"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-2xl overflow-hidden scroll-mt-6",
|
"rounded-2xl overflow-hidden",
|
||||||
"border border-border/50",
|
"border border-border/50",
|
||||||
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
|
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
|
||||||
"shadow-sm shadow-black/5"
|
"shadow-sm shadow-black/5"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user