mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Compare commits
1 Commits
clean-them
...
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,7 +45,6 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
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",
|
||||||
@@ -27,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.
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { GeistSans } from "geist/font/sans";
|
import { GeistSans } from "geist/font/sans";
|
||||||
import { GeistMono } from "geist/font/mono";
|
import { GeistMono } from "geist/font/mono";
|
||||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
// Inter font for clean theme
|
|
||||||
const inter = Inter({
|
|
||||||
subsets: ["latin"],
|
|
||||||
variable: "--font-inter",
|
|
||||||
display: "swap",
|
|
||||||
});
|
|
||||||
|
|
||||||
// JetBrains Mono for clean theme
|
|
||||||
const jetbrainsMono = JetBrains_Mono({
|
|
||||||
subsets: ["latin"],
|
|
||||||
variable: "--font-jetbrains-mono",
|
|
||||||
display: "swap",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Automaker - Autonomous AI Development Studio",
|
title: "Automaker - Autonomous AI Development Studio",
|
||||||
description: "Build software autonomously with intelligent orchestration",
|
description: "Build software autonomously with intelligent orchestration",
|
||||||
@@ -32,7 +16,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${GeistSans.variable} ${GeistMono.variable} ${inter.variable} ${jetbrainsMono.variable} antialiased`}
|
className={`${GeistSans.variable} ${GeistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<Toaster richColors position="bottom-right" />
|
<Toaster richColors position="bottom-right" />
|
||||||
|
|||||||
@@ -133,10 +133,10 @@ function HomeContent() {
|
|||||||
// Apply theme class to document (uses effective theme - preview, project-specific, or global)
|
// Apply theme class to document (uses effective theme - preview, project-specific, or global)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const themeClasses = [
|
root.classList.remove(
|
||||||
"dark",
|
"dark",
|
||||||
"light",
|
|
||||||
"retro",
|
"retro",
|
||||||
|
"light",
|
||||||
"dracula",
|
"dracula",
|
||||||
"nord",
|
"nord",
|
||||||
"monokai",
|
"monokai",
|
||||||
@@ -146,23 +146,43 @@ function HomeContent() {
|
|||||||
"catppuccin",
|
"catppuccin",
|
||||||
"onedark",
|
"onedark",
|
||||||
"synthwave",
|
"synthwave",
|
||||||
"red",
|
"red"
|
||||||
"cream",
|
);
|
||||||
"sunset",
|
|
||||||
"gray",
|
|
||||||
"clean",
|
|
||||||
];
|
|
||||||
|
|
||||||
// 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>
|
||||||
|
|||||||
@@ -1217,8 +1217,6 @@ export function Sidebar() {
|
|||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-shrink-0 flex flex-col z-30 relative",
|
"flex-shrink-0 flex flex-col z-30 relative",
|
||||||
// Clean theme sidebar-glass class
|
|
||||||
"sidebar-glass",
|
|
||||||
// Glass morphism background with gradient
|
// Glass morphism background with gradient
|
||||||
"bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl",
|
"bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl",
|
||||||
// Premium border with subtle glow
|
// Premium border with subtle glow
|
||||||
@@ -1856,8 +1854,6 @@ export function Sidebar() {
|
|||||||
isActive
|
isActive
|
||||||
? [
|
? [
|
||||||
// Active: Premium gradient with glow
|
// Active: Premium gradient with glow
|
||||||
// Clean theme nav-active class
|
|
||||||
"nav-active",
|
|
||||||
"bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10",
|
"bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10",
|
||||||
"text-foreground font-medium",
|
"text-foreground font-medium",
|
||||||
"border border-brand-500/30",
|
"border border-brand-500/30",
|
||||||
@@ -1898,8 +1894,6 @@ export function Sidebar() {
|
|||||||
{item.shortcut && sidebarOpen && (
|
{item.shortcut && sidebarOpen && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
// Clean theme shortcut-badge class
|
|
||||||
"shortcut-badge",
|
|
||||||
"hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200",
|
"hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200",
|
||||||
isActive
|
isActive
|
||||||
? "bg-brand-500/20 text-brand-400"
|
? "bg-brand-500/20 text-brand-400"
|
||||||
@@ -1925,7 +1919,7 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
{item.shortcut && (
|
{item.shortcut && (
|
||||||
<span className="shortcut-badge ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
||||||
{formatShortcut(item.shortcut, true)}
|
{formatShortcut(item.shortcut, true)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -2059,8 +2053,6 @@ export function Sidebar() {
|
|||||||
{!sidebarOpen && runningAgentsCount > 0 && (
|
{!sidebarOpen && runningAgentsCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
// Clean theme running-agents-badge class
|
|
||||||
"running-agents-badge",
|
|
||||||
"absolute -top-1.5 -right-1.5 flex items-center justify-center",
|
"absolute -top-1.5 -right-1.5 flex items-center justify-center",
|
||||||
"min-w-4 h-4 px-1 text-[9px] font-bold rounded-full",
|
"min-w-4 h-4 px-1 text-[9px] font-bold rounded-full",
|
||||||
"bg-brand-500 text-white shadow-sm",
|
"bg-brand-500 text-white shadow-sm",
|
||||||
@@ -2084,8 +2076,6 @@ export function Sidebar() {
|
|||||||
{sidebarOpen && runningAgentsCount > 0 && (
|
{sidebarOpen && runningAgentsCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
// Clean theme running-agents-badge class
|
|
||||||
"running-agents-badge",
|
|
||||||
"hidden lg:flex items-center justify-center",
|
"hidden lg:flex items-center justify-center",
|
||||||
"min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full",
|
"min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full",
|
||||||
"bg-brand-500 text-white shadow-sm",
|
"bg-brand-500 text-white shadow-sm",
|
||||||
|
|||||||
@@ -27,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 = [
|
||||||
@@ -117,7 +116,6 @@ export function SessionManager({
|
|||||||
);
|
);
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
|
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
|
||||||
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
// Check running state for all sessions
|
// Check running state for all sessions
|
||||||
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
||||||
@@ -316,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 =
|
||||||
@@ -418,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
|
||||||
@@ -606,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,223 +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;
|
|
||||||
icon?: LucideIcon;
|
|
||||||
allowCreate?: boolean;
|
|
||||||
createLabel?: (value: string) => string;
|
|
||||||
"data-testid"?: string;
|
|
||||||
itemTestIdPrefix?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeOption(opt: string | AutocompleteOption): AutocompleteOption {
|
|
||||||
if (typeof opt === "string") {
|
|
||||||
return { value: opt, label: opt };
|
|
||||||
}
|
|
||||||
return { ...opt, label: opt.label ?? opt.value };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Autocomplete({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
placeholder = "Select an option...",
|
|
||||||
searchPlaceholder = "Search...",
|
|
||||||
emptyMessage = "No results found.",
|
|
||||||
className,
|
|
||||||
disabled = false,
|
|
||||||
icon: Icon,
|
|
||||||
allowCreate = false,
|
|
||||||
createLabel = (v) => `Create "${v}"`,
|
|
||||||
"data-testid": testId,
|
|
||||||
itemTestIdPrefix = "option",
|
|
||||||
}: AutocompleteProps) {
|
|
||||||
const [open, setOpen] = React.useState(false);
|
|
||||||
const [inputValue, setInputValue] = React.useState("");
|
|
||||||
const [triggerWidth, setTriggerWidth] = React.useState<number>(0);
|
|
||||||
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
const normalizedOptions = React.useMemo(
|
|
||||||
() => options.map(normalizeOption),
|
|
||||||
[options]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update trigger width when component mounts or value changes
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (triggerRef.current) {
|
|
||||||
const updateWidth = () => {
|
|
||||||
setTriggerWidth(triggerRef.current?.offsetWidth || 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
updateWidth();
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(updateWidth);
|
|
||||||
resizeObserver.observe(triggerRef.current);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
// Filter options based on input
|
|
||||||
const filteredOptions = React.useMemo(() => {
|
|
||||||
if (!inputValue) return normalizedOptions;
|
|
||||||
const lower = inputValue.toLowerCase();
|
|
||||||
return normalizedOptions.filter(
|
|
||||||
(opt) =>
|
|
||||||
opt.value.toLowerCase().includes(lower) ||
|
|
||||||
opt.label?.toLowerCase().includes(lower)
|
|
||||||
);
|
|
||||||
}, [normalizedOptions, inputValue]);
|
|
||||||
|
|
||||||
// Check if user typed a new value that doesn't exist
|
|
||||||
const isNewValue =
|
|
||||||
allowCreate &&
|
|
||||||
inputValue.trim() &&
|
|
||||||
!normalizedOptions.some(
|
|
||||||
(opt) => opt.value.toLowerCase() === inputValue.toLowerCase()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get display value
|
|
||||||
const displayValue = React.useMemo(() => {
|
|
||||||
if (!value) return null;
|
|
||||||
const found = normalizedOptions.find((opt) => opt.value === value);
|
|
||||||
return found?.label ?? value;
|
|
||||||
}, [value, normalizedOptions]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
ref={triggerRef}
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={open}
|
|
||||||
disabled={disabled}
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-between",
|
|
||||||
Icon && "font-mono text-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
data-testid={testId}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2 truncate">
|
|
||||||
{Icon && (
|
|
||||||
<Icon className="w-4 h-4 shrink-0 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
{displayValue || placeholder}
|
|
||||||
</span>
|
|
||||||
<ChevronsUpDown className="opacity-50 shrink-0" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
className="p-0"
|
|
||||||
style={{
|
|
||||||
width: Math.max(triggerWidth, 200),
|
|
||||||
}}
|
|
||||||
data-testid={testId ? `${testId}-list` : undefined}
|
|
||||||
>
|
|
||||||
<Command shouldFilter={false}>
|
|
||||||
<CommandInput
|
|
||||||
placeholder={searchPlaceholder}
|
|
||||||
className="h-9"
|
|
||||||
value={inputValue}
|
|
||||||
onValueChange={setInputValue}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>
|
|
||||||
{isNewValue ? (
|
|
||||||
<div className="py-2 px-3 text-sm">
|
|
||||||
Press enter to create{" "}
|
|
||||||
<code className="bg-muted px-1 rounded">{inputValue}</code>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
emptyMessage
|
|
||||||
)}
|
|
||||||
</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{/* Show "Create new" option if typing a new value */}
|
|
||||||
{isNewValue && (
|
|
||||||
<CommandItem
|
|
||||||
value={inputValue}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(inputValue);
|
|
||||||
setInputValue("");
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
className="text-[var(--status-success)]"
|
|
||||||
data-testid={`${itemTestIdPrefix}-create-new`}
|
|
||||||
>
|
|
||||||
{Icon && <Icon className="w-4 h-4 mr-2" />}
|
|
||||||
{createLabel(inputValue)}
|
|
||||||
<span className="ml-auto text-xs text-muted-foreground">
|
|
||||||
(new)
|
|
||||||
</span>
|
|
||||||
</CommandItem>
|
|
||||||
)}
|
|
||||||
{filteredOptions.map((option) => (
|
|
||||||
<CommandItem
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
onSelect={(currentValue) => {
|
|
||||||
onChange(currentValue === value ? "" : currentValue);
|
|
||||||
setInputValue("");
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
data-testid={`${itemTestIdPrefix}-${option.value.toLowerCase().replace(/[\s/\\]+/g, "-")}`}
|
|
||||||
>
|
|
||||||
{Icon && <Icon className="w-4 h-4 mr-2" />}
|
|
||||||
{option.label}
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"ml-auto",
|
|
||||||
value === option.value ? "opacity-100" : "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{option.badge && (
|
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
|
||||||
({option.badge})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,53 +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[];
|
|
||||||
placeholder?: string;
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
"data-testid"?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BranchAutocomplete({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
branches,
|
|
||||||
placeholder = "Select a branch...",
|
|
||||||
className,
|
|
||||||
disabled = false,
|
|
||||||
"data-testid": testId,
|
|
||||||
}: BranchAutocompleteProps) {
|
|
||||||
// Always include "main" at the top of suggestions
|
|
||||||
const branchOptions: AutocompleteOption[] = React.useMemo(() => {
|
|
||||||
const branchSet = new Set(["main", ...branches]);
|
|
||||||
return Array.from(branchSet).map((branch) => ({
|
|
||||||
value: branch,
|
|
||||||
label: branch,
|
|
||||||
badge: branch === "main" ? "default" : undefined,
|
|
||||||
}));
|
|
||||||
}, [branches]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Autocomplete
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
options={branchOptions}
|
|
||||||
placeholder={placeholder}
|
|
||||||
searchPlaceholder="Search or type new branch..."
|
|
||||||
emptyMessage="No branches found."
|
|
||||||
className={className}
|
|
||||||
disabled={disabled}
|
|
||||||
icon={GitBranch}
|
|
||||||
allowCreate
|
|
||||||
createLabel={(v) => `Create "${v}"`}
|
|
||||||
data-testid={testId}
|
|
||||||
itemTestIdPrefix="branch-option"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,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",
|
||||||
@@ -104,7 +66,7 @@ function DialogContent({
|
|||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogContentPrimitive
|
<DialogPrimitive.Content
|
||||||
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%]",
|
||||||
@@ -129,7 +91,7 @@ function DialogContent({
|
|||||||
>
|
>
|
||||||
{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",
|
||||||
@@ -143,9 +105,9 @@ function DialogContent({
|
|||||||
>
|
>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogClosePrimitive>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</DialogContentPrimitive>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -175,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,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;
|
||||||
|
|||||||
@@ -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,171 +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, History, Trash2, Layout } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useAppStore } from "@/store/app-store";
|
|
||||||
|
|
||||||
interface BoardControlsProps {
|
|
||||||
isMounted: boolean;
|
|
||||||
onShowBoardBackground: () => void;
|
|
||||||
onShowCompletedModal: () => void;
|
|
||||||
completedCount: number;
|
|
||||||
kanbanCardDetailLevel: "minimal" | "standard" | "detailed";
|
|
||||||
onDetailLevelChange: (level: "minimal" | "standard" | "detailed") => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BoardControls({
|
|
||||||
isMounted,
|
|
||||||
onShowBoardBackground,
|
|
||||||
onShowCompletedModal,
|
|
||||||
completedCount,
|
|
||||||
kanbanCardDetailLevel,
|
|
||||||
onDetailLevelChange,
|
|
||||||
}: BoardControlsProps) {
|
|
||||||
const { getEffectiveTheme } = useAppStore();
|
|
||||||
const effectiveTheme = getEffectiveTheme();
|
|
||||||
const isCleanTheme = effectiveTheme === "clean";
|
|
||||||
|
|
||||||
if (!isMounted) return null;
|
|
||||||
|
|
||||||
if (isCleanTheme) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 ml-6">
|
|
||||||
<button
|
|
||||||
className="p-2.5 glass rounded-xl text-slate-500 hover:text-white transition"
|
|
||||||
onClick={onShowCompletedModal}
|
|
||||||
>
|
|
||||||
<History className="w-[18px] h-[18px]" />
|
|
||||||
</button>
|
|
||||||
<button className="p-2.5 glass rounded-xl text-slate-500 hover:text-white transition">
|
|
||||||
<Trash2 className="w-[18px] h-[18px]" />
|
|
||||||
</button>
|
|
||||||
<div className="w-px h-6 bg-white/10 mx-1"></div>
|
|
||||||
<button
|
|
||||||
className="p-2.5 glass rounded-xl text-slate-500 hover:text-white transition"
|
|
||||||
onClick={onShowBoardBackground}
|
|
||||||
>
|
|
||||||
<Maximize2 className="w-[18px] h-[18px]" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="p-2.5 glass rounded-xl text-slate-500 hover:text-white transition"
|
|
||||||
onClick={() => onDetailLevelChange(kanbanCardDetailLevel === 'minimal' ? 'standard' : kanbanCardDetailLevel === 'standard' ? 'detailed' : 'minimal')}
|
|
||||||
>
|
|
||||||
<Layout className="w-[18px] h-[18px]" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<div className="flex items-center gap-2 ml-4">
|
|
||||||
{/* Board Background Button */}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onShowBoardBackground}
|
|
||||||
className="h-8 px-2"
|
|
||||||
data-testid="board-background-button"
|
|
||||||
>
|
|
||||||
<ImageIcon className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Board Background Settings</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Completed/Archived Features Button */}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onShowCompletedModal}
|
|
||||||
className="h-8 px-2 relative"
|
|
||||||
data-testid="completed-features-button"
|
|
||||||
>
|
|
||||||
<Archive className="w-4 h-4" />
|
|
||||||
{completedCount > 0 && (
|
|
||||||
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
|
|
||||||
{completedCount > 99 ? "99+" : completedCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Completed Features ({completedCount})</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Kanban Card Detail Level Toggle */}
|
|
||||||
<div
|
|
||||||
className="flex items-center rounded-lg bg-secondary border border-border"
|
|
||||||
data-testid="kanban-detail-toggle"
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
onClick={() => onDetailLevelChange("minimal")}
|
|
||||||
className={cn(
|
|
||||||
"p-2 rounded-l-lg transition-colors",
|
|
||||||
kanbanCardDetailLevel === "minimal"
|
|
||||||
? "bg-brand-500/20 text-brand-500"
|
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
|
||||||
)}
|
|
||||||
data-testid="kanban-toggle-minimal"
|
|
||||||
>
|
|
||||||
<Minimize2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Minimal - Title & category only</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
onClick={() => onDetailLevelChange("standard")}
|
|
||||||
className={cn(
|
|
||||||
"p-2 transition-colors",
|
|
||||||
kanbanCardDetailLevel === "standard"
|
|
||||||
? "bg-brand-500/20 text-brand-500"
|
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
|
||||||
)}
|
|
||||||
data-testid="kanban-toggle-standard"
|
|
||||||
>
|
|
||||||
<Square className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Standard - Steps & progress</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
onClick={() => onDetailLevelChange("detailed")}
|
|
||||||
className={cn(
|
|
||||||
"p-2 rounded-r-lg transition-colors",
|
|
||||||
kanbanCardDetailLevel === "detailed"
|
|
||||||
? "bg-brand-500/20 text-brand-500"
|
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
|
||||||
)}
|
|
||||||
data-testid="kanban-toggle-detailed"
|
|
||||||
>
|
|
||||||
<Maximize2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Detailed - Model, tools & tasks</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
|
||||||
import { Slider } from "@/components/ui/slider";
|
|
||||||
import { Play, StopCircle, Plus, Users } from "lucide-react";
|
|
||||||
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
|
|
||||||
import { useAppStore } from "@/store/app-store";
|
|
||||||
|
|
||||||
interface BoardHeaderProps {
|
|
||||||
projectName: string;
|
|
||||||
maxConcurrency: number;
|
|
||||||
onConcurrencyChange: (value: number) => void;
|
|
||||||
isAutoModeRunning: boolean;
|
|
||||||
onStartAutoMode: () => void;
|
|
||||||
onStopAutoMode: () => void;
|
|
||||||
onAddFeature: () => void;
|
|
||||||
addFeatureShortcut: KeyboardShortcut;
|
|
||||||
isMounted: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BoardHeader({
|
|
||||||
projectName,
|
|
||||||
maxConcurrency,
|
|
||||||
onConcurrencyChange,
|
|
||||||
isAutoModeRunning,
|
|
||||||
onStartAutoMode,
|
|
||||||
onStopAutoMode,
|
|
||||||
onAddFeature,
|
|
||||||
addFeatureShortcut,
|
|
||||||
isMounted,
|
|
||||||
}: BoardHeaderProps) {
|
|
||||||
const { getEffectiveTheme } = useAppStore();
|
|
||||||
const effectiveTheme = getEffectiveTheme();
|
|
||||||
const isCleanTheme = effectiveTheme === "clean";
|
|
||||||
|
|
||||||
if (isCleanTheme) {
|
|
||||||
return (
|
|
||||||
<header className="h-16 flex items-center justify-between px-8 border-b border-white/5 bg-[#0b101a]/40 backdrop-blur-md z-20 shrink-0">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-bold text-white tracking-tight">Kanban Board</h2>
|
|
||||||
<p className="text-[10px] text-slate-500 uppercase tracking-[0.2em] font-bold mono">
|
|
||||||
{projectName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
{/* Concurrency Display (Visual only to match mockup for now, or interactive if needed) */}
|
|
||||||
<div className="flex items-center bg-white/5 border border-white/10 rounded-full px-4 py-1.5 gap-3">
|
|
||||||
<Users className="w-4 h-4 text-slate-500" />
|
|
||||||
<div className="toggle-track">
|
|
||||||
<div className="toggle-thumb"></div>
|
|
||||||
</div>
|
|
||||||
<span className="mono text-xs font-bold text-slate-400">{maxConcurrency}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Auto Mode Button */}
|
|
||||||
{isAutoModeRunning ? (
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-2 glass px-5 py-2 rounded-xl text-xs font-bold hover:bg-white/10 transition text-rose-400 border-rose-500/30"
|
|
||||||
onClick={onStopAutoMode}
|
|
||||||
>
|
|
||||||
<StopCircle className="w-3.5 h-3.5" /> Stop
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-2 glass px-5 py-2 rounded-xl text-xs font-bold hover:bg-white/10 transition"
|
|
||||||
onClick={onStartAutoMode}
|
|
||||||
>
|
|
||||||
<Play className="w-3.5 h-3.5 text-cyan-400 fill-cyan-400" /> Auto Mode
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add Feature Button */}
|
|
||||||
<button
|
|
||||||
className="btn-cyan px-6 py-2 rounded-xl text-xs font-black flex items-center gap-2 shadow-lg shadow-cyan-500/20"
|
|
||||||
onClick={onAddFeature}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 stroke-[3.5px]" /> ADD FEATURE
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">Kanban Board</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">{projectName}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
|
|
||||||
{isMounted && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border"
|
|
||||||
data-testid="concurrency-slider-container"
|
|
||||||
>
|
|
||||||
<Users className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<Slider
|
|
||||||
value={[maxConcurrency]}
|
|
||||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
|
||||||
min={1}
|
|
||||||
max={10}
|
|
||||||
step={1}
|
|
||||||
className="w-20"
|
|
||||||
data-testid="concurrency-slider"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="text-sm text-muted-foreground min-w-[2ch] text-center"
|
|
||||||
data-testid="concurrency-value"
|
|
||||||
>
|
|
||||||
{maxConcurrency}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
|
||||||
{isMounted && (
|
|
||||||
<>
|
|
||||||
{isAutoModeRunning ? (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={onStopAutoMode}
|
|
||||||
data-testid="stop-auto-mode"
|
|
||||||
>
|
|
||||||
<StopCircle className="w-4 h-4 mr-2" />
|
|
||||||
Stop Auto Mode
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={onStartAutoMode}
|
|
||||||
data-testid="start-auto-mode"
|
|
||||||
>
|
|
||||||
<Play className="w-4 h-4 mr-2" />
|
|
||||||
Auto Mode
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<HotkeyButton
|
|
||||||
size="sm"
|
|
||||||
onClick={onAddFeature}
|
|
||||||
hotkey={addFeatureShortcut}
|
|
||||||
hotkeyActive={false}
|
|
||||||
data-testid="add-feature-button"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Add Feature
|
|
||||||
</HotkeyButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef, useEffect } from "react";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Search, X, Loader2 } from "lucide-react";
|
|
||||||
import { useAppStore } from "@/store/app-store";
|
|
||||||
|
|
||||||
interface BoardSearchBarProps {
|
|
||||||
searchQuery: string;
|
|
||||||
onSearchChange: (query: string) => void;
|
|
||||||
isCreatingSpec: boolean;
|
|
||||||
creatingSpecProjectPath?: string;
|
|
||||||
currentProjectPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BoardSearchBar({
|
|
||||||
searchQuery,
|
|
||||||
onSearchChange,
|
|
||||||
isCreatingSpec,
|
|
||||||
creatingSpecProjectPath,
|
|
||||||
currentProjectPath,
|
|
||||||
}: BoardSearchBarProps) {
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const { getEffectiveTheme } = useAppStore();
|
|
||||||
const effectiveTheme = getEffectiveTheme();
|
|
||||||
const isCleanTheme = effectiveTheme === "clean";
|
|
||||||
|
|
||||||
// Focus search input when "/" is pressed
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
// Only focus if not typing in an input/textarea
|
|
||||||
if (
|
|
||||||
e.key === "/" &&
|
|
||||||
!(e.target instanceof HTMLInputElement) &&
|
|
||||||
!(e.target instanceof HTMLTextAreaElement)
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
|
||||||
searchInputRef.current?.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isCleanTheme) {
|
|
||||||
return (
|
|
||||||
<div className="relative flex-1 max-w-2xl group">
|
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 group-focus-within:text-cyan-400 transition-colors" />
|
|
||||||
<input
|
|
||||||
ref={searchInputRef}
|
|
||||||
type="text"
|
|
||||||
placeholder="Search features by keyword..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
className="w-full bg-white/5 border border-white/10 rounded-2xl py-2.5 pl-12 pr-12 text-sm focus:outline-none focus:border-cyan-500/50 transition-all mono"
|
|
||||||
/>
|
|
||||||
<div className="absolute right-4 top-1/2 -translate-y-1/2">
|
|
||||||
<span className="shortcut-badge">/</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative max-w-md flex-1 flex items-center gap-2">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
|
||||||
<Input
|
|
||||||
ref={searchInputRef}
|
|
||||||
type="text"
|
|
||||||
placeholder="Search features by keyword..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
className="pl-9 pr-12 border-border"
|
|
||||||
data-testid="kanban-search-input"
|
|
||||||
/>
|
|
||||||
{searchQuery ? (
|
|
||||||
<button
|
|
||||||
onClick={() => onSearchChange("")}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-sm hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
data-testid="kanban-search-clear"
|
|
||||||
aria-label="Clear search"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
|
|
||||||
data-testid="kanban-search-hotkey"
|
|
||||||
>
|
|
||||||
/
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Spec Creation Loading Badge */}
|
|
||||||
{isCreatingSpec &&
|
|
||||||
currentProjectPath === creatingSpecProjectPath && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-brand-500/10 border border-brand-500/20 shrink-0"
|
|
||||||
title="Creating App Specification"
|
|
||||||
data-testid="spec-creation-badge"
|
|
||||||
>
|
|
||||||
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
|
|
||||||
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
|
|
||||||
Creating spec
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { KanbanCard } from "./kanban-card";
|
|
||||||
export { KanbanColumn } from "./kanban-column";
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { memo } from "react";
|
|
||||||
import { useDroppable } from "@dnd-kit/core";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useAppStore } from "@/store/app-store";
|
|
||||||
|
|
||||||
interface KanbanColumnProps {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
colorClass: string;
|
|
||||||
count: number;
|
|
||||||
children: ReactNode;
|
|
||||||
headerAction?: ReactNode;
|
|
||||||
opacity?: number;
|
|
||||||
showBorder?: boolean;
|
|
||||||
hideScrollbar?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const KanbanColumn = memo(function KanbanColumn({
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
colorClass,
|
|
||||||
count,
|
|
||||||
children,
|
|
||||||
headerAction,
|
|
||||||
opacity = 100,
|
|
||||||
showBorder = true,
|
|
||||||
hideScrollbar = false,
|
|
||||||
}: KanbanColumnProps) {
|
|
||||||
const { setNodeRef, isOver } = useDroppable({ id });
|
|
||||||
const { getEffectiveTheme } = useAppStore();
|
|
||||||
const effectiveTheme = getEffectiveTheme();
|
|
||||||
const isCleanTheme = effectiveTheme === "clean";
|
|
||||||
|
|
||||||
// Map column IDs to clean theme classes
|
|
||||||
const getColumnClasses = () => {
|
|
||||||
switch (id) {
|
|
||||||
case "in_progress":
|
|
||||||
return "col-in-progress";
|
|
||||||
case "waiting_approval":
|
|
||||||
return "col-waiting";
|
|
||||||
case "verified":
|
|
||||||
return "col-verified";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Map column IDs to status dot glow classes
|
|
||||||
const getStatusDotClasses = () => {
|
|
||||||
switch (id) {
|
|
||||||
case "in_progress":
|
|
||||||
return "status-dot-in-progress glow-cyan";
|
|
||||||
case "waiting_approval":
|
|
||||||
return "status-dot-waiting glow-orange";
|
|
||||||
case "verified":
|
|
||||||
return "status-dot-verified glow-green";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clean theme column styles
|
|
||||||
if (isCleanTheme) {
|
|
||||||
const isBacklog = id === "backlog";
|
|
||||||
|
|
||||||
// Explicitly match mockup classes for status dots
|
|
||||||
const getCleanStatusDotClass = () => {
|
|
||||||
switch (id) {
|
|
||||||
case "backlog":
|
|
||||||
return "status-dot bg-slate-600";
|
|
||||||
case "in_progress":
|
|
||||||
return "status-dot bg-cyan-400 glow-cyan";
|
|
||||||
case "waiting_approval":
|
|
||||||
return "status-dot bg-orange-500 glow-orange";
|
|
||||||
case "verified":
|
|
||||||
return "status-dot bg-emerald-500 glow-green";
|
|
||||||
default:
|
|
||||||
return "status-dot bg-slate-600";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Explicitly match mockup classes for badges
|
|
||||||
const getBadgeClass = () => {
|
|
||||||
switch (id) {
|
|
||||||
case "in_progress":
|
|
||||||
return "mono text-[10px] bg-cyan-500/10 px-2.5 py-0.5 rounded-full text-cyan-400 border border-cyan-500/20";
|
|
||||||
case "verified":
|
|
||||||
return "mono text-[10px] bg-emerald-500/10 px-2.5 py-0.5 rounded-full text-emerald-500 border border-emerald-500/20";
|
|
||||||
case "backlog":
|
|
||||||
case "waiting_approval":
|
|
||||||
default:
|
|
||||||
return "mono text-[10px] bg-white/5 px-2.5 py-0.5 rounded-full text-slate-500 border border-white/5";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col h-full w-80 gap-5",
|
|
||||||
!isBacklog && "rounded-[2.5rem] p-3",
|
|
||||||
getColumnClasses()
|
|
||||||
)}
|
|
||||||
data-testid={`kanban-column-${id}`}
|
|
||||||
data-column-id={id}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-2 shrink-0">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className={getCleanStatusDotClass()} />
|
|
||||||
<h3 className={cn(
|
|
||||||
"text-[11px] font-black uppercase tracking-widest",
|
|
||||||
id === "backlog" ? "text-slate-400" :
|
|
||||||
id === "in_progress" ? "text-slate-200" : "text-slate-300"
|
|
||||||
)}>
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
{headerAction}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className={getBadgeClass()}>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex-1 overflow-y-auto custom-scrollbar space-y-4",
|
|
||||||
isBacklog ? "pr-2" : "pr-1",
|
|
||||||
hideScrollbar && "scrollbar-hide"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
className={cn(
|
|
||||||
"relative flex flex-col h-full rounded-xl transition-all duration-200 w-72 clean:w-80",
|
|
||||||
showBorder && "border border-border/60",
|
|
||||||
isOver && "ring-2 ring-primary/30 ring-offset-1 ring-offset-background",
|
|
||||||
getColumnClasses()
|
|
||||||
)}
|
|
||||||
data-testid={`kanban-column-${id}`}
|
|
||||||
data-column-id={id}
|
|
||||||
>
|
|
||||||
{/* Background layer with opacity */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200",
|
|
||||||
isOver ? "bg-accent/80" : "bg-card/80"
|
|
||||||
)}
|
|
||||||
style={{ opacity: opacity / 100 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Column Header */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative z-10 flex items-center gap-3 px-3 py-2.5",
|
|
||||||
showBorder && "border-b border-border/40"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className={cn("w-2.5 h-2.5 rounded-full shrink-0 status-dot", colorClass, getStatusDotClasses())} />
|
|
||||||
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
|
|
||||||
{headerAction}
|
|
||||||
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Column Content */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5",
|
|
||||||
hideScrollbar &&
|
|
||||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
|
||||||
// Smooth scrolling
|
|
||||||
"scroll-smooth"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Drop zone indicator when dragging over */}
|
|
||||||
{isOver && (
|
|
||||||
<div className="absolute inset-0 rounded-xl bg-primary/5 pointer-events-none z-5 border-2 border-dashed border-primary/20" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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,511 +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 { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
|
|
||||||
import {
|
|
||||||
DescriptionImageDropZone,
|
|
||||||
FeatureImagePath as DescriptionImagePath,
|
|
||||||
ImagePreviewMap,
|
|
||||||
} from "@/components/ui/description-image-dropzone";
|
|
||||||
import {
|
|
||||||
MessageSquare,
|
|
||||||
Settings2,
|
|
||||||
SlidersHorizontal,
|
|
||||||
FlaskConical,
|
|
||||||
Sparkles,
|
|
||||||
ChevronDown,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
|
||||||
import { modelSupportsThinking } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
useAppStore,
|
|
||||||
AgentModel,
|
|
||||||
ThinkingLevel,
|
|
||||||
FeatureImage,
|
|
||||||
AIProfile,
|
|
||||||
PlanningMode,
|
|
||||||
} from "@/store/app-store";
|
|
||||||
import {
|
|
||||||
ModelSelector,
|
|
||||||
ThinkingLevelSelector,
|
|
||||||
ProfileQuickSelect,
|
|
||||||
TestingTabContent,
|
|
||||||
PrioritySelector,
|
|
||||||
PlanningModeSelector,
|
|
||||||
} from "../shared";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
|
|
||||||
interface AddFeatureDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onAdd: (feature: {
|
|
||||||
category: string;
|
|
||||||
description: string;
|
|
||||||
steps: string[];
|
|
||||||
images: FeatureImage[];
|
|
||||||
imagePaths: DescriptionImagePath[];
|
|
||||||
skipTests: boolean;
|
|
||||||
model: AgentModel;
|
|
||||||
thinkingLevel: ThinkingLevel;
|
|
||||||
branchName: string;
|
|
||||||
priority: number;
|
|
||||||
planningMode: PlanningMode;
|
|
||||||
requirePlanApproval: boolean;
|
|
||||||
}) => void;
|
|
||||||
categorySuggestions: string[];
|
|
||||||
branchSuggestions: string[];
|
|
||||||
defaultSkipTests: boolean;
|
|
||||||
defaultBranch?: string;
|
|
||||||
isMaximized: boolean;
|
|
||||||
showProfilesOnly: boolean;
|
|
||||||
aiProfiles: AIProfile[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AddFeatureDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onAdd,
|
|
||||||
categorySuggestions,
|
|
||||||
branchSuggestions,
|
|
||||||
defaultSkipTests,
|
|
||||||
defaultBranch = "main",
|
|
||||||
isMaximized,
|
|
||||||
showProfilesOnly,
|
|
||||||
aiProfiles,
|
|
||||||
}: AddFeatureDialogProps) {
|
|
||||||
const [newFeature, setNewFeature] = useState({
|
|
||||||
category: "",
|
|
||||||
description: "",
|
|
||||||
steps: [""],
|
|
||||||
images: [] as FeatureImage[],
|
|
||||||
imagePaths: [] as DescriptionImagePath[],
|
|
||||||
skipTests: false,
|
|
||||||
model: "opus" as AgentModel,
|
|
||||||
thinkingLevel: "none" as ThinkingLevel,
|
|
||||||
branchName: "main",
|
|
||||||
priority: 2 as number, // Default to medium priority
|
|
||||||
});
|
|
||||||
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
|
|
||||||
useState<ImagePreviewMap>(() => new Map());
|
|
||||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
|
||||||
const [descriptionError, setDescriptionError] = useState(false);
|
|
||||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
|
||||||
const [enhancementMode, setEnhancementMode] = useState<
|
|
||||||
"improve" | "technical" | "simplify" | "acceptance"
|
|
||||||
>("improve");
|
|
||||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
|
||||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
|
||||||
|
|
||||||
// Get enhancement model, planning mode defaults, and worktrees setting from store
|
|
||||||
const { enhancementModel, defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
|
|
||||||
|
|
||||||
// Sync defaults when dialog opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setNewFeature((prev) => ({
|
|
||||||
...prev,
|
|
||||||
skipTests: defaultSkipTests,
|
|
||||||
branchName: defaultBranch,
|
|
||||||
}));
|
|
||||||
setPlanningMode(defaultPlanningMode);
|
|
||||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
|
||||||
}
|
|
||||||
}, [open, defaultSkipTests, defaultBranch, defaultPlanningMode, defaultRequirePlanApproval]);
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
if (!newFeature.description.trim()) {
|
|
||||||
setDescriptionError(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const category = newFeature.category || "Uncategorized";
|
|
||||||
const selectedModel = newFeature.model;
|
|
||||||
const normalizedThinking = modelSupportsThinking(selectedModel)
|
|
||||||
? newFeature.thinkingLevel
|
|
||||||
: "none";
|
|
||||||
|
|
||||||
onAdd({
|
|
||||||
category,
|
|
||||||
description: newFeature.description,
|
|
||||||
steps: newFeature.steps.filter((s) => s.trim()),
|
|
||||||
images: newFeature.images,
|
|
||||||
imagePaths: newFeature.imagePaths,
|
|
||||||
skipTests: newFeature.skipTests,
|
|
||||||
model: selectedModel,
|
|
||||||
thinkingLevel: normalizedThinking,
|
|
||||||
branchName: newFeature.branchName,
|
|
||||||
priority: newFeature.priority,
|
|
||||||
planningMode,
|
|
||||||
requirePlanApproval,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
setNewFeature({
|
|
||||||
category: "",
|
|
||||||
description: "",
|
|
||||||
steps: [""],
|
|
||||||
images: [],
|
|
||||||
imagePaths: [],
|
|
||||||
skipTests: defaultSkipTests,
|
|
||||||
model: "opus",
|
|
||||||
priority: 2,
|
|
||||||
thinkingLevel: "none",
|
|
||||||
branchName: defaultBranch,
|
|
||||||
});
|
|
||||||
setPlanningMode(defaultPlanningMode);
|
|
||||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
|
||||||
setNewFeaturePreviewMap(new Map());
|
|
||||||
setShowAdvancedOptions(false);
|
|
||||||
setDescriptionError(false);
|
|
||||||
onOpenChange(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDialogClose = (open: boolean) => {
|
|
||||||
onOpenChange(open);
|
|
||||||
if (!open) {
|
|
||||||
setNewFeaturePreviewMap(new Map());
|
|
||||||
setShowAdvancedOptions(false);
|
|
||||||
setDescriptionError(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEnhanceDescription = async () => {
|
|
||||||
if (!newFeature.description.trim() || isEnhancing) return;
|
|
||||||
|
|
||||||
setIsEnhancing(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
const result = await api.enhancePrompt?.enhance(
|
|
||||||
newFeature.description,
|
|
||||||
enhancementMode,
|
|
||||||
enhancementModel
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result?.success && result.enhancedText) {
|
|
||||||
const enhancedText = result.enhancedText;
|
|
||||||
setNewFeature((prev) => ({ ...prev, description: enhancedText }));
|
|
||||||
toast.success("Description enhanced!");
|
|
||||||
} else {
|
|
||||||
toast.error(result?.error || "Failed to enhance description");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Enhancement failed:", error);
|
|
||||||
toast.error("Failed to enhance description");
|
|
||||||
} finally {
|
|
||||||
setIsEnhancing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleModelSelect = (model: AgentModel) => {
|
|
||||||
setNewFeature({
|
|
||||||
...newFeature,
|
|
||||||
model,
|
|
||||||
thinkingLevel: modelSupportsThinking(model)
|
|
||||||
? newFeature.thinkingLevel
|
|
||||||
: "none",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProfileSelect = (
|
|
||||||
model: AgentModel,
|
|
||||||
thinkingLevel: ThinkingLevel
|
|
||||||
) => {
|
|
||||||
setNewFeature({
|
|
||||||
...newFeature,
|
|
||||||
model,
|
|
||||||
thinkingLevel,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const newModelAllowsThinking = modelSupportsThinking(newFeature.model);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={handleDialogClose}>
|
|
||||||
<DialogContent
|
|
||||||
compact={!isMaximized}
|
|
||||||
data-testid="add-feature-dialog"
|
|
||||||
onPointerDownOutside={(e: CustomEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onInteractOutside={(e: CustomEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add New Feature</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Create a new feature card for the Kanban board.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Tabs
|
|
||||||
defaultValue="prompt"
|
|
||||||
className="py-4 flex-1 min-h-0 flex flex-col"
|
|
||||||
>
|
|
||||||
<TabsList className="w-full grid grid-cols-3 mb-4">
|
|
||||||
<TabsTrigger value="prompt" data-testid="tab-prompt">
|
|
||||||
<MessageSquare className="w-4 h-4 mr-2" />
|
|
||||||
Prompt
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="model" data-testid="tab-model">
|
|
||||||
<Settings2 className="w-4 h-4 mr-2" />
|
|
||||||
Model
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="options" data-testid="tab-options">
|
|
||||||
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
|
||||||
Options
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* Prompt Tab */}
|
|
||||||
<TabsContent
|
|
||||||
value="prompt"
|
|
||||||
className="space-y-4 overflow-y-auto cursor-default"
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">Description</Label>
|
|
||||||
<DescriptionImageDropZone
|
|
||||||
value={newFeature.description}
|
|
||||||
onChange={(value) => {
|
|
||||||
setNewFeature({ ...newFeature, description: value });
|
|
||||||
if (value.trim()) {
|
|
||||||
setDescriptionError(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
images={newFeature.imagePaths}
|
|
||||||
onImagesChange={(images) =>
|
|
||||||
setNewFeature({ ...newFeature, imagePaths: images })
|
|
||||||
}
|
|
||||||
placeholder="Describe the feature..."
|
|
||||||
previewMap={newFeaturePreviewMap}
|
|
||||||
onPreviewMapChange={setNewFeaturePreviewMap}
|
|
||||||
autoFocus
|
|
||||||
error={descriptionError}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-[200px] justify-between"
|
|
||||||
>
|
|
||||||
{enhancementMode === "improve" && "Improve Clarity"}
|
|
||||||
{enhancementMode === "technical" && "Add Technical Details"}
|
|
||||||
{enhancementMode === "simplify" && "Simplify"}
|
|
||||||
{enhancementMode === "acceptance" &&
|
|
||||||
"Add Acceptance Criteria"}
|
|
||||||
<ChevronDown className="w-4 h-4 ml-2" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setEnhancementMode("improve")}
|
|
||||||
>
|
|
||||||
Improve Clarity
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setEnhancementMode("technical")}
|
|
||||||
>
|
|
||||||
Add Technical Details
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setEnhancementMode("simplify")}
|
|
||||||
>
|
|
||||||
Simplify
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setEnhancementMode("acceptance")}
|
|
||||||
>
|
|
||||||
Add Acceptance Criteria
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleEnhanceDescription}
|
|
||||||
disabled={!newFeature.description.trim() || isEnhancing}
|
|
||||||
loading={isEnhancing}
|
|
||||||
>
|
|
||||||
<Sparkles className="w-4 h-4 mr-2" />
|
|
||||||
Enhance with AI
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="category">Category (optional)</Label>
|
|
||||||
<CategoryAutocomplete
|
|
||||||
value={newFeature.category}
|
|
||||||
onChange={(value) =>
|
|
||||||
setNewFeature({ ...newFeature, category: value })
|
|
||||||
}
|
|
||||||
suggestions={categorySuggestions}
|
|
||||||
placeholder="e.g., Core, UI, API"
|
|
||||||
data-testid="feature-category-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{useWorktrees && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="branch">Target Branch</Label>
|
|
||||||
<BranchAutocomplete
|
|
||||||
value={newFeature.branchName}
|
|
||||||
onChange={(value) =>
|
|
||||||
setNewFeature({ ...newFeature, branchName: value })
|
|
||||||
}
|
|
||||||
branches={branchSuggestions}
|
|
||||||
placeholder="Select or create branch..."
|
|
||||||
data-testid="feature-branch-input"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Work will be done in this branch. A worktree will be created if
|
|
||||||
needed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Priority Selector */}
|
|
||||||
<PrioritySelector
|
|
||||||
selectedPriority={newFeature.priority}
|
|
||||||
onPrioritySelect={(priority) =>
|
|
||||||
setNewFeature({ ...newFeature, priority })
|
|
||||||
}
|
|
||||||
testIdPrefix="priority"
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Model Tab */}
|
|
||||||
<TabsContent
|
|
||||||
value="model"
|
|
||||||
className="space-y-4 overflow-y-auto cursor-default"
|
|
||||||
>
|
|
||||||
{/* Show Advanced Options Toggle */}
|
|
||||||
{showProfilesOnly && (
|
|
||||||
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium text-foreground">
|
|
||||||
Simple Mode Active
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Only showing AI profiles. Advanced model tweaking is hidden.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
|
|
||||||
data-testid="show-advanced-options-toggle"
|
|
||||||
>
|
|
||||||
<Settings2 className="w-4 h-4 mr-2" />
|
|
||||||
{showAdvancedOptions ? "Hide" : "Show"} Advanced
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quick Select Profile Section */}
|
|
||||||
<ProfileQuickSelect
|
|
||||||
profiles={aiProfiles}
|
|
||||||
selectedModel={newFeature.model}
|
|
||||||
selectedThinkingLevel={newFeature.thinkingLevel}
|
|
||||||
onSelect={handleProfileSelect}
|
|
||||||
showManageLink
|
|
||||||
onManageLinkClick={() => {
|
|
||||||
onOpenChange(false);
|
|
||||||
useAppStore.getState().setCurrentView("profiles");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Separator */}
|
|
||||||
{aiProfiles.length > 0 &&
|
|
||||||
(!showProfilesOnly || showAdvancedOptions) && (
|
|
||||||
<div className="border-t border-border" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Claude Models Section */}
|
|
||||||
{(!showProfilesOnly || showAdvancedOptions) && (
|
|
||||||
<>
|
|
||||||
<ModelSelector
|
|
||||||
selectedModel={newFeature.model}
|
|
||||||
onModelSelect={handleModelSelect}
|
|
||||||
/>
|
|
||||||
{newModelAllowsThinking && (
|
|
||||||
<ThinkingLevelSelector
|
|
||||||
selectedLevel={newFeature.thinkingLevel}
|
|
||||||
onLevelSelect={(level) =>
|
|
||||||
setNewFeature({ ...newFeature, thinkingLevel: level })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Options Tab */}
|
|
||||||
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
|
|
||||||
{/* Planning Mode Section */}
|
|
||||||
<PlanningModeSelector
|
|
||||||
mode={planningMode}
|
|
||||||
onModeChange={setPlanningMode}
|
|
||||||
requireApproval={requirePlanApproval}
|
|
||||||
onRequireApprovalChange={setRequirePlanApproval}
|
|
||||||
featureDescription={newFeature.description}
|
|
||||||
testIdPrefix="add-feature"
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="border-t border-border my-4" />
|
|
||||||
|
|
||||||
{/* Testing Section */}
|
|
||||||
<TestingTabContent
|
|
||||||
skipTests={newFeature.skipTests}
|
|
||||||
onSkipTestsChange={(skipTests) =>
|
|
||||||
setNewFeature({ ...newFeature, skipTests })
|
|
||||||
}
|
|
||||||
steps={newFeature.steps}
|
|
||||||
onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<HotkeyButton
|
|
||||||
onClick={handleAdd}
|
|
||||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
|
||||||
hotkeyActive={open}
|
|
||||||
data-testid="confirm-add-feature"
|
|
||||||
>
|
|
||||||
Add Feature
|
|
||||||
</HotkeyButton>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,375 +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 { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { GitPullRequest, Loader2, ExternalLink } from "lucide-react";
|
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface WorktreeInfo {
|
|
||||||
path: string;
|
|
||||||
branch: string;
|
|
||||||
isMain: boolean;
|
|
||||||
hasChanges?: boolean;
|
|
||||||
changedFilesCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreatePRDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
worktree: WorktreeInfo | null;
|
|
||||||
onCreated: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreatePRDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
worktree,
|
|
||||||
onCreated,
|
|
||||||
}: CreatePRDialogProps) {
|
|
||||||
const [title, setTitle] = useState("");
|
|
||||||
const [body, setBody] = useState("");
|
|
||||||
const [baseBranch, setBaseBranch] = useState("main");
|
|
||||||
const [commitMessage, setCommitMessage] = useState("");
|
|
||||||
const [isDraft, setIsDraft] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [prUrl, setPrUrl] = useState<string | null>(null);
|
|
||||||
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
|
|
||||||
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
|
|
||||||
|
|
||||||
// Reset state when dialog opens or worktree changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
// Only reset form fields, not the result states (prUrl, browserUrl, showBrowserFallback)
|
|
||||||
// These are set by the API response and should persist until dialog closes
|
|
||||||
setTitle("");
|
|
||||||
setBody("");
|
|
||||||
setCommitMessage("");
|
|
||||||
setBaseBranch("main");
|
|
||||||
setIsDraft(false);
|
|
||||||
setError(null);
|
|
||||||
} else {
|
|
||||||
// Reset everything when dialog closes
|
|
||||||
setTitle("");
|
|
||||||
setBody("");
|
|
||||||
setCommitMessage("");
|
|
||||||
setBaseBranch("main");
|
|
||||||
setIsDraft(false);
|
|
||||||
setError(null);
|
|
||||||
setPrUrl(null);
|
|
||||||
setBrowserUrl(null);
|
|
||||||
setShowBrowserFallback(false);
|
|
||||||
}
|
|
||||||
}, [open, worktree?.path]);
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
|
||||||
if (!worktree) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.worktree?.createPR) {
|
|
||||||
setError("Worktree API not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.worktree.createPR(worktree.path, {
|
|
||||||
commitMessage: commitMessage || undefined,
|
|
||||||
prTitle: title || worktree.branch,
|
|
||||||
prBody: body || `Changes from branch ${worktree.branch}`,
|
|
||||||
baseBranch,
|
|
||||||
draft: isDraft,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success && result.result) {
|
|
||||||
if (result.result.prCreated && result.result.prUrl) {
|
|
||||||
setPrUrl(result.result.prUrl);
|
|
||||||
toast.success("Pull request created!", {
|
|
||||||
description: `PR created from ${result.result.branch}`,
|
|
||||||
action: {
|
|
||||||
label: "View PR",
|
|
||||||
onClick: () => window.open(result.result!.prUrl!, "_blank"),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
onCreated();
|
|
||||||
} else {
|
|
||||||
// Branch was pushed successfully
|
|
||||||
const prError = result.result.prError;
|
|
||||||
const hasBrowserUrl = !!result.result.browserUrl;
|
|
||||||
|
|
||||||
// Check if we should show browser fallback
|
|
||||||
if (!result.result.prCreated && hasBrowserUrl) {
|
|
||||||
// If gh CLI is not available, show browser fallback UI
|
|
||||||
if (prError === "gh_cli_not_available" || !result.result.ghCliAvailable) {
|
|
||||||
setBrowserUrl(result.result.browserUrl ?? null);
|
|
||||||
setShowBrowserFallback(true);
|
|
||||||
toast.success("Branch pushed", {
|
|
||||||
description: result.result.committed
|
|
||||||
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
|
|
||||||
: `Branch ${result.result.branch} pushed`,
|
|
||||||
});
|
|
||||||
// Don't call onCreated() here - we want to keep the dialog open to show the browser URL
|
|
||||||
setIsLoading(false);
|
|
||||||
return; // Don't close dialog, show browser fallback UI
|
|
||||||
}
|
|
||||||
|
|
||||||
// gh CLI is available but failed - show error with browser option
|
|
||||||
if (prError) {
|
|
||||||
// Parse common gh CLI errors for better messages
|
|
||||||
let errorMessage = prError;
|
|
||||||
if (prError.includes("No commits between")) {
|
|
||||||
errorMessage = "No new commits to create PR. Make sure your branch has changes compared to the base branch.";
|
|
||||||
} else if (prError.includes("already exists")) {
|
|
||||||
errorMessage = "A pull request already exists for this branch.";
|
|
||||||
} else if (prError.includes("not logged in") || prError.includes("auth")) {
|
|
||||||
errorMessage = "GitHub CLI not authenticated. Run 'gh auth login' in terminal.";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show error but also provide browser option
|
|
||||||
setBrowserUrl(result.result.browserUrl ?? null);
|
|
||||||
setShowBrowserFallback(true);
|
|
||||||
toast.error("PR creation failed", {
|
|
||||||
description: errorMessage,
|
|
||||||
duration: 8000,
|
|
||||||
});
|
|
||||||
// Don't call onCreated() here - we want to keep the dialog open to show the browser URL
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show success toast for push
|
|
||||||
toast.success("Branch pushed", {
|
|
||||||
description: result.result.committed
|
|
||||||
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
|
|
||||||
: `Branch ${result.result.branch} pushed`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// No browser URL available, just close
|
|
||||||
if (!result.result.prCreated) {
|
|
||||||
if (!hasBrowserUrl) {
|
|
||||||
toast.info("PR not created", {
|
|
||||||
description: "Could not determine repository URL. GitHub CLI (gh) may not be installed or authenticated.",
|
|
||||||
duration: 8000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onCreated();
|
|
||||||
onOpenChange(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError(result.error || "Failed to create pull request");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to create PR");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
onOpenChange(false);
|
|
||||||
// Reset state after dialog closes
|
|
||||||
setTimeout(() => {
|
|
||||||
setTitle("");
|
|
||||||
setBody("");
|
|
||||||
setCommitMessage("");
|
|
||||||
setBaseBranch("main");
|
|
||||||
setIsDraft(false);
|
|
||||||
setError(null);
|
|
||||||
setPrUrl(null);
|
|
||||||
setBrowserUrl(null);
|
|
||||||
setShowBrowserFallback(false);
|
|
||||||
}, 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!worktree) return null;
|
|
||||||
|
|
||||||
const shouldShowBrowserFallback = showBrowserFallback && browserUrl;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
|
||||||
<DialogContent className="sm:max-w-[550px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<GitPullRequest className="w-5 h-5" />
|
|
||||||
Create Pull Request
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Push changes and create a pull request from{" "}
|
|
||||||
<code className="font-mono bg-muted px-1 rounded">
|
|
||||||
{worktree.branch}
|
|
||||||
</code>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{prUrl ? (
|
|
||||||
<div className="py-6 text-center space-y-4">
|
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-500/10">
|
|
||||||
<GitPullRequest className="w-8 h-8 text-green-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold">Pull Request Created!</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Your PR is ready for review
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => window.open(prUrl, "_blank")}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-4 h-4" />
|
|
||||||
View Pull Request
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : shouldShowBrowserFallback ? (
|
|
||||||
<div className="py-6 text-center space-y-4">
|
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-500/10">
|
|
||||||
<GitPullRequest className="w-8 h-8 text-blue-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold">Branch Pushed!</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Your changes have been pushed to GitHub.
|
|
||||||
<br />
|
|
||||||
Click below to create a pull request in your browser.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
if (browserUrl) {
|
|
||||||
window.open(browserUrl, "_blank");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="gap-2 w-full"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-4 h-4" />
|
|
||||||
Create PR in Browser
|
|
||||||
</Button>
|
|
||||||
<div className="p-2 bg-muted rounded text-xs break-all font-mono">
|
|
||||||
{browserUrl}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Tip: Install the GitHub CLI (<code className="bg-muted px-1 rounded">gh</code>) to create PRs directly from the app
|
|
||||||
</p>
|
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<Button variant="outline" onClick={handleClose}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
{worktree.hasChanges && (
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="commit-message">
|
|
||||||
Commit Message{" "}
|
|
||||||
<span className="text-muted-foreground">(optional)</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="commit-message"
|
|
||||||
placeholder="Leave empty to auto-generate"
|
|
||||||
value={commitMessage}
|
|
||||||
onChange={(e) => setCommitMessage(e.target.value)}
|
|
||||||
className="font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{worktree.changedFilesCount} uncommitted file(s) will be
|
|
||||||
committed
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="pr-title">PR Title</Label>
|
|
||||||
<Input
|
|
||||||
id="pr-title"
|
|
||||||
placeholder={worktree.branch}
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="pr-body">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="pr-body"
|
|
||||||
placeholder="Describe the changes in this PR..."
|
|
||||||
value={body}
|
|
||||||
onChange={(e) => setBody(e.target.value)}
|
|
||||||
className="min-h-[80px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="base-branch">Base Branch</Label>
|
|
||||||
<Input
|
|
||||||
id="base-branch"
|
|
||||||
placeholder="main"
|
|
||||||
value={baseBranch}
|
|
||||||
onChange={(e) => setBaseBranch(e.target.value)}
|
|
||||||
className="font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="draft"
|
|
||||||
checked={isDraft}
|
|
||||||
onCheckedChange={(checked) => setIsDraft(checked === true)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="draft" className="cursor-pointer">
|
|
||||||
Create as draft
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="ghost" onClick={handleClose} disabled={isLoading}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCreate} disabled={isLoading}>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Creating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<GitPullRequest className="w-4 h-4 mr-2" />
|
|
||||||
Create PR
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,158 +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 } from "lucide-react";
|
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface WorktreeInfo {
|
|
||||||
path: string;
|
|
||||||
branch: string;
|
|
||||||
isMain: boolean;
|
|
||||||
hasChanges?: boolean;
|
|
||||||
changedFilesCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeleteWorktreeDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
projectPath: string;
|
|
||||||
worktree: WorktreeInfo | null;
|
|
||||||
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeleteWorktreeDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
projectPath,
|
|
||||||
worktree,
|
|
||||||
onDeleted,
|
|
||||||
}: DeleteWorktreeDialogProps) {
|
|
||||||
const [deleteBranch, setDeleteBranch] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!worktree) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.worktree?.delete) {
|
|
||||||
toast.error("Worktree API not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.worktree.delete(
|
|
||||||
projectPath,
|
|
||||||
worktree.path,
|
|
||||||
deleteBranch
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(`Worktree deleted`, {
|
|
||||||
description: deleteBranch
|
|
||||||
? `Branch "${worktree.branch}" was also deleted`
|
|
||||||
: `Branch "${worktree.branch}" was kept`,
|
|
||||||
});
|
|
||||||
onDeleted(worktree, deleteBranch);
|
|
||||||
onOpenChange(false);
|
|
||||||
setDeleteBranch(false);
|
|
||||||
} else {
|
|
||||||
toast.error("Failed to delete worktree", {
|
|
||||||
description: result.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast.error("Failed to delete worktree", {
|
|
||||||
description: err instanceof Error ? err.message : "Unknown error",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!worktree) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<Trash2 className="w-5 h-5 text-destructive" />
|
|
||||||
Delete Worktree
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="space-y-3">
|
|
||||||
<span>
|
|
||||||
Are you sure you want to delete the worktree for branch{" "}
|
|
||||||
<code className="font-mono bg-muted px-1 rounded">
|
|
||||||
{worktree.branch}
|
|
||||||
</code>
|
|
||||||
?
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{worktree.hasChanges && (
|
|
||||||
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
|
|
||||||
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<span className="text-yellow-500 text-sm">
|
|
||||||
This worktree has {worktree.changedFilesCount} uncommitted
|
|
||||||
change(s). These will be lost if you proceed.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 py-4">
|
|
||||||
<Checkbox
|
|
||||||
id="delete-branch"
|
|
||||||
checked={deleteBranch}
|
|
||||||
onCheckedChange={(checked) => setDeleteBranch(checked === true)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="delete-branch" className="text-sm cursor-pointer">
|
|
||||||
Also delete the branch{" "}
|
|
||||||
<code className="font-mono bg-muted px-1 rounded">
|
|
||||||
{worktree.branch}
|
|
||||||
</code>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Deleting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
|
||||||
Delete
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,527 +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 { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
|
|
||||||
import {
|
|
||||||
DescriptionImageDropZone,
|
|
||||||
FeatureImagePath as DescriptionImagePath,
|
|
||||||
ImagePreviewMap,
|
|
||||||
} from "@/components/ui/description-image-dropzone";
|
|
||||||
import {
|
|
||||||
MessageSquare,
|
|
||||||
Settings2,
|
|
||||||
SlidersHorizontal,
|
|
||||||
FlaskConical,
|
|
||||||
Sparkles,
|
|
||||||
ChevronDown,
|
|
||||||
GitBranch,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
|
||||||
import { modelSupportsThinking } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
Feature,
|
|
||||||
AgentModel,
|
|
||||||
ThinkingLevel,
|
|
||||||
AIProfile,
|
|
||||||
useAppStore,
|
|
||||||
PlanningMode,
|
|
||||||
} from "@/store/app-store";
|
|
||||||
import {
|
|
||||||
ModelSelector,
|
|
||||||
ThinkingLevelSelector,
|
|
||||||
ProfileQuickSelect,
|
|
||||||
TestingTabContent,
|
|
||||||
PrioritySelector,
|
|
||||||
PlanningModeSelector,
|
|
||||||
} from "../shared";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { DependencyTreeDialog } from "./dependency-tree-dialog";
|
|
||||||
|
|
||||||
interface EditFeatureDialogProps {
|
|
||||||
feature: Feature | null;
|
|
||||||
onClose: () => void;
|
|
||||||
onUpdate: (
|
|
||||||
featureId: string,
|
|
||||||
updates: {
|
|
||||||
category: string;
|
|
||||||
description: string;
|
|
||||||
steps: string[];
|
|
||||||
skipTests: boolean;
|
|
||||||
model: AgentModel;
|
|
||||||
thinkingLevel: ThinkingLevel;
|
|
||||||
imagePaths: DescriptionImagePath[];
|
|
||||||
branchName: string;
|
|
||||||
priority: number;
|
|
||||||
planningMode: PlanningMode;
|
|
||||||
requirePlanApproval: boolean;
|
|
||||||
}
|
|
||||||
) => void;
|
|
||||||
categorySuggestions: string[];
|
|
||||||
branchSuggestions: string[];
|
|
||||||
isMaximized: boolean;
|
|
||||||
showProfilesOnly: boolean;
|
|
||||||
aiProfiles: AIProfile[];
|
|
||||||
allFeatures: Feature[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditFeatureDialog({
|
|
||||||
feature,
|
|
||||||
onClose,
|
|
||||||
onUpdate,
|
|
||||||
categorySuggestions,
|
|
||||||
branchSuggestions,
|
|
||||||
isMaximized,
|
|
||||||
showProfilesOnly,
|
|
||||||
aiProfiles,
|
|
||||||
allFeatures,
|
|
||||||
}: EditFeatureDialogProps) {
|
|
||||||
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
|
||||||
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
|
|
||||||
useState<ImagePreviewMap>(() => new Map());
|
|
||||||
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
|
|
||||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
|
||||||
const [enhancementMode, setEnhancementMode] = useState<
|
|
||||||
"improve" | "technical" | "simplify" | "acceptance"
|
|
||||||
>("improve");
|
|
||||||
const [showDependencyTree, setShowDependencyTree] = useState(false);
|
|
||||||
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
|
|
||||||
const [requirePlanApproval, setRequirePlanApproval] = useState(feature?.requirePlanApproval ?? false);
|
|
||||||
|
|
||||||
// Get enhancement model and worktrees setting from store
|
|
||||||
const { enhancementModel, useWorktrees } = useAppStore();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setEditingFeature(feature);
|
|
||||||
if (feature) {
|
|
||||||
setPlanningMode(feature.planningMode ?? 'skip');
|
|
||||||
setRequirePlanApproval(feature.requirePlanApproval ?? false);
|
|
||||||
} else {
|
|
||||||
setEditFeaturePreviewMap(new Map());
|
|
||||||
setShowEditAdvancedOptions(false);
|
|
||||||
}
|
|
||||||
}, [feature]);
|
|
||||||
|
|
||||||
const handleUpdate = () => {
|
|
||||||
if (!editingFeature) return;
|
|
||||||
|
|
||||||
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
|
|
||||||
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
|
|
||||||
selectedModel
|
|
||||||
)
|
|
||||||
? editingFeature.thinkingLevel ?? "none"
|
|
||||||
: "none";
|
|
||||||
|
|
||||||
const updates = {
|
|
||||||
category: editingFeature.category,
|
|
||||||
description: editingFeature.description,
|
|
||||||
steps: editingFeature.steps,
|
|
||||||
skipTests: editingFeature.skipTests ?? false,
|
|
||||||
model: selectedModel,
|
|
||||||
thinkingLevel: normalizedThinking,
|
|
||||||
imagePaths: editingFeature.imagePaths ?? [],
|
|
||||||
branchName: editingFeature.branchName ?? "main",
|
|
||||||
priority: editingFeature.priority ?? 2,
|
|
||||||
planningMode,
|
|
||||||
requirePlanApproval,
|
|
||||||
};
|
|
||||||
|
|
||||||
onUpdate(editingFeature.id, updates);
|
|
||||||
setEditFeaturePreviewMap(new Map());
|
|
||||||
setShowEditAdvancedOptions(false);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDialogClose = (open: boolean) => {
|
|
||||||
if (!open) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleModelSelect = (model: AgentModel) => {
|
|
||||||
if (!editingFeature) return;
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
model,
|
|
||||||
thinkingLevel: modelSupportsThinking(model)
|
|
||||||
? editingFeature.thinkingLevel
|
|
||||||
: "none",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProfileSelect = (
|
|
||||||
model: AgentModel,
|
|
||||||
thinkingLevel: ThinkingLevel
|
|
||||||
) => {
|
|
||||||
if (!editingFeature) return;
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
model,
|
|
||||||
thinkingLevel,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEnhanceDescription = async () => {
|
|
||||||
if (!editingFeature?.description.trim() || isEnhancing) return;
|
|
||||||
|
|
||||||
setIsEnhancing(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
const result = await api.enhancePrompt?.enhance(
|
|
||||||
editingFeature.description,
|
|
||||||
enhancementMode,
|
|
||||||
enhancementModel
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result?.success && result.enhancedText) {
|
|
||||||
const enhancedText = result.enhancedText;
|
|
||||||
setEditingFeature((prev) =>
|
|
||||||
prev ? { ...prev, description: enhancedText } : prev
|
|
||||||
);
|
|
||||||
toast.success("Description enhanced!");
|
|
||||||
} else {
|
|
||||||
toast.error(result?.error || "Failed to enhance description");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Enhancement failed:", error);
|
|
||||||
toast.error("Failed to enhance description");
|
|
||||||
} finally {
|
|
||||||
setIsEnhancing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const editModelAllowsThinking = modelSupportsThinking(editingFeature?.model);
|
|
||||||
|
|
||||||
if (!editingFeature) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={!!editingFeature} onOpenChange={handleDialogClose}>
|
|
||||||
<DialogContent
|
|
||||||
compact={!isMaximized}
|
|
||||||
data-testid="edit-feature-dialog"
|
|
||||||
onPointerDownOutside={(e: CustomEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onInteractOutside={(e: CustomEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Edit Feature</DialogTitle>
|
|
||||||
<DialogDescription>Modify the feature details.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Tabs
|
|
||||||
defaultValue="prompt"
|
|
||||||
className="py-4 flex-1 min-h-0 flex flex-col"
|
|
||||||
>
|
|
||||||
<TabsList className="w-full grid grid-cols-3 mb-4">
|
|
||||||
<TabsTrigger value="prompt" data-testid="edit-tab-prompt">
|
|
||||||
<MessageSquare className="w-4 h-4 mr-2" />
|
|
||||||
Prompt
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="model" data-testid="edit-tab-model">
|
|
||||||
<Settings2 className="w-4 h-4 mr-2" />
|
|
||||||
Model
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="options" data-testid="edit-tab-options">
|
|
||||||
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
|
||||||
Options
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* Prompt Tab */}
|
|
||||||
<TabsContent
|
|
||||||
value="prompt"
|
|
||||||
className="space-y-4 overflow-y-auto cursor-default"
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="edit-description">Description</Label>
|
|
||||||
<DescriptionImageDropZone
|
|
||||||
value={editingFeature.description}
|
|
||||||
onChange={(value) =>
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
description: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
images={editingFeature.imagePaths ?? []}
|
|
||||||
onImagesChange={(images) =>
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
imagePaths: images,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="Describe the feature..."
|
|
||||||
previewMap={editFeaturePreviewMap}
|
|
||||||
onPreviewMapChange={setEditFeaturePreviewMap}
|
|
||||||
data-testid="edit-feature-description"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-[180px] justify-between"
|
|
||||||
>
|
|
||||||
{enhancementMode === "improve" && "Improve Clarity"}
|
|
||||||
{enhancementMode === "technical" && "Add Technical Details"}
|
|
||||||
{enhancementMode === "simplify" && "Simplify"}
|
|
||||||
{enhancementMode === "acceptance" &&
|
|
||||||
"Add Acceptance Criteria"}
|
|
||||||
<ChevronDown className="w-4 h-4 ml-2" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setEnhancementMode("improve")}
|
|
||||||
>
|
|
||||||
Improve Clarity
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setEnhancementMode("technical")}
|
|
||||||
>
|
|
||||||
Add Technical Details
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setEnhancementMode("simplify")}
|
|
||||||
>
|
|
||||||
Simplify
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setEnhancementMode("acceptance")}
|
|
||||||
>
|
|
||||||
Add Acceptance Criteria
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleEnhanceDescription}
|
|
||||||
disabled={!editingFeature.description.trim() || isEnhancing}
|
|
||||||
loading={isEnhancing}
|
|
||||||
>
|
|
||||||
<Sparkles className="w-4 h-4 mr-2" />
|
|
||||||
Enhance with AI
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="edit-category">Category (optional)</Label>
|
|
||||||
<CategoryAutocomplete
|
|
||||||
value={editingFeature.category}
|
|
||||||
onChange={(value) =>
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
category: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
suggestions={categorySuggestions}
|
|
||||||
placeholder="e.g., Core, UI, API"
|
|
||||||
data-testid="edit-feature-category"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{useWorktrees && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="edit-branch">Target Branch</Label>
|
|
||||||
<BranchAutocomplete
|
|
||||||
value={editingFeature.branchName ?? "main"}
|
|
||||||
onChange={(value) =>
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
branchName: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
branches={branchSuggestions}
|
|
||||||
placeholder="Select or create branch..."
|
|
||||||
data-testid="edit-feature-branch"
|
|
||||||
disabled={editingFeature.status !== "backlog"}
|
|
||||||
/>
|
|
||||||
{editingFeature.status !== "backlog" && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Branch cannot be changed after work has started.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{editingFeature.status === "backlog" && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Work will be done in this branch. A worktree will be created
|
|
||||||
if needed.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Priority Selector */}
|
|
||||||
<PrioritySelector
|
|
||||||
selectedPriority={editingFeature.priority ?? 2}
|
|
||||||
onPrioritySelect={(priority) =>
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
priority,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
testIdPrefix="edit-priority"
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Model Tab */}
|
|
||||||
<TabsContent
|
|
||||||
value="model"
|
|
||||||
className="space-y-4 overflow-y-auto cursor-default"
|
|
||||||
>
|
|
||||||
{/* Show Advanced Options Toggle */}
|
|
||||||
{showProfilesOnly && (
|
|
||||||
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium text-foreground">
|
|
||||||
Simple Mode Active
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Only showing AI profiles. Advanced model tweaking is hidden.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
setShowEditAdvancedOptions(!showEditAdvancedOptions)
|
|
||||||
}
|
|
||||||
data-testid="edit-show-advanced-options-toggle"
|
|
||||||
>
|
|
||||||
<Settings2 className="w-4 h-4 mr-2" />
|
|
||||||
{showEditAdvancedOptions ? "Hide" : "Show"} Advanced
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quick Select Profile Section */}
|
|
||||||
<ProfileQuickSelect
|
|
||||||
profiles={aiProfiles}
|
|
||||||
selectedModel={editingFeature.model ?? "opus"}
|
|
||||||
selectedThinkingLevel={editingFeature.thinkingLevel ?? "none"}
|
|
||||||
onSelect={handleProfileSelect}
|
|
||||||
testIdPrefix="edit-profile-quick-select"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Separator */}
|
|
||||||
{aiProfiles.length > 0 &&
|
|
||||||
(!showProfilesOnly || showEditAdvancedOptions) && (
|
|
||||||
<div className="border-t border-border" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Claude Models Section */}
|
|
||||||
{(!showProfilesOnly || showEditAdvancedOptions) && (
|
|
||||||
<>
|
|
||||||
<ModelSelector
|
|
||||||
selectedModel={(editingFeature.model ?? "opus") as AgentModel}
|
|
||||||
onModelSelect={handleModelSelect}
|
|
||||||
testIdPrefix="edit-model-select"
|
|
||||||
/>
|
|
||||||
{editModelAllowsThinking && (
|
|
||||||
<ThinkingLevelSelector
|
|
||||||
selectedLevel={editingFeature.thinkingLevel ?? "none"}
|
|
||||||
onLevelSelect={(level) =>
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
thinkingLevel: level,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
testIdPrefix="edit-thinking-level"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Options Tab */}
|
|
||||||
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
|
|
||||||
{/* Planning Mode Section */}
|
|
||||||
<PlanningModeSelector
|
|
||||||
mode={planningMode}
|
|
||||||
onModeChange={setPlanningMode}
|
|
||||||
requireApproval={requirePlanApproval}
|
|
||||||
onRequireApprovalChange={setRequirePlanApproval}
|
|
||||||
featureDescription={editingFeature.description}
|
|
||||||
testIdPrefix="edit-feature"
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="border-t border-border my-4" />
|
|
||||||
|
|
||||||
{/* Testing Section */}
|
|
||||||
<TestingTabContent
|
|
||||||
skipTests={editingFeature.skipTests ?? false}
|
|
||||||
onSkipTestsChange={(skipTests) =>
|
|
||||||
setEditingFeature({ ...editingFeature, skipTests })
|
|
||||||
}
|
|
||||||
steps={editingFeature.steps}
|
|
||||||
onStepsChange={(steps) =>
|
|
||||||
setEditingFeature({ ...editingFeature, steps })
|
|
||||||
}
|
|
||||||
testIdPrefix="edit"
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
<DialogFooter className="sm:!justify-between">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowDependencyTree(true)}
|
|
||||||
className="gap-2 h-10"
|
|
||||||
>
|
|
||||||
<GitBranch className="w-4 h-4" />
|
|
||||||
View Dependency Tree
|
|
||||||
</Button>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="ghost" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<HotkeyButton
|
|
||||||
onClick={handleUpdate}
|
|
||||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
|
||||||
hotkeyActive={!!editingFeature}
|
|
||||||
data-testid="confirm-edit-feature"
|
|
||||||
>
|
|
||||||
Save Changes
|
|
||||||
</HotkeyButton>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
|
|
||||||
<DependencyTreeDialog
|
|
||||||
open={showDependencyTree}
|
|
||||||
onClose={() => setShowDependencyTree(false)}
|
|
||||||
feature={editingFeature}
|
|
||||||
allFeatures={allFeatures}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 { DeleteAllVerifiedDialog } from "./delete-all-verified-dialog";
|
|
||||||
export { DeleteCompletedFeatureDialog } from "./delete-completed-feature-dialog";
|
|
||||||
export { EditFeatureDialog } from "./edit-feature-dialog";
|
|
||||||
export { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
|
|
||||||
export { FollowUpDialog } from "./follow-up-dialog";
|
|
||||||
export { PlanApprovalDialog } from "./plan-approval-dialog";
|
|
||||||
@@ -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,900 +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,
|
|
||||||
} = useAppStore();
|
|
||||||
const autoMode = useAutoMode();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or create the worktree path for a feature based on its branchName.
|
|
||||||
* - If branchName is "main" or empty, returns the project path
|
|
||||||
* - Otherwise, creates a worktree for that branch if needed
|
|
||||||
*/
|
|
||||||
const getOrCreateWorktreeForFeature = useCallback(
|
|
||||||
async (feature: Feature): Promise<string | null> => {
|
|
||||||
if (!projectPath) return null;
|
|
||||||
|
|
||||||
const branchName = feature.branchName || "main";
|
|
||||||
|
|
||||||
// If targeting main branch, use the project path directly
|
|
||||||
if (branchName === "main" || branchName === "master") {
|
|
||||||
return projectPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other branches, create a worktree if it doesn't exist
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.worktree?.create) {
|
|
||||||
console.error("[BoardActions] Worktree API not available");
|
|
||||||
return projectPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to create the worktree (will return existing if already exists)
|
|
||||||
const result = await api.worktree.create(projectPath, branchName);
|
|
||||||
|
|
||||||
if (result.success && result.worktree) {
|
|
||||||
console.log(
|
|
||||||
`[BoardActions] Worktree ready for branch "${branchName}": ${result.worktree.path}`
|
|
||||||
);
|
|
||||||
if (result.worktree.isNew) {
|
|
||||||
toast.success(`Worktree created for branch "${branchName}"`, {
|
|
||||||
description: "A new worktree was created for this feature.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result.worktree.path;
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
"[BoardActions] Failed to create worktree:",
|
|
||||||
result.error
|
|
||||||
);
|
|
||||||
toast.error("Failed to create worktree", {
|
|
||||||
description:
|
|
||||||
result.error || "Could not create worktree for this branch.",
|
|
||||||
});
|
|
||||||
return projectPath; // Fall back to project path
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[BoardActions] Error creating worktree:", error);
|
|
||||||
toast.error("Error creating worktree", {
|
|
||||||
description: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
});
|
|
||||||
return projectPath; // Fall back to project path
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[projectPath]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddFeature = useCallback(
|
|
||||||
async (featureData: {
|
|
||||||
category: string;
|
|
||||||
description: string;
|
|
||||||
steps: string[];
|
|
||||||
images: FeatureImage[];
|
|
||||||
imagePaths: DescriptionImagePath[];
|
|
||||||
skipTests: boolean;
|
|
||||||
model: AgentModel;
|
|
||||||
thinkingLevel: ThinkingLevel;
|
|
||||||
branchName: string;
|
|
||||||
priority: number;
|
|
||||||
planningMode: PlanningMode;
|
|
||||||
requirePlanApproval: boolean;
|
|
||||||
}) => {
|
|
||||||
let worktreePath: string | undefined;
|
|
||||||
|
|
||||||
// If worktrees are enabled and a non-main branch is selected, create the worktree
|
|
||||||
if (useWorktrees && featureData.branchName) {
|
|
||||||
const branchName = featureData.branchName;
|
|
||||||
if (branchName !== "main" && branchName !== "master") {
|
|
||||||
// Create a temporary feature-like object for getOrCreateWorktreeForFeature
|
|
||||||
const tempFeature = { branchName } as Feature;
|
|
||||||
const path = await getOrCreateWorktreeForFeature(tempFeature);
|
|
||||||
if (path && path !== projectPath) {
|
|
||||||
worktreePath = path;
|
|
||||||
// Refresh worktree selector after creating worktree
|
|
||||||
onWorktreeCreated?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newFeatureData = {
|
|
||||||
...featureData,
|
|
||||||
status: "backlog" as const,
|
|
||||||
worktreePath,
|
|
||||||
};
|
|
||||||
const createdFeature = addFeature(newFeatureData);
|
|
||||||
// Must await to ensure feature exists on server before user can drag it
|
|
||||||
await persistFeatureCreate(createdFeature);
|
|
||||||
saveCategory(featureData.category);
|
|
||||||
},
|
|
||||||
[addFeature, persistFeatureCreate, saveCategory, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUpdateFeature = useCallback(
|
|
||||||
async (
|
|
||||||
featureId: string,
|
|
||||||
updates: {
|
|
||||||
category: string;
|
|
||||||
description: string;
|
|
||||||
steps: string[];
|
|
||||||
skipTests: boolean;
|
|
||||||
model: AgentModel;
|
|
||||||
thinkingLevel: ThinkingLevel;
|
|
||||||
imagePaths: DescriptionImagePath[];
|
|
||||||
branchName: string;
|
|
||||||
priority: number;
|
|
||||||
planningMode?: PlanningMode;
|
|
||||||
requirePlanApproval?: boolean;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
// Get the current feature to check if branch is changing
|
|
||||||
const currentFeature = features.find((f) => f.id === featureId);
|
|
||||||
const currentBranch = currentFeature?.branchName || "main";
|
|
||||||
const newBranch = updates.branchName || "main";
|
|
||||||
const branchIsChanging = currentBranch !== newBranch;
|
|
||||||
|
|
||||||
let worktreePath: string | undefined;
|
|
||||||
let shouldClearWorktreePath = false;
|
|
||||||
|
|
||||||
// If worktrees are enabled and branch is changing to a non-main branch, create worktree
|
|
||||||
if (useWorktrees && branchIsChanging) {
|
|
||||||
if (newBranch === "main" || newBranch === "master") {
|
|
||||||
// Changing to main - clear the worktreePath
|
|
||||||
shouldClearWorktreePath = true;
|
|
||||||
} else {
|
|
||||||
// Changing to a feature branch - create worktree if needed
|
|
||||||
const tempFeature = { branchName: newBranch } as Feature;
|
|
||||||
const path = await getOrCreateWorktreeForFeature(tempFeature);
|
|
||||||
if (path && path !== projectPath) {
|
|
||||||
worktreePath = path;
|
|
||||||
// Refresh worktree selector after creating worktree
|
|
||||||
onWorktreeCreated?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build final updates with worktreePath if it was changed
|
|
||||||
let finalUpdates: typeof updates & { worktreePath?: string };
|
|
||||||
if (branchIsChanging && useWorktrees) {
|
|
||||||
if (shouldClearWorktreePath) {
|
|
||||||
// Use null to clear the value in persistence (cast to work around type system)
|
|
||||||
finalUpdates = { ...updates, worktreePath: null as unknown as string | undefined };
|
|
||||||
} else {
|
|
||||||
finalUpdates = { ...updates, worktreePath };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
finalUpdates = updates;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFeature(featureId, finalUpdates);
|
|
||||||
persistFeatureUpdate(featureId, finalUpdates);
|
|
||||||
if (updates.category) {
|
|
||||||
saveCategory(updates.category);
|
|
||||||
}
|
|
||||||
setEditingFeature(null);
|
|
||||||
},
|
|
||||||
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, features, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDeleteFeature = useCallback(
|
|
||||||
async (featureId: string) => {
|
|
||||||
const feature = features.find((f) => f.id === featureId);
|
|
||||||
if (!feature) return;
|
|
||||||
|
|
||||||
const isRunning = runningAutoTasks.includes(featureId);
|
|
||||||
|
|
||||||
if (isRunning) {
|
|
||||||
try {
|
|
||||||
await autoMode.stopFeature(featureId);
|
|
||||||
toast.success("Agent stopped", {
|
|
||||||
description: `Stopped and deleted: ${truncateDescription(
|
|
||||||
feature.description
|
|
||||||
)}`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Board] Error stopping feature before delete:", error);
|
|
||||||
toast.error("Failed to stop agent", {
|
|
||||||
description: "The feature will still be deleted.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (feature.imagePaths && feature.imagePaths.length > 0) {
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
for (const imagePathObj of feature.imagePaths) {
|
|
||||||
try {
|
|
||||||
await api.deleteFile(imagePathObj.path);
|
|
||||||
console.log(`[Board] Deleted image: ${imagePathObj.path}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`[Board] Failed to delete image ${imagePathObj.path}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`[Board] Error deleting images for feature ${featureId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeFeature(featureId);
|
|
||||||
persistFeatureDelete(featureId);
|
|
||||||
},
|
|
||||||
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRunFeature = useCallback(
|
|
||||||
async (feature: Feature) => {
|
|
||||||
if (!currentProject) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.autoMode) {
|
|
||||||
console.error("Auto mode API not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the feature's assigned worktreePath (set when moving to in_progress)
|
|
||||||
// This ensures work happens in the correct worktree based on the feature's branchName
|
|
||||||
const featureWorktreePath = feature.worktreePath;
|
|
||||||
|
|
||||||
const result = await api.autoMode.runFeature(
|
|
||||||
currentProject.path,
|
|
||||||
feature.id,
|
|
||||||
useWorktrees,
|
|
||||||
featureWorktreePath || undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
console.log(
|
|
||||||
"[Board] Feature run started successfully in worktree:",
|
|
||||||
featureWorktreePath || "main"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.error("[Board] Failed to run feature:", result.error);
|
|
||||||
await loadFeatures();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Board] Error running feature:", error);
|
|
||||||
await loadFeatures();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentProject, useWorktrees, loadFeatures]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleStartImplementation = useCallback(
|
|
||||||
async (feature: Feature) => {
|
|
||||||
if (!autoMode.canStartNewTask) {
|
|
||||||
toast.error("Concurrency limit reached", {
|
|
||||||
description: `You can only have ${autoMode.maxConcurrency} task${
|
|
||||||
autoMode.maxConcurrency > 1 ? "s" : ""
|
|
||||||
} running at a time. Wait for a task to complete or increase the limit.`,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for blocking dependencies and show warning if enabled
|
|
||||||
if (enableDependencyBlocking) {
|
|
||||||
const blockingDeps = getBlockingDependencies(feature, features);
|
|
||||||
if (blockingDeps.length > 0) {
|
|
||||||
const depDescriptions = blockingDeps.map(depId => {
|
|
||||||
const dep = features.find(f => f.id === depId);
|
|
||||||
return dep ? truncateDescription(dep.description, 40) : depId;
|
|
||||||
}).join(", ");
|
|
||||||
|
|
||||||
toast.warning("Starting feature with incomplete dependencies", {
|
|
||||||
description: `This feature depends on: ${depDescriptions}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updates = {
|
|
||||||
status: "in_progress" as const,
|
|
||||||
startedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
updateFeature(feature.id, updates);
|
|
||||||
// Must await to ensure feature status is persisted before starting agent
|
|
||||||
await persistFeatureUpdate(feature.id, updates);
|
|
||||||
console.log("[Board] Feature moved to in_progress, starting agent...");
|
|
||||||
await handleRunFeature(feature);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
[autoMode, enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleVerifyFeature = useCallback(
|
|
||||||
async (feature: Feature) => {
|
|
||||||
if (!currentProject) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.autoMode) {
|
|
||||||
console.error("Auto mode API not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await api.autoMode.verifyFeature(
|
|
||||||
currentProject.path,
|
|
||||||
feature.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
console.log("[Board] Feature verification started successfully");
|
|
||||||
} else {
|
|
||||||
console.error("[Board] Failed to verify feature:", result.error);
|
|
||||||
await loadFeatures();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Board] Error verifying feature:", error);
|
|
||||||
await loadFeatures();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentProject, loadFeatures]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleResumeFeature = useCallback(
|
|
||||||
async (feature: Feature) => {
|
|
||||||
if (!currentProject) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.autoMode) {
|
|
||||||
console.error("Auto mode API not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await api.autoMode.resumeFeature(
|
|
||||||
currentProject.path,
|
|
||||||
feature.id,
|
|
||||||
useWorktrees
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
console.log("[Board] Feature resume started successfully");
|
|
||||||
} else {
|
|
||||||
console.error("[Board] Failed to resume feature:", result.error);
|
|
||||||
await loadFeatures();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Board] Error resuming feature:", error);
|
|
||||||
await loadFeatures();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentProject, loadFeatures, useWorktrees]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleManualVerify = useCallback(
|
|
||||||
(feature: Feature) => {
|
|
||||||
moveFeature(feature.id, "verified");
|
|
||||||
persistFeatureUpdate(feature.id, {
|
|
||||||
status: "verified",
|
|
||||||
justFinishedAt: undefined,
|
|
||||||
});
|
|
||||||
toast.success("Feature verified", {
|
|
||||||
description: `Marked as verified: ${truncateDescription(
|
|
||||||
feature.description
|
|
||||||
)}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[moveFeature, persistFeatureUpdate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMoveBackToInProgress = useCallback(
|
|
||||||
(feature: Feature) => {
|
|
||||||
const updates = {
|
|
||||||
status: "in_progress" as const,
|
|
||||||
startedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
updateFeature(feature.id, updates);
|
|
||||||
persistFeatureUpdate(feature.id, updates);
|
|
||||||
toast.info("Feature moved back", {
|
|
||||||
description: `Moved back to In Progress: ${truncateDescription(
|
|
||||||
feature.description
|
|
||||||
)}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[updateFeature, persistFeatureUpdate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleOpenFollowUp = useCallback(
|
|
||||||
(feature: Feature) => {
|
|
||||||
setFollowUpFeature(feature);
|
|
||||||
setFollowUpPrompt("");
|
|
||||||
setFollowUpImagePaths([]);
|
|
||||||
setShowFollowUpDialog(true);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
setFollowUpFeature,
|
|
||||||
setFollowUpPrompt,
|
|
||||||
setFollowUpImagePaths,
|
|
||||||
setShowFollowUpDialog,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSendFollowUp = useCallback(async () => {
|
|
||||||
if (!currentProject || !followUpFeature || !followUpPrompt.trim()) return;
|
|
||||||
|
|
||||||
const featureId = followUpFeature.id;
|
|
||||||
const featureDescription = followUpFeature.description;
|
|
||||||
const prompt = followUpPrompt;
|
|
||||||
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.autoMode?.followUpFeature) {
|
|
||||||
console.error("Follow-up feature API not available");
|
|
||||||
toast.error("Follow-up not available", {
|
|
||||||
description: "This feature is not available in the current version.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updates = {
|
|
||||||
status: "in_progress" as const,
|
|
||||||
startedAt: new Date().toISOString(),
|
|
||||||
justFinishedAt: undefined,
|
|
||||||
};
|
|
||||||
updateFeature(featureId, updates);
|
|
||||||
persistFeatureUpdate(featureId, updates);
|
|
||||||
|
|
||||||
setShowFollowUpDialog(false);
|
|
||||||
setFollowUpFeature(null);
|
|
||||||
setFollowUpPrompt("");
|
|
||||||
setFollowUpImagePaths([]);
|
|
||||||
setFollowUpPreviewMap(new Map());
|
|
||||||
|
|
||||||
toast.success("Follow-up started", {
|
|
||||||
description: `Continuing work on: ${truncateDescription(
|
|
||||||
featureDescription
|
|
||||||
)}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const imagePaths = followUpImagePaths.map((img) => img.path);
|
|
||||||
// Use the feature's worktreePath to ensure work happens in the correct branch
|
|
||||||
const featureWorktreePath = followUpFeature.worktreePath;
|
|
||||||
api.autoMode
|
|
||||||
.followUpFeature(
|
|
||||||
currentProject.path,
|
|
||||||
followUpFeature.id,
|
|
||||||
followUpPrompt,
|
|
||||||
imagePaths,
|
|
||||||
featureWorktreePath
|
|
||||||
)
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[Board] Error sending follow-up:", error);
|
|
||||||
toast.error("Failed to send follow-up", {
|
|
||||||
description:
|
|
||||||
error instanceof Error ? error.message : "An error occurred",
|
|
||||||
});
|
|
||||||
loadFeatures();
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
currentProject,
|
|
||||||
followUpFeature,
|
|
||||||
followUpPrompt,
|
|
||||||
followUpImagePaths,
|
|
||||||
updateFeature,
|
|
||||||
persistFeatureUpdate,
|
|
||||||
setShowFollowUpDialog,
|
|
||||||
setFollowUpFeature,
|
|
||||||
setFollowUpPrompt,
|
|
||||||
setFollowUpImagePaths,
|
|
||||||
setFollowUpPreviewMap,
|
|
||||||
loadFeatures,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleCommitFeature = useCallback(
|
|
||||||
async (feature: Feature) => {
|
|
||||||
if (!currentProject) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.autoMode?.commitFeature) {
|
|
||||||
console.error("Commit feature API not available");
|
|
||||||
toast.error("Commit not available", {
|
|
||||||
description:
|
|
||||||
"This feature is not available in the current version.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass the feature's worktreePath to ensure commits happen in the correct worktree
|
|
||||||
const result = await api.autoMode.commitFeature(
|
|
||||||
currentProject.path,
|
|
||||||
feature.id,
|
|
||||||
feature.worktreePath
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
moveFeature(feature.id, "verified");
|
|
||||||
persistFeatureUpdate(feature.id, { status: "verified" });
|
|
||||||
toast.success("Feature committed", {
|
|
||||||
description: `Committed and verified: ${truncateDescription(
|
|
||||||
feature.description
|
|
||||||
)}`,
|
|
||||||
});
|
|
||||||
// Refresh worktree selector to update commit counts
|
|
||||||
onWorktreeCreated?.();
|
|
||||||
} else {
|
|
||||||
console.error("[Board] Failed to commit feature:", result.error);
|
|
||||||
toast.error("Failed to commit feature", {
|
|
||||||
description: result.error || "An error occurred",
|
|
||||||
});
|
|
||||||
await loadFeatures();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Board] Error committing feature:", error);
|
|
||||||
toast.error("Failed to commit feature", {
|
|
||||||
description:
|
|
||||||
error instanceof Error ? error.message : "An error occurred",
|
|
||||||
});
|
|
||||||
await loadFeatures();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
currentProject,
|
|
||||||
moveFeature,
|
|
||||||
persistFeatureUpdate,
|
|
||||||
loadFeatures,
|
|
||||||
onWorktreeCreated,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMergeFeature = useCallback(
|
|
||||||
async (feature: Feature) => {
|
|
||||||
if (!currentProject) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.worktree?.mergeFeature) {
|
|
||||||
console.error("Worktree API not available");
|
|
||||||
toast.error("Merge not available", {
|
|
||||||
description:
|
|
||||||
"This feature is not available in the current version.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await api.worktree.mergeFeature(
|
|
||||||
currentProject.path,
|
|
||||||
feature.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
await loadFeatures();
|
|
||||||
toast.success("Feature merged", {
|
|
||||||
description: `Changes merged to main branch: ${truncateDescription(
|
|
||||||
feature.description
|
|
||||||
)}`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.error("[Board] Failed to merge feature:", result.error);
|
|
||||||
toast.error("Failed to merge feature", {
|
|
||||||
description: result.error || "An error occurred",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Board] Error merging feature:", error);
|
|
||||||
toast.error("Failed to merge feature", {
|
|
||||||
description:
|
|
||||||
error instanceof Error ? error.message : "An error occurred",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentProject, loadFeatures]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCompleteFeature = useCallback(
|
|
||||||
(feature: Feature) => {
|
|
||||||
const updates = {
|
|
||||||
status: "completed" as const,
|
|
||||||
};
|
|
||||||
updateFeature(feature.id, updates);
|
|
||||||
persistFeatureUpdate(feature.id, updates);
|
|
||||||
|
|
||||||
toast.success("Feature completed", {
|
|
||||||
description: `Archived: ${truncateDescription(feature.description)}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[updateFeature, persistFeatureUpdate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUnarchiveFeature = useCallback(
|
|
||||||
(feature: Feature) => {
|
|
||||||
const updates = {
|
|
||||||
status: "verified" as const,
|
|
||||||
};
|
|
||||||
updateFeature(feature.id, updates);
|
|
||||||
persistFeatureUpdate(feature.id, updates);
|
|
||||||
|
|
||||||
toast.success("Feature restored", {
|
|
||||||
description: `Moved back to verified: ${truncateDescription(
|
|
||||||
feature.description
|
|
||||||
)}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[updateFeature, persistFeatureUpdate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleViewOutput = useCallback(
|
|
||||||
(feature: Feature) => {
|
|
||||||
setOutputFeature(feature);
|
|
||||||
setShowOutputModal(true);
|
|
||||||
},
|
|
||||||
[setOutputFeature, setShowOutputModal]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleOutputModalNumberKeyPress = useCallback(
|
|
||||||
(key: string) => {
|
|
||||||
const index = key === "0" ? 9 : parseInt(key, 10) - 1;
|
|
||||||
const targetFeature = inProgressFeaturesForShortcuts[index];
|
|
||||||
|
|
||||||
if (!targetFeature) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetFeature.id === outputFeature?.id) {
|
|
||||||
setShowOutputModal(false);
|
|
||||||
} else {
|
|
||||||
setOutputFeature(targetFeature);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
inProgressFeaturesForShortcuts,
|
|
||||||
outputFeature?.id,
|
|
||||||
setShowOutputModal,
|
|
||||||
setOutputFeature,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleForceStopFeature = useCallback(
|
|
||||||
async (feature: Feature) => {
|
|
||||||
try {
|
|
||||||
await autoMode.stopFeature(feature.id);
|
|
||||||
|
|
||||||
const targetStatus =
|
|
||||||
feature.skipTests && feature.status === "waiting_approval"
|
|
||||||
? "waiting_approval"
|
|
||||||
: "backlog";
|
|
||||||
|
|
||||||
if (targetStatus !== feature.status) {
|
|
||||||
moveFeature(feature.id, targetStatus);
|
|
||||||
// Must await to ensure file is written before user can restart
|
|
||||||
await persistFeatureUpdate(feature.id, { status: targetStatus });
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Agent stopped", {
|
|
||||||
description:
|
|
||||||
targetStatus === "waiting_approval"
|
|
||||||
? `Stopped commit - returned to waiting approval: ${truncateDescription(
|
|
||||||
feature.description
|
|
||||||
)}`
|
|
||||||
: `Stopped working on: ${truncateDescription(
|
|
||||||
feature.description
|
|
||||||
)}`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Board] Error stopping feature:", error);
|
|
||||||
toast.error("Failed to stop agent", {
|
|
||||||
description:
|
|
||||||
error instanceof Error ? error.message : "An error occurred",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[autoMode, moveFeature, persistFeatureUpdate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleStartNextFeatures = useCallback(async () => {
|
|
||||||
// Filter backlog features by the currently selected worktree branch
|
|
||||||
// This ensures "G" only starts features from the filtered list
|
|
||||||
const backlogFeatures = features.filter((f) => {
|
|
||||||
if (f.status !== "backlog") return false;
|
|
||||||
|
|
||||||
// Determine the feature's branch (default to "main" if not set)
|
|
||||||
const featureBranch = f.branchName || "main";
|
|
||||||
|
|
||||||
// If no worktree is selected (currentWorktreeBranch is null or main-like),
|
|
||||||
// show features with no branch or "main"/"master" branch
|
|
||||||
if (
|
|
||||||
!currentWorktreeBranch ||
|
|
||||||
currentWorktreeBranch === "main" ||
|
|
||||||
currentWorktreeBranch === "master"
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
!f.branchName ||
|
|
||||||
featureBranch === "main" ||
|
|
||||||
featureBranch === "master"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, only show features matching the selected worktree branch
|
|
||||||
return featureBranch === currentWorktreeBranch;
|
|
||||||
});
|
|
||||||
|
|
||||||
const availableSlots =
|
|
||||||
useAppStore.getState().maxConcurrency - runningAutoTasks.length;
|
|
||||||
|
|
||||||
if (availableSlots <= 0) {
|
|
||||||
toast.error("Concurrency limit reached", {
|
|
||||||
description:
|
|
||||||
"Wait for a task to complete or increase the concurrency limit.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (backlogFeatures.length === 0) {
|
|
||||||
toast.info("Backlog empty", {
|
|
||||||
description:
|
|
||||||
currentWorktreeBranch &&
|
|
||||||
currentWorktreeBranch !== "main" &&
|
|
||||||
currentWorktreeBranch !== "master"
|
|
||||||
? `No features in backlog for branch "${currentWorktreeBranch}".`
|
|
||||||
: "No features in backlog to start.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by priority (lower number = higher priority, priority 1 is highest)
|
|
||||||
// This matches the auto mode service behavior for consistency
|
|
||||||
const sortedBacklog = [...backlogFeatures].sort(
|
|
||||||
(a, b) => (a.priority || 999) - (b.priority || 999)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Start only one feature per keypress (user must press again for next)
|
|
||||||
const featuresToStart = sortedBacklog.slice(0, 1);
|
|
||||||
|
|
||||||
for (const feature of featuresToStart) {
|
|
||||||
// Only create worktrees if the feature is enabled
|
|
||||||
let worktreePath: string | null = null;
|
|
||||||
if (useWorktrees) {
|
|
||||||
// Get or create worktree based on the feature's assigned branch (same as drag-to-in-progress)
|
|
||||||
worktreePath = await getOrCreateWorktreeForFeature(feature);
|
|
||||||
if (worktreePath) {
|
|
||||||
await persistFeatureUpdate(feature.id, { worktreePath });
|
|
||||||
}
|
|
||||||
// Refresh worktree selector after creating worktree
|
|
||||||
onWorktreeCreated?.();
|
|
||||||
}
|
|
||||||
// Start the implementation
|
|
||||||
// Pass feature with worktreePath so handleRunFeature uses the correct path
|
|
||||||
await handleStartImplementation({
|
|
||||||
...feature,
|
|
||||||
worktreePath: worktreePath || undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
features,
|
|
||||||
runningAutoTasks,
|
|
||||||
handleStartImplementation,
|
|
||||||
getOrCreateWorktreeForFeature,
|
|
||||||
persistFeatureUpdate,
|
|
||||||
onWorktreeCreated,
|
|
||||||
currentWorktreeBranch,
|
|
||||||
useWorktrees,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleDeleteAllVerified = useCallback(async () => {
|
|
||||||
const verifiedFeatures = features.filter((f) => f.status === "verified");
|
|
||||||
|
|
||||||
for (const feature of verifiedFeatures) {
|
|
||||||
const isRunning = runningAutoTasks.includes(feature.id);
|
|
||||||
if (isRunning) {
|
|
||||||
try {
|
|
||||||
await autoMode.stopFeature(feature.id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Board] Error stopping feature before delete:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
removeFeature(feature.id);
|
|
||||||
persistFeatureDelete(feature.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("All verified features deleted", {
|
|
||||||
description: `Deleted ${verifiedFeatures.length} feature(s).`,
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
features,
|
|
||||||
runningAutoTasks,
|
|
||||||
autoMode,
|
|
||||||
removeFeature,
|
|
||||||
persistFeatureDelete,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
handleAddFeature,
|
|
||||||
handleUpdateFeature,
|
|
||||||
handleDeleteFeature,
|
|
||||||
handleStartImplementation,
|
|
||||||
handleVerifyFeature,
|
|
||||||
handleResumeFeature,
|
|
||||||
handleManualVerify,
|
|
||||||
handleMoveBackToInProgress,
|
|
||||||
handleOpenFollowUp,
|
|
||||||
handleSendFollowUp,
|
|
||||||
handleCommitFeature,
|
|
||||||
handleMergeFeature,
|
|
||||||
handleCompleteFeature,
|
|
||||||
handleUnarchiveFeature,
|
|
||||||
handleViewOutput,
|
|
||||||
handleOutputModalNumberKeyPress,
|
|
||||||
handleForceStopFeature,
|
|
||||||
handleStartNextFeatures,
|
|
||||||
handleDeleteAllVerified,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,137 +0,0 @@
|
|||||||
import { useMemo, useCallback } from "react";
|
|
||||||
import { Feature } from "@/store/app-store";
|
|
||||||
import { resolveDependencies } from "@/lib/dependency-resolver";
|
|
||||||
import { pathsEqual } from "@/lib/utils";
|
|
||||||
|
|
||||||
type ColumnId = Feature["status"];
|
|
||||||
|
|
||||||
interface UseBoardColumnFeaturesProps {
|
|
||||||
features: Feature[];
|
|
||||||
runningAutoTasks: string[];
|
|
||||||
searchQuery: string;
|
|
||||||
currentWorktreePath: string | null; // Currently selected worktree path
|
|
||||||
currentWorktreeBranch: string | null; // Branch name of the selected worktree (null = main)
|
|
||||||
projectPath: string | null; // Main project path (for main worktree)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBoardColumnFeatures({
|
|
||||||
features,
|
|
||||||
runningAutoTasks,
|
|
||||||
searchQuery,
|
|
||||||
currentWorktreePath,
|
|
||||||
currentWorktreeBranch,
|
|
||||||
projectPath,
|
|
||||||
}: UseBoardColumnFeaturesProps) {
|
|
||||||
// Memoize column features to prevent unnecessary re-renders
|
|
||||||
const columnFeaturesMap = useMemo(() => {
|
|
||||||
const map: Record<ColumnId, Feature[]> = {
|
|
||||||
backlog: [],
|
|
||||||
in_progress: [],
|
|
||||||
waiting_approval: [],
|
|
||||||
verified: [],
|
|
||||||
completed: [], // Completed features are shown in the archive modal, not as a column
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter features by search query (case-insensitive)
|
|
||||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
|
||||||
const filteredFeatures = normalizedQuery
|
|
||||||
? features.filter(
|
|
||||||
(f) =>
|
|
||||||
f.description.toLowerCase().includes(normalizedQuery) ||
|
|
||||||
f.category?.toLowerCase().includes(normalizedQuery)
|
|
||||||
)
|
|
||||||
: features;
|
|
||||||
|
|
||||||
// Determine the effective worktree path and branch for filtering
|
|
||||||
// If currentWorktreePath is null, we're on the main worktree
|
|
||||||
const effectiveWorktreePath = currentWorktreePath || projectPath;
|
|
||||||
// Use the branch name from the selected worktree
|
|
||||||
// If we're selecting main (currentWorktreePath is null), currentWorktreeBranch
|
|
||||||
// should contain the main branch's actual name, defaulting to "main"
|
|
||||||
// If we're selecting a non-main worktree but can't find it, currentWorktreeBranch is null
|
|
||||||
// In that case, we can't do branch-based filtering, so we'll handle it specially below
|
|
||||||
const effectiveBranch = currentWorktreeBranch;
|
|
||||||
|
|
||||||
filteredFeatures.forEach((f) => {
|
|
||||||
// If feature has a running agent, always show it in "in_progress"
|
|
||||||
const isRunning = runningAutoTasks.includes(f.id);
|
|
||||||
|
|
||||||
// Check if feature matches the current worktree
|
|
||||||
// Match by worktreePath if set, OR by branchName if set
|
|
||||||
// Features with neither are considered unassigned (show on ALL worktrees)
|
|
||||||
const featureBranch = f.branchName || "main";
|
|
||||||
const hasWorktreeAssigned = f.worktreePath || f.branchName;
|
|
||||||
|
|
||||||
let matchesWorktree: boolean;
|
|
||||||
if (!hasWorktreeAssigned) {
|
|
||||||
// No worktree or branch assigned - show on ALL worktrees (unassigned)
|
|
||||||
matchesWorktree = true;
|
|
||||||
} else if (f.worktreePath) {
|
|
||||||
// Has worktreePath - match by path (use pathsEqual for cross-platform compatibility)
|
|
||||||
matchesWorktree = pathsEqual(f.worktreePath, effectiveWorktreePath);
|
|
||||||
} else if (effectiveBranch === null) {
|
|
||||||
// We're viewing main but branch hasn't been initialized yet
|
|
||||||
// (worktrees disabled or haven't loaded yet).
|
|
||||||
// Show features assigned to main/master branch since we're on the main worktree.
|
|
||||||
matchesWorktree = featureBranch === "main" || featureBranch === "master";
|
|
||||||
} else {
|
|
||||||
// Has branchName but no worktreePath - match by branch name
|
|
||||||
matchesWorktree = featureBranch === effectiveBranch;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRunning) {
|
|
||||||
// Only show running tasks if they match the current worktree
|
|
||||||
if (matchesWorktree) {
|
|
||||||
map.in_progress.push(f);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Otherwise, use the feature's status (fallback to backlog for unknown statuses)
|
|
||||||
const status = f.status as ColumnId;
|
|
||||||
|
|
||||||
// Filter all items by worktree, including backlog
|
|
||||||
// This ensures backlog items with a branch assigned only show in that branch
|
|
||||||
if (status === "backlog") {
|
|
||||||
if (matchesWorktree) {
|
|
||||||
map.backlog.push(f);
|
|
||||||
}
|
|
||||||
} else if (map[status]) {
|
|
||||||
// Only show if matches current worktree or has no worktree assigned
|
|
||||||
if (matchesWorktree) {
|
|
||||||
map[status].push(f);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Unknown status, default to backlog
|
|
||||||
map.backlog.push(f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Apply dependency-aware sorting to backlog
|
|
||||||
// This ensures features appear in dependency order (dependencies before dependents)
|
|
||||||
// Within the same dependency level, features are sorted by priority
|
|
||||||
if (map.backlog.length > 0) {
|
|
||||||
const { orderedFeatures } = resolveDependencies(map.backlog);
|
|
||||||
map.backlog = orderedFeatures;
|
|
||||||
}
|
|
||||||
|
|
||||||
return map;
|
|
||||||
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);
|
|
||||||
|
|
||||||
const getColumnFeatures = useCallback(
|
|
||||||
(columnId: ColumnId) => {
|
|
||||||
return columnFeaturesMap[columnId];
|
|
||||||
},
|
|
||||||
[columnFeaturesMap]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize completed features for the archive modal
|
|
||||||
const completedFeatures = useMemo(() => {
|
|
||||||
return features.filter((f) => f.status === "completed");
|
|
||||||
}, [features]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
columnFeaturesMap,
|
|
||||||
getColumnFeatures,
|
|
||||||
completedFeatures,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,294 +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";
|
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
|
||||||
|
|
||||||
interface UseBoardDragDropProps {
|
|
||||||
features: Feature[];
|
|
||||||
currentProject: { path: string; id: string } | null;
|
|
||||||
runningAutoTasks: string[];
|
|
||||||
persistFeatureUpdate: (
|
|
||||||
featureId: string,
|
|
||||||
updates: Partial<Feature>
|
|
||||||
) => Promise<void>;
|
|
||||||
handleStartImplementation: (feature: Feature) => Promise<boolean>;
|
|
||||||
projectPath: string | null; // Main project path
|
|
||||||
onWorktreeCreated?: () => void; // Callback when a new worktree is created
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBoardDragDrop({
|
|
||||||
features,
|
|
||||||
currentProject,
|
|
||||||
runningAutoTasks,
|
|
||||||
persistFeatureUpdate,
|
|
||||||
handleStartImplementation,
|
|
||||||
projectPath,
|
|
||||||
onWorktreeCreated,
|
|
||||||
}: UseBoardDragDropProps) {
|
|
||||||
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
|
||||||
const { moveFeature, useWorktrees } = useAppStore();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or create the worktree path for a feature based on its branchName.
|
|
||||||
* - If branchName is "main" or empty, returns the project path
|
|
||||||
* - Otherwise, creates a worktree for that branch if needed
|
|
||||||
*/
|
|
||||||
const getOrCreateWorktreeForFeature = useCallback(
|
|
||||||
async (feature: Feature): Promise<string | null> => {
|
|
||||||
if (!projectPath) return null;
|
|
||||||
|
|
||||||
const branchName = feature.branchName || "main";
|
|
||||||
|
|
||||||
// If targeting main branch, use the project path directly
|
|
||||||
if (branchName === "main" || branchName === "master") {
|
|
||||||
return projectPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other branches, create a worktree if it doesn't exist
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.worktree?.create) {
|
|
||||||
console.error("[DragDrop] Worktree API not available");
|
|
||||||
return projectPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to create the worktree (will return existing if already exists)
|
|
||||||
const result = await api.worktree.create(projectPath, branchName);
|
|
||||||
|
|
||||||
if (result.success && result.worktree) {
|
|
||||||
console.log(
|
|
||||||
`[DragDrop] Worktree ready for branch "${branchName}": ${result.worktree.path}`
|
|
||||||
);
|
|
||||||
if (result.worktree.isNew) {
|
|
||||||
toast.success(`Worktree created for branch "${branchName}"`, {
|
|
||||||
description: "A new worktree was created for this feature.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result.worktree.path;
|
|
||||||
} else {
|
|
||||||
console.error("[DragDrop] Failed to create worktree:", result.error);
|
|
||||||
toast.error("Failed to create worktree", {
|
|
||||||
description: result.error || "Could not create worktree for this branch.",
|
|
||||||
});
|
|
||||||
return projectPath; // Fall back to project path
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[DragDrop] Error creating worktree:", error);
|
|
||||||
toast.error("Error creating worktree", {
|
|
||||||
description: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
});
|
|
||||||
return projectPath; // Fall back to project path
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[projectPath]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragStart = useCallback(
|
|
||||||
(event: DragStartEvent) => {
|
|
||||||
const { active } = event;
|
|
||||||
const feature = features.find((f) => f.id === active.id);
|
|
||||||
if (feature) {
|
|
||||||
setActiveFeature(feature);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[features]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
|
||||||
async (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
setActiveFeature(null);
|
|
||||||
|
|
||||||
if (!over) return;
|
|
||||||
|
|
||||||
const featureId = active.id as string;
|
|
||||||
const overId = over.id as string;
|
|
||||||
|
|
||||||
// Find the feature being dragged
|
|
||||||
const draggedFeature = features.find((f) => f.id === featureId);
|
|
||||||
if (!draggedFeature) return;
|
|
||||||
|
|
||||||
// Check if this is a running task (non-skipTests, TDD)
|
|
||||||
const isRunningTask = runningAutoTasks.includes(featureId);
|
|
||||||
|
|
||||||
// Determine if dragging is allowed based on status and skipTests
|
|
||||||
// - Backlog items can always be dragged
|
|
||||||
// - waiting_approval items can always be dragged (to allow manual verification via drag)
|
|
||||||
// - verified items can always be dragged (to allow moving back to waiting_approval)
|
|
||||||
// - skipTests (non-TDD) items can be dragged between in_progress and verified
|
|
||||||
// - Non-skipTests (TDD) items that are in progress cannot be dragged (they are running)
|
|
||||||
if (
|
|
||||||
draggedFeature.status !== "backlog" &&
|
|
||||||
draggedFeature.status !== "waiting_approval" &&
|
|
||||||
draggedFeature.status !== "verified"
|
|
||||||
) {
|
|
||||||
// Only allow dragging in_progress if it's a skipTests feature and not currently running
|
|
||||||
if (!draggedFeature.skipTests || isRunningTask) {
|
|
||||||
console.log(
|
|
||||||
"[Board] Cannot drag feature - TDD feature or currently running"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let targetStatus: ColumnId | null = null;
|
|
||||||
|
|
||||||
// Check if we dropped on a column
|
|
||||||
const column = COLUMNS.find((c) => c.id === overId);
|
|
||||||
if (column) {
|
|
||||||
targetStatus = column.id;
|
|
||||||
} else {
|
|
||||||
// Dropped on another feature - find its column
|
|
||||||
const overFeature = features.find((f) => f.id === overId);
|
|
||||||
if (overFeature) {
|
|
||||||
targetStatus = overFeature.status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetStatus) return;
|
|
||||||
|
|
||||||
// Same column, nothing to do
|
|
||||||
if (targetStatus === draggedFeature.status) return;
|
|
||||||
|
|
||||||
// Handle different drag scenarios
|
|
||||||
if (draggedFeature.status === "backlog") {
|
|
||||||
// From backlog
|
|
||||||
if (targetStatus === "in_progress") {
|
|
||||||
// Only create worktrees if the feature is enabled
|
|
||||||
let worktreePath: string | null = null;
|
|
||||||
if (useWorktrees) {
|
|
||||||
// Get or create worktree based on the feature's assigned branch
|
|
||||||
worktreePath = await getOrCreateWorktreeForFeature(draggedFeature);
|
|
||||||
if (worktreePath) {
|
|
||||||
await persistFeatureUpdate(featureId, { worktreePath });
|
|
||||||
}
|
|
||||||
// Refresh worktree selector after moving to in_progress
|
|
||||||
onWorktreeCreated?.();
|
|
||||||
}
|
|
||||||
// Use helper function to handle concurrency check and start implementation
|
|
||||||
// Pass feature with worktreePath so handleRunFeature uses the correct path
|
|
||||||
await handleStartImplementation({ ...draggedFeature, worktreePath: worktreePath || undefined });
|
|
||||||
} else {
|
|
||||||
moveFeature(featureId, targetStatus);
|
|
||||||
persistFeatureUpdate(featureId, { status: targetStatus });
|
|
||||||
}
|
|
||||||
} else if (draggedFeature.status === "waiting_approval") {
|
|
||||||
// waiting_approval features can be dragged to verified for manual verification
|
|
||||||
// NOTE: This check must come BEFORE skipTests check because waiting_approval
|
|
||||||
// features often have skipTests=true, and we want status-based handling first
|
|
||||||
if (targetStatus === "verified") {
|
|
||||||
moveFeature(featureId, "verified");
|
|
||||||
// Clear justFinishedAt timestamp when manually verifying via drag
|
|
||||||
persistFeatureUpdate(featureId, {
|
|
||||||
status: "verified",
|
|
||||||
justFinishedAt: undefined,
|
|
||||||
});
|
|
||||||
toast.success("Feature verified", {
|
|
||||||
description: `Manually verified: ${draggedFeature.description.slice(
|
|
||||||
0,
|
|
||||||
50
|
|
||||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
|
||||||
});
|
|
||||||
} else if (targetStatus === "backlog") {
|
|
||||||
// Allow moving waiting_approval cards back to backlog
|
|
||||||
moveFeature(featureId, "backlog");
|
|
||||||
// Clear justFinishedAt timestamp and worktreePath when moving back to backlog
|
|
||||||
persistFeatureUpdate(featureId, {
|
|
||||||
status: "backlog",
|
|
||||||
justFinishedAt: undefined,
|
|
||||||
worktreePath: undefined,
|
|
||||||
});
|
|
||||||
toast.info("Feature moved to backlog", {
|
|
||||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
|
||||||
0,
|
|
||||||
50
|
|
||||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (draggedFeature.skipTests) {
|
|
||||||
// skipTests feature being moved between in_progress and verified
|
|
||||||
if (
|
|
||||||
targetStatus === "verified" &&
|
|
||||||
draggedFeature.status === "in_progress"
|
|
||||||
) {
|
|
||||||
// Manual verify via drag
|
|
||||||
moveFeature(featureId, "verified");
|
|
||||||
persistFeatureUpdate(featureId, { status: "verified" });
|
|
||||||
toast.success("Feature verified", {
|
|
||||||
description: `Marked as verified: ${draggedFeature.description.slice(
|
|
||||||
0,
|
|
||||||
50
|
|
||||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
|
||||||
});
|
|
||||||
} else if (
|
|
||||||
targetStatus === "waiting_approval" &&
|
|
||||||
draggedFeature.status === "verified"
|
|
||||||
) {
|
|
||||||
// Move verified feature back to waiting_approval
|
|
||||||
moveFeature(featureId, "waiting_approval");
|
|
||||||
persistFeatureUpdate(featureId, { status: "waiting_approval" });
|
|
||||||
toast.info("Feature moved back", {
|
|
||||||
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
|
|
||||||
0,
|
|
||||||
50
|
|
||||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
|
||||||
});
|
|
||||||
} else if (targetStatus === "backlog") {
|
|
||||||
// Allow moving skipTests cards back to backlog
|
|
||||||
moveFeature(featureId, "backlog");
|
|
||||||
// Clear worktreePath when moving back to backlog
|
|
||||||
persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
|
|
||||||
toast.info("Feature moved to backlog", {
|
|
||||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
|
||||||
0,
|
|
||||||
50
|
|
||||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (draggedFeature.status === "verified") {
|
|
||||||
// Handle verified TDD (non-skipTests) features being moved back
|
|
||||||
if (targetStatus === "waiting_approval") {
|
|
||||||
// Move verified feature back to waiting_approval
|
|
||||||
moveFeature(featureId, "waiting_approval");
|
|
||||||
persistFeatureUpdate(featureId, { status: "waiting_approval" });
|
|
||||||
toast.info("Feature moved back", {
|
|
||||||
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
|
|
||||||
0,
|
|
||||||
50
|
|
||||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
|
||||||
});
|
|
||||||
} else if (targetStatus === "backlog") {
|
|
||||||
// Allow moving verified cards back to backlog
|
|
||||||
moveFeature(featureId, "backlog");
|
|
||||||
// Clear worktreePath when moving back to backlog
|
|
||||||
persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
|
|
||||||
toast.info("Feature moved to backlog", {
|
|
||||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
|
||||||
0,
|
|
||||||
50
|
|
||||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
features,
|
|
||||||
runningAutoTasks,
|
|
||||||
moveFeature,
|
|
||||||
persistFeatureUpdate,
|
|
||||||
handleStartImplementation,
|
|
||||||
getOrCreateWorktreeForFeature,
|
|
||||||
onWorktreeCreated,
|
|
||||||
useWorktrees,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
activeFeature,
|
|
||||||
handleDragStart,
|
|
||||||
handleDragEnd,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
|
||||||
import { useAppStore } from "@/store/app-store";
|
|
||||||
import { useAutoMode } from "@/hooks/use-auto-mode";
|
|
||||||
|
|
||||||
interface UseBoardEffectsProps {
|
|
||||||
currentProject: { path: string; id: string } | null;
|
|
||||||
specCreatingForProject: string | null;
|
|
||||||
setSpecCreatingForProject: (path: string | null) => void;
|
|
||||||
setSuggestionsCount: (count: number) => void;
|
|
||||||
setFeatureSuggestions: (suggestions: any[]) => void;
|
|
||||||
setIsGeneratingSuggestions: (generating: boolean) => void;
|
|
||||||
checkContextExists: (featureId: string) => Promise<boolean>;
|
|
||||||
features: any[];
|
|
||||||
isLoading: boolean;
|
|
||||||
setFeaturesWithContext: (set: Set<string>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBoardEffects({
|
|
||||||
currentProject,
|
|
||||||
specCreatingForProject,
|
|
||||||
setSpecCreatingForProject,
|
|
||||||
setSuggestionsCount,
|
|
||||||
setFeatureSuggestions,
|
|
||||||
setIsGeneratingSuggestions,
|
|
||||||
checkContextExists,
|
|
||||||
features,
|
|
||||||
isLoading,
|
|
||||||
setFeaturesWithContext,
|
|
||||||
}: UseBoardEffectsProps) {
|
|
||||||
const autoMode = useAutoMode();
|
|
||||||
|
|
||||||
// Make current project available globally for modal
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentProject) {
|
|
||||||
(window as any).__currentProject = currentProject;
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
(window as any).__currentProject = null;
|
|
||||||
};
|
|
||||||
}, [currentProject]);
|
|
||||||
|
|
||||||
// Listen for suggestions events to update count (persists even when dialog is closed)
|
|
||||||
useEffect(() => {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.suggestions) return;
|
|
||||||
|
|
||||||
const unsubscribe = api.suggestions.onEvent((event) => {
|
|
||||||
if (event.type === "suggestions_complete" && event.suggestions) {
|
|
||||||
setSuggestionsCount(event.suggestions.length);
|
|
||||||
setFeatureSuggestions(event.suggestions);
|
|
||||||
setIsGeneratingSuggestions(false);
|
|
||||||
} else if (event.type === "suggestions_error") {
|
|
||||||
setIsGeneratingSuggestions(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, [setSuggestionsCount, setFeatureSuggestions, setIsGeneratingSuggestions]);
|
|
||||||
|
|
||||||
// Subscribe to spec regeneration events to clear creating state on completion
|
|
||||||
useEffect(() => {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.specRegeneration) return;
|
|
||||||
|
|
||||||
const unsubscribe = api.specRegeneration.onEvent((event) => {
|
|
||||||
console.log(
|
|
||||||
"[BoardView] Spec regeneration event:",
|
|
||||||
event.type,
|
|
||||||
"for project:",
|
|
||||||
event.projectPath
|
|
||||||
);
|
|
||||||
|
|
||||||
if (event.projectPath !== specCreatingForProject) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "spec_regeneration_complete") {
|
|
||||||
setSpecCreatingForProject(null);
|
|
||||||
} else if (event.type === "spec_regeneration_error") {
|
|
||||||
setSpecCreatingForProject(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, [specCreatingForProject, setSpecCreatingForProject]);
|
|
||||||
|
|
||||||
// Sync running tasks from electron backend on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentProject) return;
|
|
||||||
|
|
||||||
const syncRunningTasks = async () => {
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.autoMode?.status) return;
|
|
||||||
|
|
||||||
const status = await api.autoMode.status(currentProject.path);
|
|
||||||
if (status.success) {
|
|
||||||
const projectId = currentProject.id;
|
|
||||||
const { clearRunningTasks, addRunningTask, setAutoModeRunning } =
|
|
||||||
useAppStore.getState();
|
|
||||||
|
|
||||||
if (status.runningFeatures) {
|
|
||||||
console.log(
|
|
||||||
"[Board] Syncing running tasks from backend:",
|
|
||||||
status.runningFeatures
|
|
||||||
);
|
|
||||||
|
|
||||||
clearRunningTasks(projectId);
|
|
||||||
|
|
||||||
status.runningFeatures.forEach((featureId: string) => {
|
|
||||||
addRunningTask(projectId, featureId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAutoModeRunning =
|
|
||||||
status.autoLoopRunning ?? status.isRunning ?? false;
|
|
||||||
console.log(
|
|
||||||
"[Board] Syncing auto mode running state:",
|
|
||||||
isAutoModeRunning
|
|
||||||
);
|
|
||||||
setAutoModeRunning(projectId, isAutoModeRunning);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Board] Failed to sync running tasks:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
syncRunningTasks();
|
|
||||||
}, [currentProject]);
|
|
||||||
|
|
||||||
// Check which features have context files
|
|
||||||
useEffect(() => {
|
|
||||||
const checkAllContexts = async () => {
|
|
||||||
const featuresWithPotentialContext = features.filter(
|
|
||||||
(f) =>
|
|
||||||
f.status === "in_progress" ||
|
|
||||||
f.status === "waiting_approval" ||
|
|
||||||
f.status === "verified"
|
|
||||||
);
|
|
||||||
const contextChecks = await Promise.all(
|
|
||||||
featuresWithPotentialContext.map(async (f) => ({
|
|
||||||
id: f.id,
|
|
||||||
hasContext: await checkContextExists(f.id),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const newSet = new Set<string>();
|
|
||||||
contextChecks.forEach(({ id, hasContext }) => {
|
|
||||||
if (hasContext) {
|
|
||||||
newSet.add(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setFeaturesWithContext(newSet);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (features.length > 0 && !isLoading) {
|
|
||||||
checkAllContexts();
|
|
||||||
}
|
|
||||||
}, [features, isLoading, checkContextExists, setFeaturesWithContext]);
|
|
||||||
}
|
|
||||||
@@ -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,285 +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, useAppStore } from "@/store/app-store";
|
|
||||||
import { FastForward, Lightbulb, Trash2, GitBranch } from "lucide-react";
|
|
||||||
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
|
|
||||||
import { COLUMNS, ColumnId } from "./constants";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface KanbanBoardProps {
|
|
||||||
sensors: any;
|
|
||||||
collisionDetectionStrategy: (args: any) => any;
|
|
||||||
onDragStart: (event: any) => void;
|
|
||||||
onDragEnd: (event: any) => void;
|
|
||||||
activeFeature: Feature | null;
|
|
||||||
getColumnFeatures: (columnId: ColumnId) => Feature[];
|
|
||||||
backgroundImageStyle: React.CSSProperties;
|
|
||||||
backgroundSettings: {
|
|
||||||
columnOpacity: number;
|
|
||||||
columnBorderEnabled: boolean;
|
|
||||||
hideScrollbar: boolean;
|
|
||||||
cardOpacity: number;
|
|
||||||
cardGlassmorphism: boolean;
|
|
||||||
cardBorderEnabled: boolean;
|
|
||||||
cardBorderOpacity: number;
|
|
||||||
};
|
|
||||||
onEdit: (feature: Feature) => void;
|
|
||||||
onDelete: (featureId: string) => void;
|
|
||||||
onViewOutput: (feature: Feature) => void;
|
|
||||||
onVerify: (feature: Feature) => void;
|
|
||||||
onResume: (feature: Feature) => void;
|
|
||||||
onForceStop: (feature: Feature) => void;
|
|
||||||
onManualVerify: (feature: Feature) => void;
|
|
||||||
onMoveBackToInProgress: (feature: Feature) => void;
|
|
||||||
onFollowUp: (feature: Feature) => void;
|
|
||||||
onCommit: (feature: Feature) => void;
|
|
||||||
onComplete: (feature: Feature) => void;
|
|
||||||
onImplement: (feature: Feature) => void;
|
|
||||||
onViewPlan: (feature: Feature) => void;
|
|
||||||
onApprovePlan: (feature: Feature) => void;
|
|
||||||
featuresWithContext: Set<string>;
|
|
||||||
runningAutoTasks: string[];
|
|
||||||
shortcuts: ReturnType<typeof useKeyboardShortcutsConfig>;
|
|
||||||
onStartNextFeatures: () => void;
|
|
||||||
onShowSuggestions: () => void;
|
|
||||||
suggestionsCount: number;
|
|
||||||
onDeleteAllVerified: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KanbanBoard({
|
|
||||||
sensors,
|
|
||||||
collisionDetectionStrategy,
|
|
||||||
onDragStart,
|
|
||||||
onDragEnd,
|
|
||||||
activeFeature,
|
|
||||||
getColumnFeatures,
|
|
||||||
backgroundImageStyle,
|
|
||||||
backgroundSettings,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onViewOutput,
|
|
||||||
onVerify,
|
|
||||||
onResume,
|
|
||||||
onForceStop,
|
|
||||||
onManualVerify,
|
|
||||||
onMoveBackToInProgress,
|
|
||||||
onFollowUp,
|
|
||||||
onCommit,
|
|
||||||
onComplete,
|
|
||||||
onImplement,
|
|
||||||
onViewPlan,
|
|
||||||
onApprovePlan,
|
|
||||||
featuresWithContext,
|
|
||||||
runningAutoTasks,
|
|
||||||
shortcuts,
|
|
||||||
onStartNextFeatures,
|
|
||||||
onShowSuggestions,
|
|
||||||
suggestionsCount,
|
|
||||||
onDeleteAllVerified,
|
|
||||||
}: KanbanBoardProps) {
|
|
||||||
const { getEffectiveTheme } = useAppStore();
|
|
||||||
const effectiveTheme = getEffectiveTheme();
|
|
||||||
const isCleanTheme = effectiveTheme === "clean";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex-1 overflow-x-auto relative",
|
|
||||||
isCleanTheme ? "custom-scrollbar px-8 pb-8" : "px-4 pb-4"
|
|
||||||
)}
|
|
||||||
style={backgroundImageStyle}
|
|
||||||
>
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={collisionDetectionStrategy}
|
|
||||||
onDragStart={onDragStart}
|
|
||||||
onDragEnd={onDragEnd}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-full min-w-max",
|
|
||||||
isCleanTheme ? "gap-6 items-start" : "gap-5 py-1"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{COLUMNS.map((column) => {
|
|
||||||
const columnFeatures = getColumnFeatures(column.id);
|
|
||||||
|
|
||||||
// Clean Theme Header Actions
|
|
||||||
let headerAction;
|
|
||||||
if (isCleanTheme) {
|
|
||||||
if (column.id === "backlog") {
|
|
||||||
headerAction = (
|
|
||||||
<div className="flex items-center gap-1.5 opacity-40">
|
|
||||||
<Lightbulb className="w-3.5 h-3.5 text-yellow-500" />
|
|
||||||
<GitBranch className="w-3.5 h-3.5 text-cyan-400" />
|
|
||||||
<span className="mono text-[9px] text-cyan-400 font-bold">Mabe 6</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (column.id === "verified" && columnFeatures.length > 0) {
|
|
||||||
headerAction = (
|
|
||||||
<button
|
|
||||||
className="ml-2 text-[10px] text-rose-500 flex items-center gap-1 hover:underline font-black transition"
|
|
||||||
onClick={onDeleteAllVerified}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" /> Delete All
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Standard Theme Header Actions
|
|
||||||
if (column.id === "verified" && columnFeatures.length > 0) {
|
|
||||||
headerAction = (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
||||||
onClick={onDeleteAllVerified}
|
|
||||||
data-testid="delete-all-verified-button"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3 mr-1" />
|
|
||||||
Delete All
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
} else if (column.id === "backlog") {
|
|
||||||
headerAction = (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
|
|
||||||
onClick={onShowSuggestions}
|
|
||||||
title="Feature Suggestions"
|
|
||||||
data-testid="feature-suggestions-button"
|
|
||||||
>
|
|
||||||
<Lightbulb className="w-3.5 h-3.5" />
|
|
||||||
{suggestionsCount > 0 && (
|
|
||||||
<span
|
|
||||||
className="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-mono rounded-full bg-yellow-500 text-black flex items-center justify-center"
|
|
||||||
data-testid="suggestions-count"
|
|
||||||
>
|
|
||||||
{suggestionsCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{columnFeatures.length > 0 && (
|
|
||||||
<HotkeyButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
|
|
||||||
onClick={onStartNextFeatures}
|
|
||||||
hotkey={shortcuts.startNext}
|
|
||||||
hotkeyActive={false}
|
|
||||||
data-testid="start-next-button"
|
|
||||||
>
|
|
||||||
<FastForward className="w-3 h-3 mr-1" />
|
|
||||||
Make
|
|
||||||
</HotkeyButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<KanbanColumn
|
|
||||||
key={column.id}
|
|
||||||
id={column.id}
|
|
||||||
title={column.title}
|
|
||||||
colorClass={column.colorClass}
|
|
||||||
count={columnFeatures.length}
|
|
||||||
opacity={backgroundSettings.columnOpacity}
|
|
||||||
showBorder={backgroundSettings.columnBorderEnabled}
|
|
||||||
hideScrollbar={backgroundSettings.hideScrollbar}
|
|
||||||
headerAction={headerAction}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={columnFeatures.map((f) => f.id)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{columnFeatures.map((feature, index) => {
|
|
||||||
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
|
||||||
let shortcutKey: string | undefined;
|
|
||||||
if (column.id === "in_progress" && index < 10) {
|
|
||||||
shortcutKey =
|
|
||||||
index === 9 ? "0" : String(index + 1);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<KanbanCard
|
|
||||||
key={feature.id}
|
|
||||||
feature={feature}
|
|
||||||
onEdit={() => onEdit(feature)}
|
|
||||||
onDelete={() => onDelete(feature.id)}
|
|
||||||
onViewOutput={() => onViewOutput(feature)}
|
|
||||||
onVerify={() => onVerify(feature)}
|
|
||||||
onResume={() => onResume(feature)}
|
|
||||||
onForceStop={() => onForceStop(feature)}
|
|
||||||
onManualVerify={() => onManualVerify(feature)}
|
|
||||||
onMoveBackToInProgress={() =>
|
|
||||||
onMoveBackToInProgress(feature)
|
|
||||||
}
|
|
||||||
onFollowUp={() => onFollowUp(feature)}
|
|
||||||
onCommit={() => onCommit(feature)}
|
|
||||||
onComplete={() => onComplete(feature)}
|
|
||||||
onImplement={() => onImplement(feature)}
|
|
||||||
onViewPlan={() => onViewPlan(feature)}
|
|
||||||
onApprovePlan={() => onApprovePlan(feature)}
|
|
||||||
hasContext={featuresWithContext.has(feature.id)}
|
|
||||||
isCurrentAutoTask={runningAutoTasks.includes(
|
|
||||||
feature.id
|
|
||||||
)}
|
|
||||||
shortcutKey={shortcutKey}
|
|
||||||
opacity={backgroundSettings.cardOpacity}
|
|
||||||
glassmorphism={
|
|
||||||
backgroundSettings.cardGlassmorphism
|
|
||||||
}
|
|
||||||
cardBorderEnabled={
|
|
||||||
backgroundSettings.cardBorderEnabled
|
|
||||||
}
|
|
||||||
cardBorderOpacity={
|
|
||||||
backgroundSettings.cardBorderOpacity
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SortableContext>
|
|
||||||
</KanbanColumn>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DragOverlay
|
|
||||||
dropAnimation={{
|
|
||||||
duration: 200,
|
|
||||||
easing: "cubic-bezier(0.18, 0.67, 0.6, 1.22)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{activeFeature && (
|
|
||||||
<Card className="w-72 rotate-2 shadow-2xl shadow-black/25 border-primary/50 bg-card/95 backdrop-blur-sm transition-transform">
|
|
||||||
<CardHeader className="p-3">
|
|
||||||
<CardTitle className="text-sm font-medium line-clamp-2">
|
|
||||||
{activeFeature.description}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-xs text-muted-foreground">
|
|
||||||
{activeFeature.category}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +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 "./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,194 +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>
|
|
||||||
)}
|
|
||||||
{(worktree.branch !== "main" || worktree.hasChanges) && (
|
|
||||||
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
|
|
||||||
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Create Pull Request
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{!worktree.isMain && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onDeleteWorktree(worktree)}
|
|
||||||
className="text-xs text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Delete Worktree
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,192 +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;
|
|
||||||
isSelected: boolean;
|
|
||||||
isRunning: boolean;
|
|
||||||
isActivating: boolean;
|
|
||||||
isDevServerRunning: boolean;
|
|
||||||
devServerInfo?: DevServerInfo;
|
|
||||||
defaultEditorName: string;
|
|
||||||
branches: BranchInfo[];
|
|
||||||
filteredBranches: BranchInfo[];
|
|
||||||
branchFilter: string;
|
|
||||||
isLoadingBranches: boolean;
|
|
||||||
isSwitching: boolean;
|
|
||||||
isPulling: boolean;
|
|
||||||
isPushing: boolean;
|
|
||||||
isStartingDevServer: boolean;
|
|
||||||
aheadCount: number;
|
|
||||||
behindCount: number;
|
|
||||||
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
|
||||||
onBranchDropdownOpenChange: (open: boolean) => void;
|
|
||||||
onActionsDropdownOpenChange: (open: boolean) => void;
|
|
||||||
onBranchFilterChange: (value: string) => void;
|
|
||||||
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
|
|
||||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
|
||||||
onPull: (worktree: WorktreeInfo) => void;
|
|
||||||
onPush: (worktree: WorktreeInfo) => void;
|
|
||||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
|
||||||
onCommit: (worktree: WorktreeInfo) => void;
|
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
|
||||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
|
||||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
|
||||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
|
||||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorktreeTab({
|
|
||||||
worktree,
|
|
||||||
isSelected,
|
|
||||||
isRunning,
|
|
||||||
isActivating,
|
|
||||||
isDevServerRunning,
|
|
||||||
devServerInfo,
|
|
||||||
defaultEditorName,
|
|
||||||
branches,
|
|
||||||
filteredBranches,
|
|
||||||
branchFilter,
|
|
||||||
isLoadingBranches,
|
|
||||||
isSwitching,
|
|
||||||
isPulling,
|
|
||||||
isPushing,
|
|
||||||
isStartingDevServer,
|
|
||||||
aheadCount,
|
|
||||||
behindCount,
|
|
||||||
onSelectWorktree,
|
|
||||||
onBranchDropdownOpenChange,
|
|
||||||
onActionsDropdownOpenChange,
|
|
||||||
onBranchFilterChange,
|
|
||||||
onSwitchBranch,
|
|
||||||
onCreateBranch,
|
|
||||||
onPull,
|
|
||||||
onPush,
|
|
||||||
onOpenInEditor,
|
|
||||||
onCommit,
|
|
||||||
onCreatePR,
|
|
||||||
onDeleteWorktree,
|
|
||||||
onStartDevServer,
|
|
||||||
onStopDevServer,
|
|
||||||
onOpenDevServerUrl,
|
|
||||||
}: WorktreeTabProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center">
|
|
||||||
{worktree.isMain ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant={isSelected ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
"h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none",
|
|
||||||
isSelected && "bg-primary text-primary-foreground",
|
|
||||||
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
|
||||||
)}
|
|
||||||
onClick={() => onSelectWorktree(worktree)}
|
|
||||||
disabled={isActivating}
|
|
||||||
title="Click to preview main"
|
|
||||||
>
|
|
||||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
|
||||||
{isActivating && !isRunning && (
|
|
||||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
|
||||||
)}
|
|
||||||
{worktree.branch}
|
|
||||||
{worktree.hasChanges && (
|
|
||||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
|
||||||
{worktree.changedFilesCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<BranchSwitchDropdown
|
|
||||||
worktree={worktree}
|
|
||||||
isSelected={isSelected}
|
|
||||||
branches={branches}
|
|
||||||
filteredBranches={filteredBranches}
|
|
||||||
branchFilter={branchFilter}
|
|
||||||
isLoadingBranches={isLoadingBranches}
|
|
||||||
isSwitching={isSwitching}
|
|
||||||
onOpenChange={onBranchDropdownOpenChange}
|
|
||||||
onFilterChange={onBranchFilterChange}
|
|
||||||
onSwitchBranch={onSwitchBranch}
|
|
||||||
onCreateBranch={onCreateBranch}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant={isSelected ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
"h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0",
|
|
||||||
isSelected && "bg-primary text-primary-foreground",
|
|
||||||
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
|
||||||
!worktree.hasWorktree && !isSelected && "opacity-70"
|
|
||||||
)}
|
|
||||||
onClick={() => onSelectWorktree(worktree)}
|
|
||||||
disabled={isActivating}
|
|
||||||
title={
|
|
||||||
worktree.hasWorktree
|
|
||||||
? "Click to switch to this worktree's branch"
|
|
||||||
: "Click to switch to this branch"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
|
||||||
{isActivating && !isRunning && (
|
|
||||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
|
||||||
)}
|
|
||||||
{worktree.branch}
|
|
||||||
{worktree.hasChanges && (
|
|
||||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
|
||||||
{worktree.changedFilesCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isDevServerRunning && (
|
|
||||||
<Button
|
|
||||||
variant={isSelected ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
"h-7 w-7 p-0 rounded-none border-r-0",
|
|
||||||
isSelected && "bg-primary text-primary-foreground",
|
|
||||||
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
|
||||||
"text-green-500"
|
|
||||||
)}
|
|
||||||
onClick={() => onOpenDevServerUrl(worktree)}
|
|
||||||
title={`Open dev server (port ${devServerInfo?.port})`}
|
|
||||||
>
|
|
||||||
<Globe className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<WorktreeActionsDropdown
|
|
||||||
worktree={worktree}
|
|
||||||
isSelected={isSelected}
|
|
||||||
defaultEditorName={defaultEditorName}
|
|
||||||
aheadCount={aheadCount}
|
|
||||||
behindCount={behindCount}
|
|
||||||
isPulling={isPulling}
|
|
||||||
isPushing={isPushing}
|
|
||||||
isStartingDevServer={isStartingDevServer}
|
|
||||||
isDevServerRunning={isDevServerRunning}
|
|
||||||
devServerInfo={devServerInfo}
|
|
||||||
onOpenChange={onActionsDropdownOpenChange}
|
|
||||||
onPull={onPull}
|
|
||||||
onPush={onPush}
|
|
||||||
onOpenInEditor={onOpenInEditor}
|
|
||||||
onCommit={onCommit}
|
|
||||||
onCreatePR={onCreatePR}
|
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
|
||||||
onStartDevServer={onStartDevServer}
|
|
||||||
onStopDevServer={onStopDevServer}
|
|
||||||
onOpenDevServerUrl={onOpenDevServerUrl}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,50 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { pathsEqual } from "@/lib/utils";
|
|
||||||
import type { WorktreeInfo, FeatureInfo } from "../types";
|
|
||||||
|
|
||||||
interface UseRunningFeaturesOptions {
|
|
||||||
projectPath: string;
|
|
||||||
runningFeatureIds: string[];
|
|
||||||
features: FeatureInfo[];
|
|
||||||
getWorktreeKey: (worktree: WorktreeInfo) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRunningFeatures({
|
|
||||||
projectPath,
|
|
||||||
runningFeatureIds,
|
|
||||||
features,
|
|
||||||
getWorktreeKey,
|
|
||||||
}: UseRunningFeaturesOptions) {
|
|
||||||
const hasRunningFeatures = useCallback(
|
|
||||||
(worktree: WorktreeInfo) => {
|
|
||||||
if (runningFeatureIds.length === 0) return false;
|
|
||||||
|
|
||||||
const worktreeKey = getWorktreeKey(worktree);
|
|
||||||
|
|
||||||
return runningFeatureIds.some((featureId) => {
|
|
||||||
const feature = features.find((f) => f.id === featureId);
|
|
||||||
if (!feature) return false;
|
|
||||||
|
|
||||||
if (feature.worktreePath) {
|
|
||||||
if (worktree.isMain) {
|
|
||||||
return pathsEqual(feature.worktreePath, projectPath);
|
|
||||||
}
|
|
||||||
return pathsEqual(feature.worktreePath, worktreeKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (feature.branchName) {
|
|
||||||
return worktree.branch === feature.branchName;
|
|
||||||
}
|
|
||||||
|
|
||||||
return worktree.isMain;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[runningFeatureIds, features, projectPath, getWorktreeKey]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasRunningFeatures,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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<void>;
|
|
||||||
fetchBranches: (worktreePath: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorktreeActions({
|
|
||||||
fetchWorktrees,
|
|
||||||
fetchBranches,
|
|
||||||
}: UseWorktreeActionsOptions) {
|
|
||||||
const [isPulling, setIsPulling] = useState(false);
|
|
||||||
const [isPushing, setIsPushing] = useState(false);
|
|
||||||
const [isSwitching, setIsSwitching] = useState(false);
|
|
||||||
const [isActivating, setIsActivating] = useState(false);
|
|
||||||
|
|
||||||
const handleSwitchBranch = useCallback(
|
|
||||||
async (worktree: WorktreeInfo, branchName: string) => {
|
|
||||||
if (isSwitching || branchName === worktree.branch) return;
|
|
||||||
setIsSwitching(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.worktree?.switchBranch) {
|
|
||||||
toast.error("Switch branch API not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.worktree.switchBranch(worktree.path, branchName);
|
|
||||||
if (result.success && result.result) {
|
|
||||||
toast.success(result.result.message);
|
|
||||||
fetchWorktrees();
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || "Failed to switch branch");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Switch branch failed:", error);
|
|
||||||
toast.error("Failed to switch branch");
|
|
||||||
} finally {
|
|
||||||
setIsSwitching(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isSwitching, fetchWorktrees]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePull = useCallback(
|
|
||||||
async (worktree: WorktreeInfo) => {
|
|
||||||
if (isPulling) return;
|
|
||||||
setIsPulling(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.worktree?.pull) {
|
|
||||||
toast.error("Pull API not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.worktree.pull(worktree.path);
|
|
||||||
if (result.success && result.result) {
|
|
||||||
toast.success(result.result.message);
|
|
||||||
fetchWorktrees();
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || "Failed to pull latest changes");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Pull failed:", error);
|
|
||||||
toast.error("Failed to pull latest changes");
|
|
||||||
} finally {
|
|
||||||
setIsPulling(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isPulling, fetchWorktrees]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePush = useCallback(
|
|
||||||
async (worktree: WorktreeInfo) => {
|
|
||||||
if (isPushing) return;
|
|
||||||
setIsPushing(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.worktree?.push) {
|
|
||||||
toast.error("Push API not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.worktree.push(worktree.path);
|
|
||||||
if (result.success && result.result) {
|
|
||||||
toast.success(result.result.message);
|
|
||||||
fetchBranches(worktree.path);
|
|
||||||
fetchWorktrees();
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || "Failed to push changes");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Push failed:", error);
|
|
||||||
toast.error("Failed to push changes");
|
|
||||||
} finally {
|
|
||||||
setIsPushing(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isPushing, fetchBranches, fetchWorktrees]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo) => {
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.worktree?.openInEditor) {
|
|
||||||
console.warn("Open in editor API not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.worktree.openInEditor(worktree.path);
|
|
||||||
if (result.success && result.result) {
|
|
||||||
toast.success(result.result.message);
|
|
||||||
} else if (result.error) {
|
|
||||||
toast.error(result.error);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Open in editor failed:", error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isPulling,
|
|
||||||
isPushing,
|
|
||||||
isSwitching,
|
|
||||||
isActivating,
|
|
||||||
setIsActivating,
|
|
||||||
handleSwitchBranch,
|
|
||||||
handlePull,
|
|
||||||
handlePush,
|
|
||||||
handleOpenInEditor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,95 +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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOptions) {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
|
|
||||||
|
|
||||||
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
|
|
||||||
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
|
|
||||||
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
|
|
||||||
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
|
|
||||||
|
|
||||||
const fetchWorktrees = useCallback(async () => {
|
|
||||||
if (!projectPath) return;
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.worktree?.listAll) {
|
|
||||||
console.warn("Worktree API not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.worktree.listAll(projectPath, true);
|
|
||||||
if (result.success && result.worktrees) {
|
|
||||||
setWorktrees(result.worktrees);
|
|
||||||
setWorktreesInStore(projectPath, result.worktrees);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch worktrees:", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [projectPath, setWorktreesInStore]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchWorktrees();
|
|
||||||
}, [fetchWorktrees]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (refreshTrigger > 0) {
|
|
||||||
fetchWorktrees();
|
|
||||||
}
|
|
||||||
}, [refreshTrigger, fetchWorktrees]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (worktrees.length > 0) {
|
|
||||||
const currentPath = currentWorktree?.path;
|
|
||||||
const currentWorktreeExists = currentPath === null
|
|
||||||
? true
|
|
||||||
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
|
|
||||||
|
|
||||||
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
|
|
||||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
|
||||||
const mainBranch = mainWorktree?.branch || "main";
|
|
||||||
setCurrentWorktree(projectPath, null, mainBranch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
|
|
||||||
|
|
||||||
const handleSelectWorktree = useCallback(
|
|
||||||
(worktree: WorktreeInfo) => {
|
|
||||||
setCurrentWorktree(
|
|
||||||
projectPath,
|
|
||||||
worktree.isMain ? null : worktree.path,
|
|
||||||
worktree.branch
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[projectPath, setCurrentWorktree]
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentWorktreePath = currentWorktree?.path ?? null;
|
|
||||||
const selectedWorktree = currentWorktreePath
|
|
||||||
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
|
|
||||||
: worktrees.find((w) => w.isMain);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isLoading,
|
|
||||||
worktrees,
|
|
||||||
currentWorktree,
|
|
||||||
currentWorktreePath,
|
|
||||||
selectedWorktree,
|
|
||||||
useWorktreesEnabled,
|
|
||||||
fetchWorktrees,
|
|
||||||
handleSelectWorktree,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export { WorktreePanel } from "./worktree-panel";
|
|
||||||
export type {
|
|
||||||
WorktreeInfo,
|
|
||||||
BranchInfo,
|
|
||||||
DevServerInfo,
|
|
||||||
FeatureInfo,
|
|
||||||
WorktreePanelProps,
|
|
||||||
} from "./types";
|
|
||||||
@@ -1,39 +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;
|
|
||||||
worktreePath?: string;
|
|
||||||
branchName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorktreePanelProps {
|
|
||||||
projectPath: string;
|
|
||||||
onCreateWorktree: () => void;
|
|
||||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
|
||||||
onCommit: (worktree: WorktreeInfo) => void;
|
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
|
||||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
|
||||||
runningFeatureIds?: string[];
|
|
||||||
features?: FeatureInfo[];
|
|
||||||
refreshTrigger?: number;
|
|
||||||
}
|
|
||||||
@@ -1,177 +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,
|
|
||||||
runningFeatureIds = [],
|
|
||||||
features = [],
|
|
||||||
refreshTrigger = 0,
|
|
||||||
}: WorktreePanelProps) {
|
|
||||||
const {
|
|
||||||
isLoading,
|
|
||||||
worktrees,
|
|
||||||
currentWorktree,
|
|
||||||
currentWorktreePath,
|
|
||||||
useWorktreesEnabled,
|
|
||||||
fetchWorktrees,
|
|
||||||
handleSelectWorktree,
|
|
||||||
} = useWorktrees({ projectPath, refreshTrigger });
|
|
||||||
|
|
||||||
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({
|
|
||||||
projectPath,
|
|
||||||
runningFeatureIds,
|
|
||||||
features,
|
|
||||||
getWorktreeKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
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) => (
|
|
||||||
<WorktreeTab
|
|
||||||
key={worktree.path}
|
|
||||||
worktree={worktree}
|
|
||||||
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={fetchWorktrees}
|
|
||||||
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>(
|
||||||
@@ -243,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];
|
||||||
@@ -475,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>
|
||||||
)}
|
)}
|
||||||
@@ -803,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
|
||||||
File diff suppressed because it is too large
Load Diff
86
apps/app/src/components/views/kanban-column.tsx
Normal file
86
apps/app/src/components/views/kanban-column.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { useDroppable } from "@dnd-kit/core";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface KanbanColumnProps {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
colorClass: string;
|
||||||
|
count: number;
|
||||||
|
children: ReactNode;
|
||||||
|
headerAction?: ReactNode;
|
||||||
|
opacity?: number;
|
||||||
|
showBorder?: boolean;
|
||||||
|
hideScrollbar?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KanbanColumn = memo(function KanbanColumn({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
colorClass,
|
||||||
|
count,
|
||||||
|
children,
|
||||||
|
headerAction,
|
||||||
|
opacity = 100,
|
||||||
|
showBorder = true,
|
||||||
|
hideScrollbar = false,
|
||||||
|
}: KanbanColumnProps) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-col h-full rounded-xl transition-all duration-200 w-72",
|
||||||
|
showBorder && "border border-border/60",
|
||||||
|
isOver && "ring-2 ring-primary/30 ring-offset-1 ring-offset-background"
|
||||||
|
)}
|
||||||
|
data-testid={`kanban-column-${id}`}
|
||||||
|
>
|
||||||
|
{/* Background layer with opacity */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200",
|
||||||
|
isOver ? "bg-accent/80" : "bg-card/80"
|
||||||
|
)}
|
||||||
|
style={{ opacity: opacity / 100 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Column Header */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex items-center gap-3 px-3 py-2.5",
|
||||||
|
showBorder && "border-b border-border/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn("w-2.5 h-2.5 rounded-full shrink-0", colorClass)} />
|
||||||
|
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
|
||||||
|
{headerAction}
|
||||||
|
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column Content */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5",
|
||||||
|
hideScrollbar &&
|
||||||
|
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||||
|
// Smooth scrolling
|
||||||
|
"scroll-smooth"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drop zone indicator when dragging over */}
|
||||||
|
{isOver && (
|
||||||
|
<div className="absolute inset-0 rounded-xl bg-primary/5 pointer-events-none z-5 border-2 border-dashed border-primary/20" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ export function AppearanceSection({
|
|||||||
}: AppearanceSectionProps) {
|
}: AppearanceSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
id="appearance"
|
||||||
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