mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
Compare commits
1 Commits
v0.5.0
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b8b14b714 |
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"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,9 +24,6 @@ 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,75 +6,10 @@ 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
|
|
||||||
|
|||||||
@@ -1,310 +0,0 @@
|
|||||||
# 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
Normal file
BIN
apps/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -45,11 +45,8 @@
|
|||||||
"@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,17 +1,14 @@
|
|||||||
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: undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
reporter: "html",
|
reporter: "html",
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
use: {
|
use: {
|
||||||
@@ -28,33 +25,15 @@ export default defineConfig({
|
|||||||
...(reuseServer
|
...(reuseServer
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
webServer: [
|
webServer: {
|
||||||
// Backend server - runs with mock agent enabled in CI
|
command: `npx next dev -p ${port}`,
|
||||||
{
|
url: `http://localhost:${port}`,
|
||||||
command: `cd ../server && npm run dev`,
|
reuseExistingServer: !process.env.CI,
|
||||||
url: `http://localhost:${serverPort}/api/health`,
|
timeout: 120000,
|
||||||
reuseExistingServer: true,
|
env: {
|
||||||
timeout: 60000,
|
...process.env,
|
||||||
env: {
|
NEXT_PUBLIC_SKIP_SETUP: "true",
|
||||||
...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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
30
apps/app/playwright.config.ts.bak
Normal file
30
apps/app/playwright.config.ts.bak
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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,9 +13,6 @@
|
|||||||
@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);
|
||||||
@@ -1223,252 +1220,6 @@
|
|||||||
--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;
|
||||||
@@ -1504,12 +1255,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar for dark themes */
|
/* Custom scrollbar for dark themes */
|
||||||
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red, .sunset, .gray) ::-webkit-scrollbar {
|
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red) ::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red, .sunset, .gray) ::-webkit-scrollbar-track {
|
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red) ::-webkit-scrollbar-track {
|
||||||
background: var(--muted);
|
background: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1545,62 +1296,6 @@
|
|||||||
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;
|
||||||
const themeClasses = [
|
root.classList.remove(
|
||||||
"dark",
|
"dark",
|
||||||
"light",
|
|
||||||
"retro",
|
"retro",
|
||||||
|
"light",
|
||||||
"dracula",
|
"dracula",
|
||||||
"nord",
|
"nord",
|
||||||
"monokai",
|
"monokai",
|
||||||
@@ -146,22 +146,43 @@ function HomeContent() {
|
|||||||
"catppuccin",
|
"catppuccin",
|
||||||
"onedark",
|
"onedark",
|
||||||
"synthwave",
|
"synthwave",
|
||||||
"red",
|
"red"
|
||||||
"cream",
|
);
|
||||||
"sunset",
|
|
||||||
"gray",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Remove all theme classes
|
if (effectiveTheme === "dark") {
|
||||||
root.classList.remove(...themeClasses);
|
root.classList.add("dark");
|
||||||
|
} else if (effectiveTheme === "retro") {
|
||||||
// Apply the effective theme
|
root.classList.add("retro");
|
||||||
if (themeClasses.includes(effectiveTheme)) {
|
} else if (effectiveTheme === "dracula") {
|
||||||
root.classList.add(effectiveTheme);
|
root.classList.add("dracula");
|
||||||
|
} 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 - detect OS preference
|
// System theme
|
||||||
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
root.classList.add(isDark ? "dark" : "light");
|
if (isDark) {
|
||||||
|
root.classList.add("dark");
|
||||||
|
} else {
|
||||||
|
root.classList.add("light");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [effectiveTheme, previewTheme, currentProject, theme]);
|
}, [effectiveTheme, previewTheme, currentProject, theme]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
"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, useCallback } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -9,8 +9,6 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
CornerDownLeft,
|
CornerDownLeft,
|
||||||
Clock,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -47,44 +45,6 @@ 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,
|
||||||
@@ -101,26 +61,8 @@ 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("");
|
||||||
@@ -211,34 +153,27 @@ 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-3xl max-h-[85vh] overflow-hidden flex flex-col p-4">
|
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
<DialogHeader className="pb-1">
|
<DialogHeader className="pb-2">
|
||||||
<DialogTitle className="flex items-center gap-2 text-base">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<FolderOpen className="w-4 h-4 text-brand-500" />
|
<FolderOpen className="w-5 h-5 text-brand-500" />
|
||||||
{title}
|
{title}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-muted-foreground text-xs">
|
<DialogDescription className="text-muted-foreground">
|
||||||
{description}
|
{description}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 min-h-[350px] flex-1 overflow-hidden py-1">
|
<div className="flex flex-col gap-3 min-h-[400px] flex-1 overflow-hidden py-2">
|
||||||
{/* Direct path input */}
|
{/* Direct path input */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
ref={pathInputRef}
|
ref={pathInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -246,7 +181,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-xs h-8"
|
className="flex-1 font-mono text-sm"
|
||||||
data-testid="path-input"
|
data-testid="path-input"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
@@ -256,46 +191,16 @@ 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-3.5 h-3.5 mr-1" />
|
<CornerDownLeft className="w-4 h-4 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-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
|
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-1">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-2">
|
||||||
<HardDrive className="w-3 h-3" />
|
<HardDrive className="w-3 h-3" />
|
||||||
<span>Drives:</span>
|
<span>Drives:</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -307,7 +212,7 @@ export function FileBrowserDialog({
|
|||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleSelectDrive(drive)}
|
onClick={() => handleSelectDrive(drive)}
|
||||||
className="h-6 px-2 text-xs"
|
className="h-7 px-3 text-xs"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{drive.replace("\\", "")}
|
{drive.replace("\\", "")}
|
||||||
@@ -317,57 +222,57 @@ export function FileBrowserDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Current path breadcrumb */}
|
{/* Current path breadcrumb */}
|
||||||
<div className="flex items-center gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleGoHome}
|
onClick={handleGoHome}
|
||||||
className="h-6 px-1.5"
|
className="h-7 px-2"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<Home className="w-3.5 h-3.5" />
|
<Home className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{parentPath && (
|
{parentPath && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleGoToParent}
|
onClick={handleGoToParent}
|
||||||
className="h-6 px-1.5"
|
className="h-7 px-2"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-3.5 h-3.5" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 font-mono text-xs truncate text-muted-foreground">
|
<div className="flex-1 font-mono text-sm 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-md">
|
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-lg">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center h-full p-4">
|
<div className="flex items-center justify-center h-full p-8">
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Loading directories...
|
Loading directories...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center justify-center h-full p-4">
|
<div className="flex items-center justify-center h-full p-8">
|
||||||
<div className="text-xs text-destructive">{error}</div>
|
<div className="text-sm text-destructive">{error}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{warning && (
|
{warning && (
|
||||||
<div className="p-2 bg-yellow-500/10 border border-yellow-500/30 rounded-md mb-1">
|
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg mb-2">
|
||||||
<div className="text-xs text-yellow-500">{warning}</div>
|
<div className="text-sm 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-4">
|
<div className="flex items-center justify-center h-full p-8">
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
No subdirectories found
|
No subdirectories found
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -379,29 +284,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-2 px-2 py-1.5 hover:bg-sidebar-accent/10 transition-colors text-left group"
|
className="w-full flex items-center gap-3 p-3 hover:bg-sidebar-accent/10 transition-colors text-left group"
|
||||||
>
|
>
|
||||||
<Folder className="w-4 h-4 text-brand-500 shrink-0" />
|
<Folder className="w-5 h-5 text-brand-500 shrink-0" />
|
||||||
<span className="flex-1 truncate text-xs">{dir.name}</span>
|
<span className="flex-1 truncate text-sm">{dir.name}</span>
|
||||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[10px] text-muted-foreground">
|
<div className="text-xs 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-3 gap-2 mt-1">
|
<DialogFooter className="border-t border-border pt-4 gap-2">
|
||||||
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={handleSelect} disabled={!currentPath || loading}>
|
<Button onClick={handleSelect} disabled={!currentPath || loading}>
|
||||||
<FolderOpen className="w-3.5 h-3.5 mr-1.5" />
|
<FolderOpen className="w-4 h-4 mr-2" />
|
||||||
Select Current Folder
|
Select Current Folder
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import {
|
||||||
|
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";
|
||||||
@@ -22,7 +27,6 @@ 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 = [
|
||||||
@@ -111,10 +115,7 @@ export function SessionManager({
|
|||||||
new Set()
|
new Set()
|
||||||
);
|
);
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [sessionToDelete, setSessionToDelete] =
|
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
|
||||||
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[]) => {
|
||||||
@@ -231,7 +232,11 @@ 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(sessionId, editingName, undefined);
|
const result = await api.sessions.update(
|
||||||
|
sessionId,
|
||||||
|
editingName,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setEditingSessionId(null);
|
setEditingSessionId(null);
|
||||||
@@ -309,20 +314,6 @@ 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 =
|
||||||
@@ -411,22 +402,6 @@ 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
|
||||||
@@ -599,14 +574,6 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
"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,7 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Autocomplete } from "@/components/ui/autocomplete";
|
import { Check, ChevronsUpDown } 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";
|
||||||
|
|
||||||
interface CategoryAutocompleteProps {
|
interface CategoryAutocompleteProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -22,18 +38,54 @@ export function CategoryAutocomplete({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
"data-testid": testId,
|
"data-testid": testId,
|
||||||
}: CategoryAutocompleteProps) {
|
}: CategoryAutocompleteProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Autocomplete
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
value={value}
|
<PopoverTrigger asChild>
|
||||||
onChange={onChange}
|
<Button
|
||||||
options={suggestions}
|
variant="outline"
|
||||||
placeholder={placeholder}
|
role="combobox"
|
||||||
searchPlaceholder="Search category..."
|
aria-expanded={open}
|
||||||
emptyMessage="No category found."
|
disabled={disabled}
|
||||||
className={className}
|
className={cn("w-full justify-between", className)}
|
||||||
disabled={disabled}
|
data-testid={testId}
|
||||||
data-testid={testId}
|
>
|
||||||
itemTestIdPrefix="category-option"
|
{value
|
||||||
/>
|
? 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,23 +13,9 @@ 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, children: _children, ...props }, ref) => (
|
({ className, onCheckedChange, ...props }, ref) => (
|
||||||
<CheckboxRoot
|
<CheckboxPrimitive.Root
|
||||||
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",
|
||||||
@@ -43,12 +29,12 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
|||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxIndicator
|
<CheckboxPrimitive.Indicator
|
||||||
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" />
|
||||||
</CheckboxIndicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxRoot>
|
</CheckboxPrimitive.Root>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||||
|
|||||||
@@ -268,52 +268,6 @@ 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 */}
|
||||||
@@ -359,7 +313,6 @@ 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}
|
||||||
@@ -373,7 +326,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">
|
||||||
Paste, drag and drop images, or{" "}
|
Drag and drop images here or{" "}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleBrowseClick}
|
onClick={handleBrowseClick}
|
||||||
|
|||||||
@@ -6,36 +6,6 @@ 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>) {
|
||||||
@@ -60,20 +30,12 @@ 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 (
|
||||||
<DialogOverlayPrimitive
|
<DialogPrimitive.Overlay
|
||||||
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",
|
||||||
@@ -87,18 +49,16 @@ function DialogOverlay({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DialogContentProps = Omit<
|
function DialogContent({
|
||||||
React.ComponentProps<typeof DialogPrimitive.Content>,
|
className,
|
||||||
"ref"
|
children,
|
||||||
> & {
|
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-");
|
||||||
@@ -106,8 +66,7 @@ const DialogContent = React.forwardRef<
|
|||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogContentPrimitive
|
<DialogPrimitive.Content
|
||||||
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%]",
|
||||||
@@ -132,7 +91,7 @@ const DialogContent = React.forwardRef<
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<DialogClosePrimitive
|
<DialogPrimitive.Close
|
||||||
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",
|
||||||
@@ -146,14 +105,12 @@ const DialogContent = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogClosePrimitive>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</DialogContentPrimitive>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
DialogContent.displayName = "DialogContent";
|
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
@@ -180,42 +137,27 @@ 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 (
|
||||||
<DialogTitlePrimitive
|
<DialogPrimitive.Title
|
||||||
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 (
|
||||||
<DialogDescriptionPrimitive
|
<DialogPrimitive.Description
|
||||||
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,83 +6,9 @@ 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
|
||||||
|
|
||||||
function DropdownMenuTrigger({
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
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
|
||||||
|
|
||||||
@@ -90,26 +16,15 @@ const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
|||||||
|
|
||||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
function DropdownMenuRadioGroup({
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
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) => (
|
||||||
<DropdownMenuSubTriggerPrimitive
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
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",
|
||||||
@@ -120,15 +35,13 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
</DropdownMenuSubTriggerPrimitive>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
))
|
))
|
||||||
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
|
||||||
@@ -145,9 +58,7 @@ 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
|
||||||
@@ -167,10 +78,9 @@ 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
|
}
|
||||||
} & React.HTMLAttributes<HTMLDivElement>
|
>(({ className, inset, ...props }, ref) => (
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
<DropdownMenuPrimitive.Item
|
||||||
<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",
|
||||||
@@ -178,20 +88,15 @@ 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) => (
|
||||||
<DropdownMenuCheckboxItemPrimitive
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
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",
|
||||||
@@ -201,23 +106,21 @@ 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">
|
||||||
<DropdownMenuItemIndicatorPrimitive>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
</DropdownMenuItemIndicatorPrimitive>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuCheckboxItemPrimitive>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
))
|
))
|
||||||
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) => (
|
||||||
<DropdownMenuRadioItemPrimitive
|
<DropdownMenuPrimitive.RadioItem
|
||||||
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",
|
||||||
@@ -226,12 +129,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">
|
||||||
<DropdownMenuItemIndicatorPrimitive>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<Circle className="h-2 w-2 fill-current" />
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
</DropdownMenuItemIndicatorPrimitive>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuRadioItemPrimitive>
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
))
|
))
|
||||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
@@ -239,11 +142,9 @@ 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, children, ...props }, ref) => (
|
>(({ className, inset, ...props }, ref) => (
|
||||||
<DropdownMenuLabelPrimitive
|
<DropdownMenuPrimitive.Label
|
||||||
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",
|
||||||
@@ -251,19 +152,15 @@ 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) => (
|
||||||
<DropdownMenuSeparatorPrimitive
|
<DropdownMenuPrimitive.Separator
|
||||||
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,41 +620,6 @@ 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,16 +244,14 @@ 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>
|
||||||
{image.size !== undefined && (
|
<p className="text-xs text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground">
|
{formatFileSize(image.size)}
|
||||||
{formatFileSize(image.size)}
|
</p>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* Remove button */}
|
{/* Remove button */}
|
||||||
{!disabled && image.id && (
|
{!disabled && (
|
||||||
<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, useEffect, useRef } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -14,26 +14,13 @@ 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 {
|
||||||
@@ -66,160 +53,6 @@ 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;
|
||||||
@@ -230,54 +63,9 @@ 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(() => {
|
||||||
let content = entry.content;
|
const 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;
|
||||||
@@ -315,20 +103,14 @@ 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.title, isToolCall]);
|
}, [entry.content]);
|
||||||
|
|
||||||
// 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",
|
||||||
bgColor,
|
colors.bg,
|
||||||
borderColor,
|
colors.border,
|
||||||
"hover:brightness-110"
|
"hover:brightness-110"
|
||||||
)}
|
)}
|
||||||
data-testid={`log-entry-${entry.type}`}
|
data-testid={`log-entry-${entry.type}`}
|
||||||
@@ -348,14 +130,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", isToolCall ? toolCategoryColors.split(" ")[0] : colors.icon)}>
|
<span className={cn("flex-shrink-0", colors.icon)}>
|
||||||
{icon}
|
{getLogIcon(entry.type)}
|
||||||
</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",
|
||||||
isToolCall ? toolCategoryColors : colors.badge
|
colors.badge
|
||||||
)}
|
)}
|
||||||
data-testid="log-entry-badge"
|
data-testid="log-entry-badge"
|
||||||
>
|
>
|
||||||
@@ -363,7 +145,9 @@ 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">
|
||||||
{collapsedPreview}
|
{!isExpanded &&
|
||||||
|
entry.content.slice(0, 80) +
|
||||||
|
(entry.content.length > 80 ? "..." : "")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -372,140 +156,36 @@ 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}`}
|
||||||
>
|
>
|
||||||
{/* Render TodoWrite entries with special formatting */}
|
<div className="font-mono text-xs space-y-1">
|
||||||
{parsedTodos ? (
|
{formattedContent.map((part, index) => (
|
||||||
<TodoListRenderer todos={parsedTodos} />
|
<div key={index}>
|
||||||
) : (
|
{part.type === "json" ? (
|
||||||
<div className="font-mono text-xs space-y-1">
|
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto text-xs text-primary">
|
||||||
{formattedContent.map((part, index) => (
|
{part.content}
|
||||||
<div key={index}>
|
</pre>
|
||||||
{part.type === "json" ? (
|
) : (
|
||||||
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto text-xs text-primary">
|
<pre
|
||||||
{part.content}
|
className={cn(
|
||||||
</pre>
|
"whitespace-pre-wrap break-words",
|
||||||
) : (
|
colors.text
|
||||||
<pre
|
)}
|
||||||
className={cn(
|
>
|
||||||
"whitespace-pre-wrap break-words",
|
{part.content}
|
||||||
textColor
|
</pre>
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
{part.content}
|
))}
|
||||||
</pre>
|
</div>
|
||||||
)}
|
|
||||||
</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());
|
|
||||||
|
|
||||||
// Parse entries and compute initial expanded state together
|
const entries = useMemo(() => parseLogOutput(output), [output]);
|
||||||
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) => {
|
||||||
@@ -520,45 +200,13 @@ export function LogViewer({ output, className }: LogViewerProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const expandAll = () => {
|
const expandAll = () => {
|
||||||
setExpandedIds(new Set(filteredEntries.map((e) => e.id)));
|
setExpandedIds(new Set(entries.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">
|
||||||
@@ -581,123 +229,28 @@ 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", className)}>
|
<div className={cn("flex flex-col gap-2", className)}>
|
||||||
{/* Sticky header with search, stats, and filters */}
|
{/* Header with controls */}
|
||||||
{/* 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-1 flex-wrap">
|
<div className="flex items-center gap-2 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 (
|
||||||
<button
|
<span
|
||||||
key={type}
|
key={type}
|
||||||
onClick={() => toggleTypeFilter(type as LogEntryType)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs px-2 py-0.5 rounded-full transition-all",
|
"text-xs px-2 py-0.5 rounded-full",
|
||||||
colors.badge,
|
colors.badge
|
||||||
isHidden && "opacity-40 line-through"
|
|
||||||
)}
|
)}
|
||||||
title={isHidden ? `Show ${type}` : `Hide ${type}`}
|
data-testid={`log-type-count-${type}`}
|
||||||
data-testid={`log-type-filter-${type}`}
|
|
||||||
>
|
>
|
||||||
{type}: {count}
|
{type}: {count}
|
||||||
</button>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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"
|
||||||
@@ -714,32 +267,17 @@ export function LogViewer({ output, className }: LogViewerProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Log entries */}
|
{/* Log entries */}
|
||||||
<div className="space-y-2 mt-2" data-testid="log-entries-container">
|
<div className="space-y-2" data-testid="log-entries-container">
|
||||||
{filteredEntries.length === 0 ? (
|
{entries.map((entry) => (
|
||||||
<div className="text-center py-4 text-zinc-500 text-sm">
|
<LogEntryItem
|
||||||
No entries match your filters.
|
key={entry.id}
|
||||||
{hasActiveFilters && (
|
entry={entry}
|
||||||
<button
|
isExpanded={expandedIds.has(entry.id)}
|
||||||
onClick={clearFilters}
|
onToggle={() => toggleEntry(entry.id)}
|
||||||
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,20 +5,6 @@ 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>) {
|
||||||
@@ -26,18 +12,9 @@ function Popover({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PopoverTrigger({
|
function PopoverTrigger({
|
||||||
children,
|
|
||||||
asChild,
|
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger> & {
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
children?: React.ReactNode;
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
asChild?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<PopoverTriggerPrimitive data-slot="popover-trigger" asChild={asChild} {...props}>
|
|
||||||
{children}
|
|
||||||
</PopoverTriggerPrimitive>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverContent({
|
function PopoverContent({
|
||||||
@@ -45,12 +22,10 @@ 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>
|
||||||
<PopoverContentPrimitive
|
<PopoverPrimitive.Content
|
||||||
data-slot="popover-content"
|
data-slot="popover-content"
|
||||||
align={align}
|
align={align}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
"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 };
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
"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,33 +4,6 @@ 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[];
|
||||||
@@ -48,7 +21,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) => (
|
||||||
<SliderRootPrimitive
|
<SliderPrimitive.Root
|
||||||
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",
|
||||||
@@ -56,11 +29,11 @@ const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
|
<SliderPrimitive.Track className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
|
||||||
<SliderRangePrimitive className="slider-range absolute h-full bg-primary" />
|
<SliderPrimitive.Range className="slider-range absolute h-full bg-primary" />
|
||||||
</SliderTrackPrimitive>
|
</SliderPrimitive.Track>
|
||||||
<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.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" />
|
||||||
</SliderRootPrimitive>
|
</SliderPrimitive.Root>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
"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,86 +5,41 @@ 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 (
|
||||||
<TabsRootPrimitive
|
<TabsPrimitive.Root
|
||||||
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 (
|
||||||
<TabsListPrimitive
|
<TabsPrimitive.List
|
||||||
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 (
|
||||||
<TabsTriggerPrimitive
|
<TabsPrimitive.Trigger
|
||||||
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",
|
||||||
@@ -96,28 +51,20 @@ 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 (
|
||||||
<TabsContentPrimitive
|
<TabsPrimitive.Content
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,274 +0,0 @@
|
|||||||
"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,47 +5,18 @@ 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
|
||||||
|
|
||||||
function TooltipTrigger({
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
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>
|
||||||
<TooltipContentPrimitive
|
<TooltipPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ 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";
|
||||||
|
|
||||||
@@ -100,6 +99,24 @@ 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;
|
||||||
@@ -125,7 +142,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}\n` : ""
|
toolInput ? `Input: ${toolInput}` : ""
|
||||||
}`;
|
}`;
|
||||||
break;
|
break;
|
||||||
case "auto_mode_phase":
|
case "auto_mode_phase":
|
||||||
@@ -170,64 +187,6 @@ 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`;
|
||||||
@@ -243,8 +202,11 @@ export function AgentOutputModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newContent) {
|
if (newContent) {
|
||||||
// Only update local state - server is the single source of truth for file writes
|
setOutput((prev) => {
|
||||||
setOutput((prev) => prev + newContent);
|
const updated = prev + newContent;
|
||||||
|
saveOutput(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -347,13 +309,6 @@ 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 ? (
|
||||||
@@ -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, type AgentModel } from "@/store/app-store";
|
import { useAppStore } 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,8 +17,6 @@ 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";
|
||||||
@@ -30,17 +28,9 @@ 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 } =
|
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
|
||||||
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[]>([]);
|
||||||
@@ -49,7 +39,6 @@ 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);
|
||||||
@@ -75,7 +64,6 @@ 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);
|
||||||
@@ -83,16 +71,13 @@ export function AgentView() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle session selection with persistence
|
// Handle session selection with persistence
|
||||||
const handleSelectSession = useCallback(
|
const handleSelectSession = useCallback((sessionId: string | null) => {
|
||||||
(sessionId: string | null) => {
|
setCurrentSessionId(sessionId);
|
||||||
setCurrentSessionId(sessionId);
|
// Persist the selection for this project
|
||||||
// Persist the selection for this project
|
if (currentProject?.path) {
|
||||||
if (currentProject?.path) {
|
setLastSelectedSession(currentProject.path, sessionId);
|
||||||
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(() => {
|
||||||
@@ -109,10 +94,7 @@ export function AgentView() {
|
|||||||
|
|
||||||
const lastSessionId = getLastSelectedSession(currentProject.path);
|
const lastSessionId = getLastSelectedSession(currentProject.path);
|
||||||
if (lastSessionId) {
|
if (lastSessionId) {
|
||||||
console.log(
|
console.log("[AgentView] Restoring last selected session:", lastSessionId);
|
||||||
"[AgentView] Restoring last selected session:",
|
|
||||||
lastSessionId
|
|
||||||
);
|
|
||||||
setCurrentSessionId(lastSessionId);
|
setCurrentSessionId(lastSessionId);
|
||||||
}
|
}
|
||||||
}, [currentProject?.path, getLastSelectedSession]);
|
}, [currentProject?.path, getLastSelectedSession]);
|
||||||
@@ -435,9 +417,7 @@ 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">
|
<h2 className="text-xl font-semibold mb-3 text-foreground">No Project Selected</h2>
|
||||||
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>
|
||||||
@@ -499,9 +479,7 @@ 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">
|
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
|
||||||
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..."}
|
||||||
@@ -511,43 +489,6 @@ 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" />
|
||||||
@@ -555,9 +496,7 @@ export function AgentView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{agentError && (
|
{agentError && (
|
||||||
<span className="text-xs text-destructive font-medium">
|
<span className="text-xs text-destructive font-medium">{agentError}</span>
|
||||||
{agentError}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{currentSessionId && messages.length > 0 && (
|
{currentSessionId && messages.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
@@ -649,50 +588,6 @@ 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",
|
||||||
@@ -719,18 +614,9 @@ 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
|
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" style={{ animationDelay: "0ms" }} />
|
||||||
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: "150ms" }} />
|
||||||
style={{ animationDelay: "0ms" }}
|
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" style={{ animationDelay: "300ms" }} />
|
||||||
/>
|
|
||||||
<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...
|
||||||
@@ -791,22 +677,18 @@ 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>
|
||||||
{image.size !== undefined && (
|
<p className="text-[10px] text-muted-foreground">
|
||||||
<p className="text-[10px] text-muted-foreground">
|
{formatFileSize(image.size)}
|
||||||
{formatFileSize(image.size)}
|
</p>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* Remove button */}
|
{/* Remove button */}
|
||||||
{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/10 text-muted-foreground hover:text-destructive"
|
||||||
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}
|
||||||
disabled={isProcessing}
|
>
|
||||||
>
|
<X className="h-3 w-3" />
|
||||||
<X className="h-3 w-3" />
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -847,8 +729,7 @@ 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} image{selectedImages.length > 1 ? "s" : ""}
|
||||||
{selectedImages.length > 1 ? "s" : ""}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isDragOver && (
|
{isDragOver && (
|
||||||
@@ -867,8 +748,7 @@ 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 &&
|
showImageDropZone && "bg-primary/10 text-primary border-primary/30",
|
||||||
"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"
|
||||||
@@ -893,11 +773,7 @@ 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{" "}
|
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to send
|
||||||
<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
@@ -1,137 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { KanbanCard } from "./kanban-card";
|
|
||||||
export { KanbanColumn } from "./kanban-column";
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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)]",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,544 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,551 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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";
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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";
|
|
||||||
@@ -1,896 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
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]);
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
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";
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { BranchSwitchDropdown } from "./branch-switch-dropdown";
|
|
||||||
export { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
|
|
||||||
export { WorktreeTab } from "./worktree-tab";
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
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";
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
"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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
"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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
"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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export { WorktreePanel } from "./worktree-panel";
|
|
||||||
export type {
|
|
||||||
WorktreeInfo,
|
|
||||||
BranchInfo,
|
|
||||||
DevServerInfo,
|
|
||||||
FeatureInfo,
|
|
||||||
WorktreePanelProps,
|
|
||||||
} from "./types";
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
"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,7 +19,6 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
Eye,
|
Eye,
|
||||||
Pencil,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useKeyboardShortcuts,
|
useKeyboardShortcuts,
|
||||||
@@ -57,8 +56,6 @@ 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>(
|
||||||
@@ -213,19 +210,15 @@ 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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -247,60 +240,6 @@ 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];
|
||||||
@@ -479,40 +418,24 @@ export function ContextView() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{contextFiles.map((file) => (
|
{contextFiles.map((file) => (
|
||||||
<div
|
<button
|
||||||
key={file.path}
|
key={file.path}
|
||||||
|
onClick={() => handleSelectFile(file)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors",
|
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left 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}`}
|
||||||
>
|
>
|
||||||
<button
|
{file.type === "image" ? (
|
||||||
onClick={() => handleSelectFile(file)}
|
<ImageIcon className="w-4 h-4 flex-shrink-0" />
|
||||||
className="flex-1 flex items-center gap-2 text-left min-w-0"
|
) : (
|
||||||
data-testid={`context-file-${file.name}`}
|
<FileText className="w-4 h-4 flex-shrink-0" />
|
||||||
>
|
)}
|
||||||
{file.type === "image" ? (
|
<span className="truncate text-sm">{file.name}</span>
|
||||||
<ImageIcon className="w-4 h-4 flex-shrink-0" />
|
</button>
|
||||||
) : (
|
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
@@ -807,53 +730,6 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,7 +239,6 @@ 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
|
||||||
@@ -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,18 +146,13 @@ 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, enableDependencyBlocking, features, useWorktrees } = useAppStore();
|
const { kanbanCardDetailLevel } = useAppStore();
|
||||||
|
|
||||||
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
const hasWorktree = !!feature.branchName;
|
||||||
const blockingDependencies = useMemo(() => {
|
|
||||||
if (!enableDependencyBlocking || feature.status !== "backlog") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return getBlockingDependencies(feature, features);
|
|
||||||
}, [enableDependencyBlocking, feature, features]);
|
|
||||||
|
|
||||||
const showSteps =
|
const showSteps =
|
||||||
kanbanCardDetailLevel === "standard" ||
|
kanbanCardDetailLevel === "standard" ||
|
||||||
@@ -261,7 +256,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.status === "in_progress" && !isCurrentAutoTask);
|
(feature.skipTests && !isCurrentAutoTask);
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
@@ -335,67 +330,23 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Priority badge */}
|
{/* Skip Tests (Manual) indicator badge */}
|
||||||
{feature.priority && (
|
{feature.skipTests && !feature.error && (
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
|
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
|
||||||
"top-2 left-2 min-w-[36px]",
|
"top-2 left-2",
|
||||||
feature.priority === 1 &&
|
"bg-[var(--status-warning-bg)] border border-[var(--status-warning)]/40 text-[var(--status-warning)]"
|
||||||
"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={`priority-badge-${feature.id}`}
|
|
||||||
>
|
|
||||||
{feature.priority === 1 ? "H" : feature.priority === 2 ? "M" : "L"}
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<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}`}
|
data-testid={`skip-tests-badge-${feature.id}`}
|
||||||
>
|
>
|
||||||
<Hand className="w-4 h-4" />
|
<Hand className="w-3 h-3" />
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="left" className="text-xs">
|
<TooltipContent side="right" className="text-xs">
|
||||||
<p>Manual verification required</p>
|
<p>Manual verification required</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -409,14 +360,13 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute px-2 py-1 text-[11px] font-medium rounded-md flex items-center justify-center z-10",
|
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
|
||||||
"min-w-[36px]",
|
"top-2 left-2",
|
||||||
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.5 h-3.5" />
|
<AlertCircle className="w-3 h-3" />
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" className="text-xs max-w-[250px]">
|
<TooltipContent side="right" className="text-xs max-w-[250px]">
|
||||||
@@ -426,42 +376,12 @@ 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.priority ? "top-11 left-2" : "top-2 left-2",
|
feature.skipTests ? "top-8 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"
|
||||||
)}
|
)}
|
||||||
@@ -472,13 +392,40 @@ 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.priority && "pt-12",
|
(feature.skipTests || feature.error || isJustFinished) && "pt-10",
|
||||||
!feature.priority &&
|
hasWorktree &&
|
||||||
(feature.skipTests || feature.error || isJustFinished) &&
|
(feature.skipTests || feature.error || isJustFinished) &&
|
||||||
"pt-10"
|
"pt-14"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isCurrentAutoTask && (
|
{isCurrentAutoTask && (
|
||||||
@@ -496,7 +443,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isCurrentAutoTask && feature.status === "backlog" && (
|
{!isCurrentAutoTask && feature.status === "backlog" && (
|
||||||
<div className="absolute bottom-1 right-2">
|
<div className="absolute top-2 right-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -515,110 +462,43 @@ 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">
|
||||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
<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();
|
||||||
|
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();
|
||||||
onEdit();
|
onViewOutput();
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
data-testid={`edit-${
|
data-testid={`logs-${
|
||||||
feature.status === "waiting_approval" ? "waiting" : "verified"
|
feature.status === "waiting_approval"
|
||||||
|
? "waiting"
|
||||||
|
: "verified"
|
||||||
}-${feature.id}`}
|
}-${feature.id}`}
|
||||||
title="Edit"
|
title="Logs"
|
||||||
>
|
>
|
||||||
<Edit className="w-4 h-4" />
|
<FileText 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"
|
||||||
@@ -628,13 +508,69 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
handleDeleteClick(e);
|
handleDeleteClick(e);
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
data-testid={`delete-feature-${feature.id}`}
|
data-testid={`delete-${
|
||||||
|
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 && (
|
||||||
@@ -677,26 +613,14 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!feature.priority && (
|
<CardDescription className="text-[11px] mt-1.5 truncate text-muted-foreground/70">
|
||||||
<CardDescription className="text-[11px] mt-1.5 truncate text-muted-foreground/70">
|
{feature.category}
|
||||||
{feature.category}
|
</CardDescription>
|
||||||
</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">
|
||||||
@@ -880,31 +804,14 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{isCurrentAutoTask && (
|
{isCurrentAutoTask && (
|
||||||
<>
|
<>
|
||||||
{/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */}
|
{onViewOutput && (
|
||||||
{feature.planSpec?.status === 'generated' && onApprovePlan && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex-1 min-w-0 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
className="flex-1 h-7 text-[11px] bg-[var(--status-info)] hover:bg-[var(--status-info)]/90"
|
||||||
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();
|
||||||
@@ -912,11 +819,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 shrink-0" />
|
<FileText className="w-3 h-3 mr-1" />
|
||||||
<span className="truncate">Logs</span>
|
Logs
|
||||||
{shortcutKey && (
|
{shortcutKey && (
|
||||||
<span
|
<span
|
||||||
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
|
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-white/20"
|
||||||
data-testid={`shortcut-key-${feature.id}`}
|
data-testid={`shortcut-key-${feature.id}`}
|
||||||
>
|
>
|
||||||
{shortcutKey}
|
{shortcutKey}
|
||||||
@@ -928,7 +835,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-[11px] px-2 shrink-0"
|
className="h-7 text-[11px] px-2"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onForceStop();
|
onForceStop();
|
||||||
@@ -943,23 +850,6 @@ 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"
|
||||||
@@ -1008,7 +898,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
) : null}
|
) : null}
|
||||||
{onViewOutput && !feature.skipTests && (
|
{onViewOutput && !feature.skipTests && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-[11px] px-2"
|
className="h-7 text-[11px] px-2"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -1025,7 +915,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
)}
|
)}
|
||||||
{!isCurrentAutoTask && feature.status === "verified" && (
|
{!isCurrentAutoTask && feature.status === "verified" && (
|
||||||
<>
|
<>
|
||||||
{/* Logs button */}
|
{/* Logs button - styled like Refine */}
|
||||||
{onViewOutput && (
|
{onViewOutput && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -1063,6 +953,30 @@ 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
|
||||||
@@ -1080,7 +994,24 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<span className="truncate">Refine</span>
|
<span className="truncate">Refine</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onCommit && (
|
{hasWorktree && onMerge && (
|
||||||
|
<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"
|
||||||
@@ -1114,22 +1045,6 @@ 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"
|
||||||
@@ -1205,6 +1120,54 @@ 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>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -46,4 +46,3 @@ export const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
|
|||||||
{ id: "ultrathink", label: "Ultrathink" },
|
{ id: "ultrathink", label: "Ultrathink" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,4 +5,3 @@ export function getProviderFromModel(model: AgentModel): ModelProvider {
|
|||||||
return "claude";
|
return "claude";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,28 @@
|
|||||||
|
|
||||||
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, useSettingsView } from "./settings-view/hooks";
|
import { useCliStatus } from "./settings-view/hooks/use-cli-status";
|
||||||
import { NAV_ITEMS } from "./settings-view/config/navigation";
|
import { useScrollTracking } from "@/hooks/use-scroll-tracking";
|
||||||
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";
|
||||||
@@ -23,6 +33,17 @@ 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,
|
||||||
@@ -30,8 +51,6 @@ export function SettingsView() {
|
|||||||
setProjectTheme,
|
setProjectTheme,
|
||||||
defaultSkipTests,
|
defaultSkipTests,
|
||||||
setDefaultSkipTests,
|
setDefaultSkipTests,
|
||||||
enableDependencyBlocking,
|
|
||||||
setEnableDependencyBlocking,
|
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
setUseWorktrees,
|
setUseWorktrees,
|
||||||
showProfilesOnly,
|
showProfilesOnly,
|
||||||
@@ -40,10 +59,6 @@ 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
|
||||||
@@ -76,78 +91,23 @@ export function SettingsView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Use CLI status hook
|
// Use CLI status hook
|
||||||
const { claudeCliStatus, isCheckingClaudeCli, handleRefreshClaudeCli } =
|
const {
|
||||||
useCliStatus();
|
claudeCliStatus,
|
||||||
|
isCheckingClaudeCli,
|
||||||
|
handleRefreshClaudeCli,
|
||||||
|
} = useCliStatus();
|
||||||
|
|
||||||
// Use settings view navigation hook
|
// Use scroll tracking hook
|
||||||
const { activeView, navigateTo } = useSettingsView();
|
const { activeSection, scrollToSection, scrollContainerRef } =
|
||||||
|
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"
|
||||||
@@ -158,17 +118,107 @@ 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">
|
||||||
{/* Side Navigation - No longer scrolls, just switches views */}
|
{/* Sticky Side Navigation */}
|
||||||
<SettingsNavigation
|
<SettingsNavigation
|
||||||
navItems={NAV_ITEMS}
|
navItems={NAV_ITEMS}
|
||||||
activeSection={activeView}
|
activeSection={activeSection}
|
||||||
currentProject={currentProject}
|
currentProject={currentProject}
|
||||||
onNavigate={navigateTo}
|
onNavigate={scrollToSection}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content Panel - Shows only the active section */}
|
{/* Scrollable Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-8">
|
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-8">
|
||||||
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
|
<div className="max-w-4xl mx-auto space-y-6 pb-96">
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { AIEnhancementSection } from "./ai-enhancement-section";
|
|
||||||
@@ -58,8 +58,9 @@ export function ApiKeysSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
id="api-keys"
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-2xl overflow-hidden",
|
"rounded-2xl overflow-hidden scroll-mt-6",
|
||||||
"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