Compare commits

..

1 Commits

Author SHA1 Message Date
SuperComboGamer
d4907a610e IDK 2025-12-18 19:06:14 -05:00
346 changed files with 21617 additions and 21175 deletions

View File

@@ -1,66 +0,0 @@
name: "Setup Project"
description: "Common setup steps for CI workflows - checkout, Node.js, dependencies, and native modules"
inputs:
node-version:
description: "Node.js version to use"
required: false
default: "22"
check-lockfile:
description: "Run lockfile lint check for SSH URLs"
required: false
default: "false"
rebuild-node-pty-path:
description: "Working directory for node-pty rebuild (empty = root)"
required: false
default: ""
runs:
using: "composite"
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: "npm"
cache-dependency-path: package-lock.json
- name: Check for SSH URLs in lockfile
if: inputs.check-lockfile == 'true'
shell: bash
run: npm run lint:lockfile
- name: Configure Git for HTTPS
shell: bash
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
# This is needed because SSH authentication isn't available in CI
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Install dependencies
shell: bash
# Use npm install instead of npm ci to correctly resolve platform-specific
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
# Skip scripts to avoid electron-builder install-app-deps which uses too much memory
run: npm install --ignore-scripts
- name: Install Linux native bindings
shell: bash
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Explicitly install Linux bindings needed for build tools
run: |
npm install --no-save --force --ignore-scripts \
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Rebuild native modules (root)
if: inputs.rebuild-node-pty-path == ''
shell: bash
# Rebuild node-pty and other native modules for Electron
run: npm rebuild node-pty
- name: Rebuild native modules (workspace)
if: inputs.rebuild-node-pty-path != ''
shell: bash
# Rebuild node-pty and other native modules needed for server
run: npm rebuild node-pty
working-directory: ${{ inputs.rebuild-node-pty-path }}

View File

@@ -18,15 +18,34 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup project
uses: ./.github/actions/setup-project
- name: Setup Node.js
uses: actions/setup-node@v4
with:
check-lockfile: "true"
rebuild-node-pty-path: "apps/server"
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
- name: Configure Git for HTTPS
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
# This is needed because SSH authentication isn't available in CI
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Install dependencies
# Use npm install instead of npm ci to correctly resolve platform-specific
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
run: npm install
- name: Install Linux native bindings
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Explicitly install Linux bindings needed for build tools
run: |
npm install --no-save --force \
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
working-directory: apps/ui
working-directory: apps/app
- name: Build server
run: npm run build --workspace=apps/server
@@ -52,20 +71,20 @@ jobs:
exit 1
- name: Run E2E tests
# Playwright automatically starts the Vite frontend via webServer config
# (see apps/ui/playwright.config.ts) - no need to start it manually
run: npm run test --workspace=apps/ui
# Playwright automatically starts the Next.js frontend via webServer config
# (see apps/app/playwright.config.ts) - no need to start it manually
run: npm run test --workspace=apps/app
env:
CI: true
VITE_SERVER_URL: http://localhost:3008
VITE_SKIP_SETUP: "true"
NEXT_PUBLIC_SERVER_URL: http://localhost:3008
NEXT_PUBLIC_SKIP_SETUP: "true"
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: apps/ui/playwright-report/
path: apps/app/playwright-report/
retention-days: 7
- name: Upload test results
@@ -73,5 +92,5 @@ jobs:
if: failure()
with:
name: test-results
path: apps/ui/test-results/
path: apps/app/test-results/
retention-days: 7

View File

@@ -17,10 +17,33 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup project
uses: ./.github/actions/setup-project
- name: Setup Node.js
uses: actions/setup-node@v4
with:
check-lockfile: "true"
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
- name: Run build:electron (dir only - faster CI)
run: npm run build:electron:dir
- name: Check for SSH URLs in lockfile
run: npm run lint:lockfile
- name: Configure Git for HTTPS
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
# This is needed because SSH authentication isn't available in CI
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Install dependencies
# Use npm install instead of npm ci to correctly resolve platform-specific
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
run: npm install
- name: Install Linux native bindings
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Explicitly install Linux bindings needed for build tools
run: |
npm install --no-save --force \
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Run build:electron
run: npm run build:electron

180
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,180 @@
name: Build and Release Electron App
on:
push:
tags:
- "v*.*.*" # Triggers on version tags like v1.0.0
workflow_dispatch: # Allows manual triggering
inputs:
version:
description: "Version to release (e.g., v1.0.0)"
required: true
default: "v0.1.0"
jobs:
build-and-release:
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
name: macOS
artifact-name: macos-builds
- os: windows-latest
name: Windows
artifact-name: windows-builds
- os: ubuntu-latest
name: Linux
artifact-name: linux-builds
runs-on: ${{ matrix.os }}
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
- name: Configure Git for HTTPS
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
# This is needed because SSH authentication isn't available in CI
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Install dependencies
# Use npm install instead of npm ci to correctly resolve platform-specific
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
run: npm install
- name: Install Linux native bindings
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Only needed on Linux - macOS and Windows get their bindings automatically
if: matrix.os == 'ubuntu-latest'
run: |
npm install --no-save --force \
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Extract and set version
id: version
shell: bash
run: |
VERSION_TAG="${{ github.event.inputs.version || github.ref_name }}"
# Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0)
VERSION="${VERSION_TAG#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Extracted version: $VERSION from tag: $VERSION_TAG"
# Update the app's package.json version
cd apps/app
npm version $VERSION --no-git-tag-version
cd ../..
echo "Updated apps/app/package.json to version $VERSION"
- name: Build Electron App (macOS)
if: matrix.os == 'macos-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm run build:electron -- --mac --x64 --arm64
- name: Build Electron App (Windows)
if: matrix.os == 'windows-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm run build:electron -- --win --x64
- name: Build Electron App (Linux)
if: matrix.os == 'ubuntu-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm run build:electron -- --linux --x64
- name: Upload Release Assets
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ github.event.inputs.version || github.ref_name }}
files: |
apps/app/dist/*.exe
apps/app/dist/*.dmg
apps/app/dist/*.AppImage
apps/app/dist/*.zip
apps/app/dist/*.deb
apps/app/dist/*.rpm
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload macOS artifacts for R2
if: matrix.os == 'macos-latest'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact-name }}
path: apps/app/dist/*.dmg
retention-days: 1
- name: Upload Windows artifacts for R2
if: matrix.os == 'windows-latest'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact-name }}
path: apps/app/dist/*.exe
retention-days: 1
- name: Upload Linux artifacts for R2
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact-name }}
path: apps/app/dist/*.AppImage
retention-days: 1
upload-to-r2:
needs: build-and-release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Install AWS SDK
run: npm install @aws-sdk/client-s3
- name: Extract version
id: version
shell: bash
run: |
VERSION_TAG="${{ github.event.inputs.version || github.ref_name }}"
# Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0)
VERSION="${VERSION_TAG#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_tag=$VERSION_TAG" >> $GITHUB_OUTPUT
echo "Extracted version: $VERSION from tag: $VERSION_TAG"
- name: Upload to R2 and update releases.json
env:
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
RELEASE_VERSION: ${{ steps.version.outputs.version }}
RELEASE_TAG: ${{ steps.version.outputs.version_tag }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: node .github/scripts/upload-to-r2.js

View File

@@ -17,11 +17,30 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup project
uses: ./.github/actions/setup-project
- name: Setup Node.js
uses: actions/setup-node@v4
with:
check-lockfile: "true"
rebuild-node-pty-path: "apps/server"
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
- name: Configure Git for HTTPS
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
# This is needed because SSH authentication isn't available in CI
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Install dependencies
# Use npm install instead of npm ci to correctly resolve platform-specific
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
run: npm install
- name: Install Linux native bindings
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Explicitly install Linux bindings needed for build tools
run: |
npm install --no-save --force \
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Run server tests with coverage
run: npm run test:server:coverage

View File

@@ -1,5 +1,5 @@
<p align="center">
<img src="apps/ui/public/readme_logo.png" alt="Automaker Logo" height="80" />
<img src="apps/app/public/readme_logo.png" alt="Automaker Logo" height="80" />
</p>
> **[!TIP]**
@@ -88,7 +88,6 @@ The future of software development is **agentic coding**—where developers beco
Join the **Agentic Jumpstart** to connect with other builders exploring **agentic coding** and autonomous development workflows.
In the Discord, you can:
- 💬 Discuss agentic coding patterns and best practices
- 🧠 Share ideas for AI-driven development workflows
- 🛠️ Get help setting up or extending Automaker
@@ -253,16 +252,19 @@ This project is licensed under the **Automaker License Agreement**. See [LICENSE
**Summary of Terms:**
- **Allowed:**
- **Build Anything:** You can clone and use Automaker locally or in your organization to build ANY product (commercial or free).
- **Internal Use:** You can use it internally within your company (commercial or non-profit) without restriction.
- **Modify:** You can modify the code for internal use within your organization (commercial or non-profit).
- **Restricted (The "No Monetization of the Tool" Rule):**
- **No Resale:** You cannot resell Automaker itself.
- **No SaaS:** You cannot host Automaker as a service for others.
- **No Monetizing Mods:** You cannot distribute modified versions of Automaker for money.
- **Liability:**
- **Use at Own Risk:** This tool uses AI. We are **NOT** responsible if it breaks your computer, deletes your files, or generates bad code. You assume all risk.
- **Contributing:**

310
REFACTORING_CANDIDATES.md Normal file
View File

@@ -0,0 +1,310 @@
# Large Files - Refactoring Candidates
This document tracks files in the AutoMaker codebase that exceed 3000 lines or are significantly large (1000+ lines) and should be considered for refactoring into smaller, more maintainable components.
**Last Updated:** 2025-12-15
**Total Large Files:** 8
**Combined Size:** 15,027 lines
---
## 🔴 CRITICAL - Over 3000 Lines
### 1. board-view.tsx - 3,325 lines
**Path:** `apps/app/src/components/views/board-view.tsx`
**Type:** React Component (TSX)
**Priority:** VERY HIGH
**Description:**
Main Kanban board view component that serves as the centerpiece of the application.
**Current Responsibilities:**
- Feature/task card management and drag-and-drop operations using @dnd-kit
- Adding, editing, and deleting features
- Running autonomous agents to implement features
- Displaying feature status across multiple columns (Backlog, In Progress, Waiting Approval, Verified)
- Model/AI profile selection for feature implementation
- Advanced options configuration (thinking level, model selection, skip tests)
- Search/filtering functionality for cards
- Output modal for viewing agent results
- Feature suggestions dialog
- Board background customization
- Integration with Electron APIs for IPC communication
- Keyboard shortcuts support
- 40+ state variables for managing UI state
**Refactoring Recommendations:**
Extract into smaller components:
- `AddFeatureDialog.tsx` - Feature creation dialog with image upload
- `EditFeatureDialog.tsx` - Feature editing dialog
- `AgentOutputModal.tsx` - Already exists, verify separation
- `FeatureSuggestionsDialog.tsx` - Already exists, verify separation
- `BoardHeader.tsx` - Header with controls and search
- `BoardSearchBar.tsx` - Search and filter functionality
- `ConcurrencyControl.tsx` - Concurrency slider component
- `BoardActions.tsx` - Action buttons (add feature, auto mode, etc.)
- `DragDropContext.tsx` - Wrap drag-and-drop logic
- Custom hooks:
- `useBoardFeatures.ts` - Feature loading and management
- `useBoardDragDrop.ts` - Drag and drop handlers
- `useBoardActions.ts` - Feature action handlers (run, verify, delete, etc.)
- `useBoardKeyboardShortcuts.ts` - Keyboard shortcut logic
---
## 🟡 HIGH PRIORITY - 2000+ Lines
### 2. sidebar.tsx - 2,396 lines
**Path:** `apps/app/src/components/layout/sidebar.tsx`
**Type:** React Component (TSX)
**Priority:** HIGH
**Description:**
Main navigation sidebar with comprehensive project management.
**Current Responsibilities:**
- Project folder navigation and selection
- View mode switching (Board, Agent, Settings, etc.)
- Project operations (create, delete, rename)
- Theme and appearance controls
- Terminal, Wiki, and other view launchers
- Drag-and-drop project reordering
- Settings and configuration access
**Refactoring Recommendations:**
Split into focused components:
- `ProjectSelector.tsx` - Project list and selection
- `NavigationTabs.tsx` - View mode tabs
- `ProjectActions.tsx` - Create, delete, rename operations
- `SettingsMenu.tsx` - Settings dropdown
- `ThemeSelector.tsx` - Theme controls
- `ViewLaunchers.tsx` - Terminal, Wiki launchers
- Custom hooks:
- `useProjectManagement.ts` - Project CRUD operations
- `useSidebarState.ts` - Sidebar state management
---
### 3. electron.ts - 2,356 lines
**Path:** `apps/app/src/lib/electron.ts`
**Type:** TypeScript Utility/API Bridge
**Priority:** HIGH
**Description:**
Electron IPC bridge and type definitions for frontend-backend communication.
**Current Responsibilities:**
- File system operations (read, write, directory listing)
- Project management APIs
- Feature management APIs
- Terminal/shell execution
- Auto mode and agent execution APIs
- Worktree management
- Provider status APIs
- Event handling and subscriptions
**Refactoring Recommendations:**
Modularize into domain-specific API modules:
- `api/file-system-api.ts` - File operations
- `api/project-api.ts` - Project CRUD
- `api/feature-api.ts` - Feature management
- `api/execution-api.ts` - Auto mode and agent execution
- `api/provider-api.ts` - Provider status and management
- `api/worktree-api.ts` - Git worktree operations
- `api/terminal-api.ts` - Terminal/shell APIs
- `types/electron-types.ts` - Shared type definitions
- `electron.ts` - Main export aggregator
---
### 4. app-store.ts - 2,174 lines
**Path:** `apps/app/src/store/app-store.ts`
**Type:** TypeScript State Management (Zustand Store)
**Priority:** HIGH
**Description:**
Centralized application state store using Zustand.
**Current Responsibilities:**
- Global app state types and interfaces
- Project and feature management state
- Theme and appearance settings
- API keys configuration
- Keyboard shortcuts configuration
- Terminal themes configuration
- Auto mode settings
- All store mutations and selectors
**Refactoring Recommendations:**
Split into domain-specific stores:
- `stores/projects-store.ts` - Project state and actions
- `stores/features-store.ts` - Feature state and actions
- `stores/ui-store.ts` - UI state (theme, sidebar, modals)
- `stores/settings-store.ts` - User settings and preferences
- `stores/execution-store.ts` - Auto mode and running tasks
- `stores/provider-store.ts` - Provider configuration
- `types/store-types.ts` - Shared type definitions
- `app-store.ts` - Main store aggregator with combined selectors
---
## 🟢 MEDIUM PRIORITY - 1000-2000 Lines
### 5. auto-mode-service.ts - 1,232 lines
**Path:** `apps/server/src/services/auto-mode-service.ts`
**Type:** TypeScript Service (Backend)
**Priority:** MEDIUM-HIGH
**Description:**
Core autonomous feature implementation service.
**Current Responsibilities:**
- Worktree creation and management
- Feature execution with Claude Agent SDK
- Concurrent execution with concurrency limits
- Progress streaming via events
- Verification and merge workflows
- Provider management
- Error handling and classification
**Refactoring Recommendations:**
Extract into service modules:
- `services/worktree-manager.ts` - Worktree operations
- `services/feature-executor.ts` - Feature execution logic
- `services/concurrency-manager.ts` - Concurrency control
- `services/verification-service.ts` - Verification workflows
- `utils/error-classifier.ts` - Error handling utilities
---
### 6. spec-view.tsx - 1,230 lines
**Path:** `apps/app/src/components/views/spec-view.tsx`
**Type:** React Component (TSX)
**Priority:** MEDIUM
**Description:**
Specification editor view component for feature specification management.
**Refactoring Recommendations:**
Extract editor components and hooks:
- `SpecEditor.tsx` - Main editor component
- `SpecToolbar.tsx` - Editor toolbar
- `SpecSidebar.tsx` - Spec navigation sidebar
- `useSpecEditor.ts` - Editor state management
---
### 7. kanban-card.tsx - 1,180 lines
**Path:** `apps/app/src/components/views/kanban-card.tsx`
**Type:** React Component (TSX)
**Priority:** MEDIUM
**Description:**
Individual Kanban card component with rich feature display and interaction.
**Refactoring Recommendations:**
Split into smaller card components:
- `KanbanCardHeader.tsx` - Card title and metadata
- `KanbanCardBody.tsx` - Card content
- `KanbanCardActions.tsx` - Action buttons
- `KanbanCardStatus.tsx` - Status indicators
- `useKanbanCard.ts` - Card interaction logic
---
### 8. analysis-view.tsx - 1,134 lines
**Path:** `apps/app/src/components/views/analysis-view.tsx`
**Type:** React Component (TSX)
**Priority:** MEDIUM
**Description:**
Analysis view component for displaying and managing feature analysis data.
**Refactoring Recommendations:**
Extract visualization and data components:
- `AnalysisChart.tsx` - Chart/graph components
- `AnalysisTable.tsx` - Data table
- `AnalysisFilters.tsx` - Filter controls
- `useAnalysisData.ts` - Data fetching and processing
---
## Refactoring Strategy
### Phase 1: Critical (Immediate)
1. **board-view.tsx** - Break into dialogs, header, and custom hooks
- Extract all dialogs first (AddFeature, EditFeature)
- Move to custom hooks for business logic
- Split remaining UI into smaller components
### Phase 2: High Priority (Next Sprint)
2. **sidebar.tsx** - Componentize navigation and project management
3. **electron.ts** - Modularize into API domains
4. **app-store.ts** - Split into domain stores
### Phase 3: Medium Priority (Future)
5. **auto-mode-service.ts** - Extract service modules
6. **spec-view.tsx** - Break into editor components
7. **kanban-card.tsx** - Split card into sub-components
8. **analysis-view.tsx** - Extract visualization components
---
## General Refactoring Guidelines
### When Refactoring Large Components:
1. **Extract Dialogs/Modals First**
- Move dialog components to separate files
- Keep dialog state management in parent initially
- Later extract to custom hooks if complex
2. **Create Custom Hooks for Business Logic**
- Move data fetching to `useFetch*` hooks
- Move complex state logic to `use*State` hooks
- Move side effects to `use*Effect` hooks
3. **Split UI into Presentational Components**
- Header/toolbar components
- Content area components
- Footer/action components
4. **Move Utils and Helpers**
- Extract pure functions to utility files
- Move constants to separate constant files
- Create type files for shared interfaces
### When Refactoring Large Files:
1. **Identify Domains/Concerns**
- Group related functionality
- Find natural boundaries
2. **Extract Gradually**
- Start with least coupled code
- Work towards core functionality
- Test after each extraction
3. **Maintain Type Safety**
- Export types from extracted modules
- Use shared type files for common interfaces
- Ensure no type errors after refactoring
---
## Progress Tracking
- [ ] board-view.tsx (3,325 lines)
- [ ] sidebar.tsx (2,396 lines)
- [ ] electron.ts (2,356 lines)
- [ ] app-store.ts (2,174 lines)
- [ ] auto-mode-service.ts (1,232 lines)
- [ ] spec-view.tsx (1,230 lines)
- [ ] kanban-card.tsx (1,180 lines)
- [ ] analysis-view.tsx (1,134 lines)
**Target:** All files under 500 lines, most under 300 lines
---
*Generated: 2025-12-15*

View File

@@ -13,9 +13,12 @@
# testing
/coverage
# Vite
/dist/
/dist-electron/
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
@@ -30,8 +33,12 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# Playwright
/test-results/
@@ -40,8 +47,5 @@ yarn-error.log*
/playwright/.cache/
# Electron
/release/
/dist/
/server-bundle/
# TanStack Router generated
src/routeTree.gen.ts

View File

@@ -0,0 +1,5 @@
module.exports = {
rules: {
"@typescript-eslint/no-require-imports": "off",
},
};

View File

@@ -1,53 +1,47 @@
/**
* Electron main process (TypeScript)
* Simplified Electron main process
*
* This version spawns the backend server and uses HTTP API for most operations.
* Only native features (dialogs, shell) use IPC.
*/
import path from "path";
import { spawn, ChildProcess } from "child_process";
import fs from "fs";
import http, { Server } from "http";
import { app, BrowserWindow, ipcMain, dialog, shell } from "electron";
// Development environment
const isDev = !app.isPackaged;
const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
const path = require("path");
const { spawn } = require("child_process");
const fs = require("fs");
const http = require("http");
const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron");
// Load environment variables from .env file (development only)
if (isDev) {
if (!app.isPackaged) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("dotenv").config({ path: path.join(__dirname, "../.env") });
} catch (error) {
console.warn("[Electron] dotenv not available:", (error as Error).message);
console.warn("[Electron] dotenv not available:", error.message);
}
}
let mainWindow: BrowserWindow | null = null;
let serverProcess: ChildProcess | null = null;
let staticServer: Server | null = null;
let mainWindow = null;
let serverProcess = null;
let staticServer = null;
const SERVER_PORT = 3008;
const STATIC_PORT = 3007;
/**
* Get icon path - works in both dev and production, cross-platform
*/
function getIconPath(): string | null {
let iconFile: string;
// Get icon path - works in both dev and production, cross-platform
function getIconPath() {
// Different icon formats for different platforms
let iconFile;
if (process.platform === "win32") {
iconFile = "icon.ico";
} else if (process.platform === "darwin") {
iconFile = "logo_larger.png";
} else {
// Linux
iconFile = "logo_larger.png";
}
const iconPath = isDev
? path.join(__dirname, "../public", iconFile)
: path.join(__dirname, "../dist/public", iconFile);
const iconPath = path.join(__dirname, "../public", iconFile);
// Verify the icon exists
if (!fs.existsSync(iconPath)) {
console.warn(`[Electron] Icon not found at: ${iconPath}`);
return null;
@@ -59,29 +53,28 @@ function getIconPath(): string | null {
/**
* Start static file server for production builds
*/
async function startStaticServer(): Promise<void> {
const staticPath = path.join(__dirname, "../dist");
async function startStaticServer() {
const staticPath = path.join(__dirname, "../out");
staticServer = http.createServer((request, response) => {
let filePath = path.join(staticPath, request.url?.split("?")[0] || "/");
// Parse the URL and remove query string
let filePath = path.join(staticPath, request.url.split("?")[0]);
// Default to index.html for directory requests
if (filePath.endsWith("/")) {
filePath = path.join(filePath, "index.html");
} else if (!path.extname(filePath)) {
// For client-side routing, serve index.html for paths without extensions
const possibleFile = filePath + ".html";
if (!fs.existsSync(filePath) && !fs.existsSync(possibleFile)) {
filePath = path.join(staticPath, "index.html");
} else if (fs.existsSync(possibleFile)) {
filePath = possibleFile;
}
filePath += ".html";
}
// Check if file exists
fs.stat(filePath, (err, stats) => {
if (err || !stats?.isFile()) {
if (err || !stats.isFile()) {
// Try index.html for SPA fallback
filePath = path.join(staticPath, "index.html");
}
// Read and serve the file
fs.readFile(filePath, (error, content) => {
if (error) {
response.writeHead(500);
@@ -89,8 +82,9 @@ async function startStaticServer(): Promise<void> {
return;
}
// Set content type based on file extension
const ext = path.extname(filePath);
const contentTypes: Record<string, string> = {
const contentTypes = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
@@ -106,44 +100,53 @@ async function startStaticServer(): Promise<void> {
".eot": "application/vnd.ms-fontobject",
};
response.writeHead(200, {
"Content-Type": contentTypes[ext] || "application/octet-stream",
});
response.writeHead(200, { "Content-Type": contentTypes[ext] || "application/octet-stream" });
response.end(content);
});
});
});
return new Promise((resolve, reject) => {
staticServer!.listen(STATIC_PORT, () => {
console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`);
resolve();
staticServer.listen(STATIC_PORT, (err) => {
if (err) {
reject(err);
} else {
console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`);
resolve();
}
});
staticServer!.on("error", reject);
});
}
/**
* Start the backend server
*/
async function startServer(): Promise<void> {
let command: string;
let args: string[];
let serverPath: string;
async function startServer() {
const isDev = !app.isPackaged;
// Server entry point - use tsx in dev, compiled version in production
let command, args, serverPath;
if (isDev) {
// In development, use tsx to run TypeScript directly
// Use node from PATH (process.execPath in Electron points to Electron, not Node.js)
// spawn() resolves "node" from PATH on all platforms (Windows, Linux, macOS)
command = "node";
serverPath = path.join(__dirname, "../../server/src/index.ts");
const serverNodeModules = path.join(__dirname, "../../server/node_modules/tsx");
// Find tsx CLI - check server node_modules first, then root
const serverNodeModules = path.join(
__dirname,
"../../server/node_modules/tsx"
);
const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx");
let tsxCliPath: string;
let tsxCliPath;
if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) {
tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs");
} else if (fs.existsSync(path.join(rootNodeModules, "dist/cli.mjs"))) {
tsxCliPath = path.join(rootNodeModules, "dist/cli.mjs");
} else {
// Last resort: try require.resolve
try {
tsxCliPath = require.resolve("tsx/cli.mjs", {
paths: [path.join(__dirname, "../../server")],
@@ -157,21 +160,26 @@ async function startServer(): Promise<void> {
args = [tsxCliPath, "watch", serverPath];
} else {
// In production, use compiled JavaScript
command = "node";
serverPath = path.join(process.resourcesPath, "server", "index.js");
args = [serverPath];
// Verify server files exist
if (!fs.existsSync(serverPath)) {
throw new Error(`Server not found at: ${serverPath}`);
}
}
// Set environment variables for server
const serverNodeModules = app.isPackaged
? path.join(process.resourcesPath, "server", "node_modules")
: path.join(__dirname, "../../server/node_modules");
// Set default workspace directory to user's Documents/Automaker
const defaultWorkspaceDir = path.join(app.getPath("documents"), "Automaker");
// Ensure workspace directory exists
if (!fs.existsSync(defaultWorkspaceDir)) {
try {
fs.mkdirSync(defaultWorkspaceDir, { recursive: true });
@@ -199,11 +207,11 @@ async function startServer(): Promise<void> {
stdio: ["ignore", "pipe", "pipe"],
});
serverProcess.stdout?.on("data", (data) => {
serverProcess.stdout.on("data", (data) => {
console.log(`[Server] ${data.toString().trim()}`);
});
serverProcess.stderr?.on("data", (data) => {
serverProcess.stderr.on("data", (data) => {
console.error(`[Server Error] ${data.toString().trim()}`);
});
@@ -217,16 +225,19 @@ async function startServer(): Promise<void> {
serverProcess = null;
});
// Wait for server to be ready
await waitForServer();
}
/**
* Wait for server to be available
*/
async function waitForServer(maxAttempts = 30): Promise<void> {
async function waitForServer(maxAttempts = 30) {
const http = require("http");
for (let i = 0; i < maxAttempts; i++) {
try {
await new Promise<void>((resolve, reject) => {
await new Promise((resolve, reject) => {
const req = http.get(
`http://localhost:${SERVER_PORT}/api/health`,
(res) => {
@@ -256,13 +267,13 @@ async function waitForServer(maxAttempts = 30): Promise<void> {
/**
* Create the main window
*/
function createWindow(): void {
function createWindow() {
const iconPath = getIconPath();
const windowOptions: Electron.BrowserWindowConstructorOptions = {
width: 1600,
height: 950,
minWidth: 1280,
minHeight: 768,
const windowOptions = {
width: 1400,
height: 900,
minWidth: 1024,
minHeight: 700,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
@@ -272,22 +283,16 @@ function createWindow(): void {
backgroundColor: "#0a0a0a",
};
// Only set icon if it exists
if (iconPath) {
windowOptions.icon = iconPath;
}
mainWindow = new BrowserWindow(windowOptions);
// Load Vite dev server in development or static server in production
if (VITE_DEV_SERVER_URL) {
mainWindow.loadURL(VITE_DEV_SERVER_URL);
} else if (isDev) {
// Fallback for dev without Vite server URL
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
} else {
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
}
// Load Next.js dev server in development or static server in production
const isDev = !app.isPackaged;
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
if (isDev && process.env.OPEN_DEVTOOLS === "true") {
mainWindow.webContents.openDevTools();
}
@@ -296,6 +301,7 @@ function createWindow(): void {
mainWindow = null;
});
// Handle external links - open in default browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: "deny" };
@@ -304,13 +310,14 @@ function createWindow(): void {
// App lifecycle
app.whenReady().then(async () => {
// Set app icon (dock icon on macOS)
if (process.platform === "darwin" && app.dock) {
const iconPath = getIconPath();
if (iconPath) {
try {
app.dock.setIcon(iconPath);
} catch (error) {
console.warn("[Electron] Failed to set dock icon:", (error as Error).message);
console.warn("[Electron] Failed to set dock icon:", error.message);
}
}
}
@@ -345,12 +352,14 @@ app.on("window-all-closed", () => {
});
app.on("before-quit", () => {
// Kill server process
if (serverProcess) {
console.log("[Electron] Stopping server...");
serverProcess.kill();
serverProcess = null;
}
// Close static server
if (staticServer) {
console.log("[Electron] Stopping static server...");
staticServer.close();
@@ -364,9 +373,6 @@ app.on("before-quit", () => {
// Native file dialogs
ipcMain.handle("dialog:openDirectory", async () => {
if (!mainWindow) {
return { canceled: true, filePaths: [] };
}
const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openDirectory", "createDirectory"],
});
@@ -374,9 +380,6 @@ ipcMain.handle("dialog:openDirectory", async () => {
});
ipcMain.handle("dialog:openFile", async (_, options = {}) => {
if (!mainWindow) {
return { canceled: true, filePaths: [] };
}
const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openFile"],
...options,
@@ -385,34 +388,31 @@ ipcMain.handle("dialog:openFile", async (_, options = {}) => {
});
ipcMain.handle("dialog:saveFile", async (_, options = {}) => {
if (!mainWindow) {
return { canceled: true, filePath: undefined };
}
const result = await dialog.showSaveDialog(mainWindow, options);
return result;
});
// Shell operations
ipcMain.handle("shell:openExternal", async (_, url: string) => {
ipcMain.handle("shell:openExternal", async (_, url) => {
try {
await shell.openExternal(url);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
return { success: false, error: error.message };
}
});
ipcMain.handle("shell:openPath", async (_, filePath: string) => {
ipcMain.handle("shell:openPath", async (_, filePath) => {
try {
await shell.openPath(filePath);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
return { success: false, error: error.message };
}
});
// App info
ipcMain.handle("app:getPath", async (_, name: Parameters<typeof app.getPath>[0]) => {
ipcMain.handle("app:getPath", async (_, name) => {
return app.getPath(name);
});

View File

@@ -0,0 +1,37 @@
/**
* Simplified Electron preload script
*
* Only exposes native features (dialogs, shell) and server URL.
* All other operations go through HTTP API.
*/
const { contextBridge, ipcRenderer } = require("electron");
// Expose minimal API for native features
contextBridge.exposeInMainWorld("electronAPI", {
// Platform info
platform: process.platform,
isElectron: true,
// Connection check
ping: () => ipcRenderer.invoke("ping"),
// Get server URL for HTTP client
getServerUrl: () => ipcRenderer.invoke("server:getUrl"),
// Native dialogs - better UX than prompt()
openDirectory: () => ipcRenderer.invoke("dialog:openDirectory"),
openFile: (options) => ipcRenderer.invoke("dialog:openFile", options),
saveFile: (options) => ipcRenderer.invoke("dialog:saveFile", options),
// Shell operations
openExternalLink: (url) => ipcRenderer.invoke("shell:openExternal", url),
openPath: (filePath) => ipcRenderer.invoke("shell:openPath", filePath),
// App info
getPath: (name) => ipcRenderer.invoke("app:getPath", name),
getVersion: () => ipcRenderer.invoke("app:getVersion"),
isPackaged: () => ipcRenderer.invoke("app:isPackaged"),
});
console.log("[Preload] Electron API exposed (simplified mode)");

View File

@@ -0,0 +1,20 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
// Electron files use CommonJS
"electron/**",
]),
]);
export default eslintConfig;

7
apps/app/next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
};
export default nextConfig;

View File

@@ -1,5 +1,5 @@
{
"name": "@automaker/ui",
"name": "@automaker/app",
"version": "0.1.0",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker",
@@ -13,29 +13,25 @@
},
"private": true,
"license": "Unlicense",
"main": "dist-electron/main.js",
"main": "electron/main.js",
"scripts": {
"dev": "vite",
"dev:web": "cross-env VITE_SKIP_ELECTRON=true vite",
"dev:electron": "vite",
"dev:electron:debug": "cross-env OPEN_DEVTOOLS=true vite",
"build": "vite build",
"build:electron": "node scripts/prepare-server.mjs && vite build && electron-builder",
"build:electron:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --dir",
"build:electron:win": "node scripts/prepare-server.mjs && vite build && electron-builder --win",
"build:electron:win:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --win --dir",
"build:electron:mac": "node scripts/prepare-server.mjs && vite build && electron-builder --mac",
"build:electron:mac:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --mac --dir",
"build:electron:linux": "node scripts/prepare-server.mjs && vite build && electron-builder --linux",
"build:electron:linux:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --linux --dir",
"dev": "next dev -p 3007",
"dev:web": "next dev -p 3007",
"dev:electron": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron .\"",
"dev:electron:debug": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && OPEN_DEVTOOLS=true electron .\"",
"build": "next build",
"build:electron": "node scripts/prepare-server.js && next build && electron-builder",
"build:electron:win": "node scripts/prepare-server.js && next build && electron-builder --win",
"build:electron:mac": "node scripts/prepare-server.js && next build && electron-builder --mac",
"build:electron:linux": "node scripts/prepare-server.js && next build && electron-builder --linux",
"postinstall": "electron-builder install-app-deps",
"preview": "vite preview",
"start": "next start",
"lint": "eslint",
"pretest": "node scripts/setup-e2e-fixtures.mjs",
"pretest": "node scripts/setup-e2e-fixtures.js",
"test": "playwright test",
"test:headed": "playwright test --headed",
"dev:electron:wsl": "cross-env vite",
"dev:electron:wsl:gpu": "cross-env MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA vite"
"dev:electron:wsl": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron . --no-sandbox --disable-gpu\"",
"dev:electron:wsl:gpu": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA electron . --no-sandbox --disable-gpu-sandbox\""
},
"dependencies": {
"@codemirror/lang-xml": "^6.1.0",
@@ -49,15 +45,12 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-router": "^1.141.6",
"@uiw/react-codemirror": "^4.25.4",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.18.0",
@@ -67,9 +60,10 @@
"cmdk": "^1.1.1",
"dotenv": "^17.2.3",
"geist": "^1.5.1",
"lucide-react": "^0.562.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"lucide-react": "^0.556.0",
"next": "^16.0.10",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"sonner": "^2.0.7",
@@ -89,39 +83,32 @@
},
"devDependencies": {
"@electron/rebuild": "^4.0.2",
"@eslint/js": "^9.0.0",
"@playwright/test": "^1.57.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/router-plugin": "^1.141.7",
"@types/node": "^22",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.50.0",
"@typescript-eslint/parser": "^8.50.0",
"@vitejs/plugin-react": "^5.1.2",
"cross-env": "^10.1.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.2.1",
"electron": "39.2.7",
"electron-builder": "^26.0.12",
"eslint": "^9.39.2",
"tailwindcss": "^4.1.18",
"eslint": "^9",
"eslint-config-next": "16.0.7",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "5.9.3",
"vite": "^7.3.0",
"vite-plugin-electron": "^0.29.0",
"vite-plugin-electron-renderer": "^0.14.6"
"wait-on": "^9.0.3"
},
"build": {
"appId": "com.automaker.app",
"productName": "Automaker",
"artifactName": "${productName}-${version}-${arch}.${ext}",
"npmRebuild": false,
"afterPack": "./scripts/rebuild-server-natives.cjs",
"afterPack": "./scripts/rebuild-server-natives.js",
"directories": {
"output": "release"
"output": "dist"
},
"files": [
"dist/**/*",
"dist-electron/**/*",
"electron/**/*",
"out/**/*",
"public/**/*",
"!node_modules/**/*"
],

View File

@@ -3,15 +3,14 @@ import { defineConfig, devices } from "@playwright/test";
const port = process.env.TEST_PORT || 3007;
const serverPort = process.env.TEST_SERVER_PORT || 3008;
const reuseServer = process.env.TEST_REUSE_SERVER === "true";
const mockAgent =
process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true";
const mockAgent = process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: undefined,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
timeout: 30000,
use: {
@@ -44,17 +43,15 @@ export default defineConfig({
ALLOWED_PROJECT_DIRS: "/Users,/home,/tmp,/var/folders",
},
},
// Frontend Vite dev server
// Frontend Next.js server
{
command: `npm run dev`,
command: `npx next dev -p ${port}`,
url: `http://localhost:${port}`,
reuseExistingServer: true,
timeout: 120000,
env: {
...process.env,
VITE_SKIP_SETUP: "true",
// Skip electron plugin in CI - no display available for Electron
VITE_SKIP_ELECTRON: process.env.CI === "true" ? "true" : undefined,
NEXT_PUBLIC_SKIP_SETUP: "true",
},
},
],

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 391 B

After

Width:  |  Height:  |  Size: 391 B

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 317 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

View File

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 128 B

View File

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -12,7 +12,7 @@ import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Resolve workspace root (apps/ui/scripts -> workspace root)
// Resolve workspace root (apps/app/scripts -> workspace root)
const WORKSPACE_ROOT = path.resolve(__dirname, "../../..");
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, "test/fixtures/projectA");
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, ".automaker/app_spec.txt");

View File

@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from "next/server";
interface AnthropicResponse {
content?: Array<{ type: string; text?: string }>;
model?: string;
error?: { message?: string };
}
export async function POST(request: NextRequest) {
try {
const { apiKey } = await request.json();
// Use provided API key or fall back to environment variable
const effectiveApiKey = apiKey || process.env.ANTHROPIC_API_KEY;
if (!effectiveApiKey) {
return NextResponse.json(
{ success: false, error: "No API key provided or configured in environment" },
{ status: 400 }
);
}
// Send a simple test prompt to the Anthropic API
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": effectiveApiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 100,
messages: [
{
role: "user",
content: "Respond with exactly: 'Claude API connection successful!' and nothing else.",
},
],
}),
});
if (!response.ok) {
const errorData = (await response.json()) as AnthropicResponse;
const errorMessage = errorData.error?.message || `HTTP ${response.status}`;
if (response.status === 401) {
return NextResponse.json(
{ success: false, error: "Invalid API key. Please check your Anthropic API key." },
{ status: 401 }
);
}
if (response.status === 429) {
return NextResponse.json(
{ success: false, error: "Rate limit exceeded. Please try again later." },
{ status: 429 }
);
}
return NextResponse.json(
{ success: false, error: `API error: ${errorMessage}` },
{ status: response.status }
);
}
const data = (await response.json()) as AnthropicResponse;
// Check if we got a valid response
if (data.content && data.content.length > 0) {
const textContent = data.content.find((block) => block.type === "text");
if (textContent && textContent.type === "text" && textContent.text) {
return NextResponse.json({
success: true,
message: `Connection successful! Response: "${textContent.text}"`,
model: data.model,
});
}
}
return NextResponse.json({
success: true,
message: "Connection successful! Claude responded.",
model: data.model,
});
} catch (error: unknown) {
console.error("Claude API test error:", error);
const errorMessage =
error instanceof Error ? error.message : "Failed to connect to Claude API";
return NextResponse.json(
{ success: false, error: errorMessage },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,191 @@
import { NextRequest, NextResponse } from "next/server";
interface GeminiContent {
parts: Array<{
text?: string;
inlineData?: {
mimeType: string;
data: string;
};
}>;
role?: string;
}
interface GeminiRequest {
contents: GeminiContent[];
generationConfig?: {
maxOutputTokens?: number;
temperature?: number;
};
}
interface GeminiResponse {
candidates?: Array<{
content: {
parts: Array<{
text: string;
}>;
role: string;
};
finishReason: string;
safetyRatings?: Array<{
category: string;
probability: string;
}>;
}>;
promptFeedback?: {
safetyRatings?: Array<{
category: string;
probability: string;
}>;
};
error?: {
code: number;
message: string;
status: string;
};
}
export async function POST(request: NextRequest) {
try {
const { apiKey, imageData, mimeType, prompt } = await request.json();
// Use provided API key or fall back to environment variable
const effectiveApiKey = apiKey || process.env.GOOGLE_API_KEY;
if (!effectiveApiKey) {
return NextResponse.json(
{ success: false, error: "No API key provided or configured in environment" },
{ status: 400 }
);
}
// Build the request body
const requestBody: GeminiRequest = {
contents: [
{
parts: [],
},
],
generationConfig: {
maxOutputTokens: 150,
temperature: 0.4,
},
};
// Add image if provided
if (imageData && mimeType) {
requestBody.contents[0].parts.push({
inlineData: {
mimeType: mimeType,
data: imageData,
},
});
}
// Add text prompt
const textPrompt = prompt || (imageData
? "Describe what you see in this image briefly."
: "Respond with exactly: 'Gemini SDK connection successful!' and nothing else.");
requestBody.contents[0].parts.push({
text: textPrompt,
});
// Call Gemini API - using gemini-1.5-flash as it supports both text and vision
const model = imageData ? "gemini-1.5-flash" : "gemini-1.5-flash";
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${effectiveApiKey}`;
const response = await fetch(geminiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
const data: GeminiResponse = await response.json();
// Check for API errors
if (data.error) {
const errorMessage = data.error.message || "Unknown Gemini API error";
const statusCode = data.error.code || 500;
if (statusCode === 400 && errorMessage.includes("API key")) {
return NextResponse.json(
{ success: false, error: "Invalid API key. Please check your Google API key." },
{ status: 401 }
);
}
if (statusCode === 429) {
return NextResponse.json(
{ success: false, error: "Rate limit exceeded. Please try again later." },
{ status: 429 }
);
}
return NextResponse.json(
{ success: false, error: `API error: ${errorMessage}` },
{ status: statusCode }
);
}
// Check for valid response
if (!response.ok) {
return NextResponse.json(
{ success: false, error: `HTTP error: ${response.status} ${response.statusText}` },
{ status: response.status }
);
}
// Extract response text
if (data.candidates && data.candidates.length > 0 && data.candidates[0].content?.parts?.length > 0) {
const responseText = data.candidates[0].content.parts
.filter((part) => part.text)
.map((part) => part.text)
.join("");
return NextResponse.json({
success: true,
message: `Connection successful! Response: "${responseText.substring(0, 200)}${responseText.length > 200 ? '...' : ''}"`,
model: model,
hasImage: !!imageData,
});
}
// Handle blocked responses
if (data.promptFeedback?.safetyRatings) {
return NextResponse.json({
success: true,
message: "Connection successful! Gemini responded (response may have been filtered).",
model: model,
hasImage: !!imageData,
});
}
return NextResponse.json({
success: true,
message: "Connection successful! Gemini responded.",
model: model,
hasImage: !!imageData,
});
} catch (error: unknown) {
console.error("Gemini API test error:", error);
if (error instanceof TypeError && error.message.includes("fetch")) {
return NextResponse.json(
{ success: false, error: "Network error. Unable to reach Gemini API." },
{ status: 503 }
);
}
const errorMessage =
error instanceof Error ? error.message : "Failed to connect to Gemini API";
return NextResponse.json(
{ success: false, error: errorMessage },
{ status: 500 }
);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

5189
apps/app/src/app/globals.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
import { Inter, JetBrains_Mono } from "next/font/google";
import { Toaster } from "sonner";
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 = {
title: "Automaker - Autonomous AI Development Studio",
description: "Build software autonomously with intelligent orchestration",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${GeistSans.variable} ${GeistMono.variable} ${inter.variable} ${jetbrainsMono.variable} antialiased`}
>
{children}
<Toaster richColors position="bottom-right" />
</body>
</html>
);
}

235
apps/app/src/app/page.tsx Normal file
View File

@@ -0,0 +1,235 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Sidebar } from "@/components/layout/sidebar";
import { WelcomeView } from "@/components/views/welcome-view";
import { BoardView } from "@/components/views/board-view";
import { SpecView } from "@/components/views/spec-view";
import { AgentView } from "@/components/views/agent-view";
import { SettingsView } from "@/components/views/settings-view";
import { InterviewView } from "@/components/views/interview-view";
import { ContextView } from "@/components/views/context-view";
import { ProfilesView } from "@/components/views/profiles-view";
import { SetupView } from "@/components/views/setup-view";
import { RunningAgentsView } from "@/components/views/running-agents-view";
import { TerminalView } from "@/components/views/terminal-view";
import { WikiView } from "@/components/views/wiki-view";
import { useAppStore } from "@/store/app-store";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI, isElectron } from "@/lib/electron";
import {
FileBrowserProvider,
useFileBrowser,
setGlobalFileBrowser,
} from "@/contexts/file-browser-context";
function HomeContent() {
const {
currentView,
setCurrentView,
setIpcConnected,
theme,
currentProject,
previewTheme,
getEffectiveTheme,
} = useAppStore();
const { isFirstRun, setupComplete } = useSetupStore();
const [isMounted, setIsMounted] = useState(false);
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
const { openFileBrowser } = useFileBrowser();
// Hidden streamer panel - opens with "\" key
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
// Don't trigger when typing in inputs
const activeElement = document.activeElement;
if (activeElement) {
const tagName = activeElement.tagName.toLowerCase();
if (
tagName === "input" ||
tagName === "textarea" ||
tagName === "select"
) {
return;
}
if (activeElement.getAttribute("contenteditable") === "true") {
return;
}
const role = activeElement.getAttribute("role");
if (role === "textbox" || role === "searchbox" || role === "combobox") {
return;
}
}
// Don't trigger with modifier keys
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
}
// Check for "\" key (backslash)
if (event.key === "\\") {
event.preventDefault();
setStreamerPanelOpen((prev) => !prev);
}
}, []);
// Register the "\" shortcut for streamer panel
useEffect(() => {
window.addEventListener("keydown", handleStreamerPanelShortcut);
return () => {
window.removeEventListener("keydown", handleStreamerPanelShortcut);
};
}, [handleStreamerPanelShortcut]);
// Compute the effective theme: previewTheme takes priority, then project theme, then global theme
// This is reactive because it depends on previewTheme, currentProject, and theme from the store
const effectiveTheme = getEffectiveTheme();
// Prevent hydration issues
useEffect(() => {
setIsMounted(true);
}, []);
// Initialize global file browser for HttpApiClient
useEffect(() => {
setGlobalFileBrowser(openFileBrowser);
}, [openFileBrowser]);
// Check if this is first run and redirect to setup if needed
useEffect(() => {
console.log("[Setup Flow] Checking setup state:", {
isMounted,
isFirstRun,
setupComplete,
currentView,
shouldShowSetup: isMounted && isFirstRun && !setupComplete,
});
if (isMounted && isFirstRun && !setupComplete) {
console.log(
"[Setup Flow] Redirecting to setup wizard (first run, not complete)"
);
setCurrentView("setup");
} else if (isMounted && setupComplete) {
console.log("[Setup Flow] Setup already complete, showing normal view");
}
}, [isMounted, isFirstRun, setupComplete, setCurrentView, currentView]);
// Test IPC connection on mount
useEffect(() => {
const testConnection = async () => {
try {
const api = getElectronAPI();
const result = await api.ping();
setIpcConnected(result === "pong");
} catch (error) {
console.error("IPC connection failed:", error);
setIpcConnected(false);
}
};
testConnection();
}, [setIpcConnected]);
// Apply theme class to document (uses effective theme - preview, project-specific, or global)
useEffect(() => {
const root = document.documentElement;
const themeClasses = [
"dark",
"light",
"retro",
"dracula",
"nord",
"monokai",
"tokyonight",
"solarized",
"gruvbox",
"catppuccin",
"onedark",
"synthwave",
"red",
"cream",
"sunset",
"gray",
"clean",
];
// Remove all theme classes
root.classList.remove(...themeClasses);
// Apply the effective theme
if (themeClasses.includes(effectiveTheme)) {
root.classList.add(effectiveTheme);
} else if (effectiveTheme === "system") {
// System theme - detect OS preference
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
root.classList.add(isDark ? "dark" : "light");
}
}, [effectiveTheme, previewTheme, currentProject, theme]);
const renderView = () => {
switch (currentView) {
case "welcome":
return <WelcomeView />;
case "setup":
return <SetupView />;
case "board":
return <BoardView />;
case "spec":
return <SpecView />;
case "agent":
return <AgentView />;
case "settings":
return <SettingsView />;
case "interview":
return <InterviewView />;
case "context":
return <ContextView />;
case "profiles":
return <ProfilesView />;
case "running-agents":
return <RunningAgentsView />;
case "terminal":
return <TerminalView />;
case "wiki":
return <WikiView />;
default:
return <WelcomeView />;
}
};
// Setup view is full-screen without sidebar
if (currentView === "setup") {
return (
<main className="h-screen overflow-hidden" data-testid="app-container">
<SetupView />
</main>
);
}
return (
<main className="flex h-screen overflow-hidden" data-testid="app-container">
<Sidebar />
<div
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
style={{ marginRight: streamerPanelOpen ? "250px" : "0" }}
>
{renderView()}
</div>
{/* Hidden streamer panel - opens with "\" key, pushes content */}
<div
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 ${
streamerPanelOpen ? "translate-x-0" : "translate-x-full"
}`}
/>
</main>
);
}
export default function Home() {
return (
<FileBrowserProvider>
<HomeContent />
</FileBrowserProvider>
);
}

View File

@@ -1,3 +1,4 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { ImageIcon, Upload, Loader2, Trash2 } from "lucide-react";
@@ -71,7 +72,7 @@ export function BoardBackgroundModal({
useEffect(() => {
if (currentProject && backgroundSettings.imagePath) {
const serverUrl =
import.meta.env.VITE_SERVER_URL || "http://localhost:3008";
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
// Add cache-busting query parameter to force browser to reload image
const cacheBuster = imageVersion
? `&v=${imageVersion}`

View File

@@ -1,3 +1,4 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import {
@@ -128,7 +129,7 @@ export function FileBrowserDialog({
try {
// Get server URL from environment or default
const serverUrl =
import.meta.env.VITE_SERVER_URL || "http://localhost:3008";
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const response = await fetch(`${serverUrl}/api/fs/browse`, {
method: "POST",

View File

@@ -1,4 +1,6 @@
import { Sparkles, Clock, Loader2 } from "lucide-react";
"use client";
import { Sparkles, Clock } from "lucide-react";
import {
Dialog,
DialogContent,
@@ -8,49 +10,66 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { FEATURE_COUNT_OPTIONS } from "../constants";
import type { CreateSpecDialogProps, FeatureCount } from "../types";
export function CreateSpecDialog({
// Feature count options
export type FeatureCount = 20 | 50 | 100;
const FEATURE_COUNT_OPTIONS: {
value: FeatureCount;
label: string;
warning?: string;
}[] = [
{ value: 20, label: "20" },
{ value: 50, label: "50", warning: "May take up to 5 minutes" },
{ value: 100, label: "100", warning: "May take up to 5 minutes" },
];
interface ProjectSetupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectOverview: string;
onProjectOverviewChange: (value: string) => void;
generateFeatures: boolean;
onGenerateFeaturesChange: (value: boolean) => void;
featureCount: FeatureCount;
onFeatureCountChange: (value: FeatureCount) => void;
onCreateSpec: () => void;
onSkip: () => void;
isCreatingSpec: boolean;
}
export function ProjectSetupDialog({
open,
onOpenChange,
projectOverview,
onProjectOverviewChange,
generateFeatures,
onGenerateFeaturesChange,
analyzeProject,
onAnalyzeProjectChange,
featureCount,
onFeatureCountChange,
onCreateSpec,
onSkip,
isCreatingSpec,
showSkipButton = false,
title = "Create App Specification",
description = "We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification.",
}: CreateSpecDialogProps) {
const selectedOption = FEATURE_COUNT_OPTIONS.find(
(o) => o.value === featureCount
);
}: ProjectSetupDialogProps) {
return (
<Dialog
open={open}
onOpenChange={(open) => {
onOpenChange(open);
if (!open && !isCreatingSpec && onSkip) {
if (!open && !isCreatingSpec) {
onSkip();
}
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogTitle>Set Up Your Project</DialogTitle>
<DialogDescription className="text-muted-foreground">
{description}
We didn&apos;t find an app_spec.txt file. Let us help you generate
your app_spec.txt to help describe your project for our system.
We&apos;ll analyze your project&apos;s tech stack and create a
comprehensive specification.
</DialogDescription>
</DialogHeader>
@@ -68,52 +87,21 @@ export function CreateSpecDialog({
onChange={(e) => onProjectOverviewChange(e.target.value)}
placeholder="e.g., A project management tool that allows teams to track tasks, manage sprints, and visualize progress through kanban boards. It should support user authentication, real-time updates, and file attachments..."
autoFocus
disabled={isCreatingSpec}
/>
</div>
<div className="flex items-start space-x-3 pt-2">
<Checkbox
id="create-analyze-project"
checked={analyzeProject}
onCheckedChange={(checked) =>
onAnalyzeProjectChange(checked === true)
}
disabled={isCreatingSpec}
/>
<div className="space-y-1">
<label
htmlFor="create-analyze-project"
className={`text-sm font-medium ${
isCreatingSpec ? "" : "cursor-pointer"
}`}
>
Analyze current project for additional context
</label>
<p className="text-xs text-muted-foreground">
If checked, the agent will research your existing codebase to
understand the tech stack. If unchecked, defaults to TanStack
Start, Drizzle ORM, PostgreSQL, shadcn/ui, Tailwind CSS, and
React.
</p>
</div>
</div>
<div className="flex items-start space-x-3 pt-2">
<Checkbox
id="create-generate-features"
id="sidebar-generate-features"
checked={generateFeatures}
onCheckedChange={(checked) =>
onGenerateFeaturesChange(checked === true)
}
disabled={isCreatingSpec}
/>
<div className="space-y-1">
<label
htmlFor="create-generate-features"
className={`text-sm font-medium ${
isCreatingSpec ? "" : "cursor-pointer"
}`}
htmlFor="sidebar-generate-features"
className="text-sm font-medium cursor-pointer"
>
Generate feature list
</label>
@@ -137,10 +125,7 @@ export function CreateSpecDialog({
featureCount === option.value ? "default" : "outline"
}
size="sm"
onClick={() =>
onFeatureCountChange(option.value as FeatureCount)
}
disabled={isCreatingSpec}
onClick={() => onFeatureCountChange(option.value)}
className={cn(
"flex-1 transition-all",
featureCount === option.value
@@ -153,10 +138,14 @@ export function CreateSpecDialog({
</Button>
))}
</div>
{selectedOption?.warning && (
{FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount)
?.warning && (
<p className="text-xs text-amber-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{selectedOption.warning}
{
FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount)
?.warning
}
</p>
)}
</div>
@@ -164,37 +153,13 @@ export function CreateSpecDialog({
</div>
<DialogFooter>
{showSkipButton && onSkip ? (
<Button variant="ghost" onClick={onSkip} disabled={isCreatingSpec}>
Skip for now
</Button>
) : (
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isCreatingSpec}
>
Cancel
</Button>
)}
<HotkeyButton
onClick={onCreateSpec}
disabled={!projectOverview.trim() || isCreatingSpec}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open && !isCreatingSpec}
>
{isCreatingSpec ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Generating...
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
Generate Spec
</>
)}
</HotkeyButton>
<Button variant="ghost" onClick={onSkip}>
Skip for now
</Button>
<Button onClick={onCreateSpec} disabled={!projectOverview.trim()}>
<Sparkles className="w-4 h-4 mr-2" />
Generate Spec
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -1,7 +1,9 @@
"use client";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { useNavigate, useLocation } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
import { useAppStore, formatShortcut, type ThemeMode } from "@/store/app-store";
import { CoursePromoBadge } from "@/components/ui/course-promo-badge";
import {
FolderOpen,
Plus,
@@ -80,8 +82,10 @@ import { themeOptions } from "@/config/theme-options";
import type { SpecRegenerationEvent } from "@/types/electron";
import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog";
import { NewProjectModal } from "@/components/new-project-modal";
import { CreateSpecDialog } from "@/components/views/spec-view/dialogs";
import type { FeatureCount } from "@/components/views/spec-view/types";
import {
ProjectSetupDialog,
type FeatureCount,
} from "@/components/layout/project-setup-dialog";
import {
DndContext,
DragEndEvent,
@@ -219,17 +223,16 @@ const BugReportButton = ({
};
export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const {
projects,
trashedProjects,
currentProject,
currentView,
sidebarOpen,
projectHistory,
upsertAndSetCurrentProject,
setCurrentProject,
setCurrentView,
toggleSidebar,
restoreTrashedProject,
deleteTrashedProject,
@@ -248,13 +251,14 @@ export function Sidebar() {
} = useAppStore();
// Environment variable flags for hiding sidebar items
const hideTerminal = import.meta.env.VITE_HIDE_TERMINAL === "true";
const hideWiki = import.meta.env.VITE_HIDE_WIKI === "true";
// Note: Next.js requires static access to process.env variables (no dynamic keys)
const hideTerminal = process.env.NEXT_PUBLIC_HIDE_TERMINAL === "true";
const hideWiki = process.env.NEXT_PUBLIC_HIDE_WIKI === "true";
const hideRunningAgents =
import.meta.env.VITE_HIDE_RUNNING_AGENTS === "true";
const hideContext = import.meta.env.VITE_HIDE_CONTEXT === "true";
const hideSpecEditor = import.meta.env.VITE_HIDE_SPEC_EDITOR === "true";
const hideAiProfiles = import.meta.env.VITE_HIDE_AI_PROFILES === "true";
process.env.NEXT_PUBLIC_HIDE_RUNNING_AGENTS === "true";
const hideContext = process.env.NEXT_PUBLIC_HIDE_CONTEXT === "true";
const hideSpecEditor = process.env.NEXT_PUBLIC_HIDE_SPEC_EDITOR === "true";
const hideAiProfiles = process.env.NEXT_PUBLIC_HIDE_AI_PROFILES === "true";
// Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
@@ -287,7 +291,6 @@ export function Sidebar() {
const [setupProjectPath, setSetupProjectPath] = useState("");
const [projectOverview, setProjectOverview] = useState("");
const [generateFeatures, setGenerateFeatures] = useState(true);
const [analyzeProject, setAnalyzeProject] = useState(true);
const [featureCount, setFeatureCount] = useState<FeatureCount>(50);
const [showSpecIndicator, setShowSpecIndicator] = useState(true);
@@ -426,6 +429,7 @@ export function Sidebar() {
unsubscribe();
};
}, [
setCurrentView,
creatingSpecProjectPath,
setupProjectPath,
setSpecCreatingForProject,
@@ -494,7 +498,7 @@ export function Sidebar() {
setupProjectPath,
projectOverview.trim(),
generateFeatures,
analyzeProject,
undefined, // analyzeProject - use default
generateFeatures ? featureCount : undefined // only pass maxFeatures if generating features
);
@@ -523,7 +527,6 @@ export function Sidebar() {
setupProjectPath,
projectOverview,
generateFeatures,
analyzeProject,
featureCount,
setSpecCreatingForProject,
]);
@@ -1174,7 +1177,7 @@ export function Sidebar() {
if (item.shortcut) {
shortcutsList.push({
key: item.shortcut,
action: () => navigate({ to: `/${item.id}` as const }),
action: () => setCurrentView(item.id as any),
description: `Navigate to ${item.label}`,
});
}
@@ -1184,7 +1187,7 @@ export function Sidebar() {
// Add settings shortcut
shortcutsList.push({
key: shortcuts.settings,
action: () => navigate({ to: "/settings" }),
action: () => setCurrentView("settings"),
description: "Navigate to Settings",
});
}
@@ -1193,7 +1196,7 @@ export function Sidebar() {
}, [
shortcuts,
currentProject,
navigate,
setCurrentView,
toggleSidebar,
projects.length,
handleOpenFolder,
@@ -1207,15 +1210,15 @@ export function Sidebar() {
useKeyboardShortcuts(navigationShortcuts);
const isActiveRoute = (id: string) => {
// Map view IDs to route paths
const routePath = id === "welcome" ? "/" : `/${id}`;
return location.pathname === routePath;
return currentView === id;
};
return (
<aside
className={cn(
"flex-shrink-0 flex flex-col z-30 relative",
// Clean theme sidebar-glass class
"sidebar-glass",
// Glass morphism background with gradient
"bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl",
// Premium border with subtle glow
@@ -1288,7 +1291,7 @@ export function Sidebar() {
"flex items-center gap-3 titlebar-no-drag cursor-pointer group",
!sidebarOpen && "flex-col gap-1"
)}
onClick={() => navigate({ to: "/" })}
onClick={() => setCurrentView("welcome")}
data-testid="logo-button"
>
{!sidebarOpen ? (
@@ -1846,13 +1849,15 @@ export function Sidebar() {
return (
<button
key={item.id}
onClick={() => navigate({ to: `/${item.id}` as const })}
onClick={() => setCurrentView(item.id as any)}
className={cn(
"group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag",
"transition-all duration-200 ease-out",
isActive
? [
// 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",
"text-foreground font-medium",
"border border-brand-500/30",
@@ -1871,6 +1876,9 @@ export function Sidebar() {
title={!sidebarOpen ? item.label : undefined}
data-testid={`nav-${item.id}`}
>
{isActive && (
<div className="absolute inset-y-0 left-0 w-1 bg-gradient-to-b from-brand-400 via-brand-500 to-brand-600 rounded-r-full shadow-sm shadow-brand-500/50"></div>
)}
<Icon
className={cn(
"w-[18px] h-[18px] shrink-0 transition-all duration-200",
@@ -1890,6 +1898,8 @@ export function Sidebar() {
{item.shortcut && sidebarOpen && (
<span
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",
isActive
? "bg-brand-500/20 text-brand-400"
@@ -1915,7 +1925,7 @@ export function Sidebar() {
>
{item.label}
{item.shortcut && (
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
<span className="shortcut-badge ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
{formatShortcut(item.shortcut, true)}
</span>
)}
@@ -1941,11 +1951,13 @@ export function Sidebar() {
"bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent"
)}
>
{/* Course Promo Badge */}
<CoursePromoBadge sidebarOpen={sidebarOpen} />
{/* Wiki Link */}
{!hideWiki && (
<div className="p-2 pb-0">
<button
onClick={() => navigate({ to: "/wiki" })}
onClick={() => setCurrentView("wiki")}
className={cn(
"group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag",
"transition-all duration-200 ease-out",
@@ -1968,6 +1980,9 @@ export function Sidebar() {
title={!sidebarOpen ? "Wiki" : undefined}
data-testid="wiki-link"
>
{isActiveRoute("wiki") && (
<div className="absolute inset-y-0 left-0 w-1 bg-gradient-to-b from-brand-400 via-brand-500 to-brand-600 rounded-r-full shadow-sm shadow-brand-500/50"></div>
)}
<BookOpen
className={cn(
"w-[18px] h-[18px] shrink-0 transition-all duration-200",
@@ -2005,7 +2020,7 @@ export function Sidebar() {
{!hideRunningAgents && (
<div className="p-2 pb-0">
<button
onClick={() => navigate({ to: "/running-agents" })}
onClick={() => setCurrentView("running-agents")}
className={cn(
"group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag",
"transition-all duration-200 ease-out",
@@ -2028,6 +2043,9 @@ export function Sidebar() {
title={!sidebarOpen ? "Running Agents" : undefined}
data-testid="running-agents-link"
>
{isActiveRoute("running-agents") && (
<div className="absolute inset-y-0 left-0 w-1 bg-gradient-to-b from-brand-400 via-brand-500 to-brand-600 rounded-r-full shadow-sm shadow-brand-500/50"></div>
)}
<div className="relative">
<Activity
className={cn(
@@ -2041,6 +2059,8 @@ export function Sidebar() {
{!sidebarOpen && runningAgentsCount > 0 && (
<span
className={cn(
// Clean theme running-agents-badge class
"running-agents-badge",
"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",
"bg-brand-500 text-white shadow-sm",
@@ -2064,6 +2084,8 @@ export function Sidebar() {
{sidebarOpen && runningAgentsCount > 0 && (
<span
className={cn(
// Clean theme running-agents-badge class
"running-agents-badge",
"hidden lg:flex items-center justify-center",
"min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full",
"bg-brand-500 text-white shadow-sm",
@@ -2100,7 +2122,7 @@ export function Sidebar() {
{/* Settings Link */}
<div className="p-2">
<button
onClick={() => navigate({ to: "/settings" })}
onClick={() => setCurrentView("settings")}
className={cn(
"group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag",
"transition-all duration-200 ease-out",
@@ -2123,6 +2145,9 @@ export function Sidebar() {
title={!sidebarOpen ? "Settings" : undefined}
data-testid="settings-button"
>
{isActiveRoute("settings") && (
<div className="absolute inset-y-0 left-0 w-1 bg-gradient-to-b from-brand-400 via-brand-500 to-brand-600 rounded-r-full shadow-sm shadow-brand-500/50"></div>
)}
<Settings
className={cn(
"w-[18px] h-[18px] shrink-0 transition-all duration-200",
@@ -2261,23 +2286,18 @@ export function Sidebar() {
</Dialog>
{/* New Project Setup Dialog */}
<CreateSpecDialog
<ProjectSetupDialog
open={showSetupDialog}
onOpenChange={setShowSetupDialog}
projectOverview={projectOverview}
onProjectOverviewChange={setProjectOverview}
generateFeatures={generateFeatures}
onGenerateFeaturesChange={setGenerateFeatures}
analyzeProject={analyzeProject}
onAnalyzeProjectChange={setAnalyzeProject}
featureCount={featureCount}
onFeatureCountChange={setFeatureCount}
onCreateSpec={handleCreateInitialSpec}
onSkip={handleSkipSetup}
isCreatingSpec={isCreatingSpec}
showSkipButton={true}
title="Set Up Your Project"
description="We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification."
/>
{/* New Project Onboarding Dialog */}

View File

@@ -1,3 +1,4 @@
"use client";
import { useState, useEffect } from "react";
import {

View File

@@ -1,6 +1,12 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
@@ -110,10 +116,8 @@ export function SessionManager({
new Set()
);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [sessionToDelete, setSessionToDelete] =
useState<SessionListItem | null>(null);
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] =
useState(false);
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false);
// Check running state for all sessions
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
@@ -230,7 +234,11 @@ export function SessionManager({
const api = getElectronAPI();
if (!editingName.trim() || !api?.sessions) return;
const result = await api.sessions.update(sessionId, editingName, undefined);
const result = await api.sessions.update(
sessionId,
editingName,
undefined
);
if (result.success) {
setEditingSessionId(null);

View File

@@ -1,3 +1,4 @@
"use client";
import * as React from "react";
import { ChevronDown } from "lucide-react";

View File

@@ -1,3 +1,4 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, LucideIcon } from "lucide-react";
@@ -34,7 +35,6 @@ interface AutocompleteProps {
emptyMessage?: string;
className?: string;
disabled?: boolean;
error?: boolean;
icon?: LucideIcon;
allowCreate?: boolean;
createLabel?: (value: string) => string;
@@ -58,7 +58,6 @@ export function Autocomplete({
emptyMessage = "No results found.",
className,
disabled = false,
error = false,
icon: Icon,
allowCreate = false,
createLabel = (v) => `Create "${v}"`,
@@ -131,7 +130,6 @@ export function Autocomplete({
className={cn(
"w-full justify-between",
Icon && "font-mono text-sm",
error && "border-destructive focus-visible:ring-destructive",
className
)}
data-testid={testId}

View File

@@ -1,3 +1,4 @@
"use client";
import * as React from "react";
import { GitBranch } from "lucide-react";
@@ -7,11 +8,9 @@ interface BranchAutocompleteProps {
value: string;
onChange: (value: string) => void;
branches: string[];
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
placeholder?: string;
className?: string;
disabled?: boolean;
error?: boolean;
"data-testid"?: string;
}
@@ -19,32 +18,20 @@ export function BranchAutocomplete({
value,
onChange,
branches,
branchCardCounts,
placeholder = "Select a branch...",
className,
disabled = false,
error = false,
"data-testid": testId,
}: BranchAutocompleteProps) {
// Always include "main" at the top of suggestions
const branchOptions: AutocompleteOption[] = React.useMemo(() => {
const branchSet = new Set(["main", ...branches]);
return Array.from(branchSet).map((branch) => {
const cardCount = branchCardCounts?.[branch];
// Show card count if available, otherwise show "default" for main branch only
const badge = branchCardCounts !== undefined
? String(cardCount ?? 0)
: branch === "main"
? "default"
: undefined;
return {
value: branch,
label: branch,
badge,
};
});
}, [branches, branchCardCounts]);
return Array.from(branchSet).map((branch) => ({
value: branch,
label: branch,
badge: branch === "main" ? "default" : undefined,
}));
}, [branches]);
return (
<Autocomplete
@@ -56,7 +43,6 @@ export function BranchAutocomplete({
emptyMessage="No branches found."
className={className}
disabled={disabled}
error={error}
icon={GitBranch}
allowCreate
createLabel={(v) => `Create "${v}"`}

View File

@@ -1,3 +1,4 @@
"use client";
import * as React from "react";
import { Autocomplete } from "@/components/ui/autocomplete";

View File

@@ -1,3 +1,4 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";

View File

@@ -1,3 +1,4 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"

View File

@@ -1,3 +1,4 @@
"use client";
import { useState, useEffect } from "react";
import { Clock } from "lucide-react";

View File

@@ -0,0 +1,88 @@
"use client";
import * as React from "react";
import { Sparkles, X } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface CoursePromoBadgeProps {
sidebarOpen?: boolean;
}
export function CoursePromoBadge({ sidebarOpen = true }: CoursePromoBadgeProps) {
const [dismissed, setDismissed] = React.useState(false);
if (dismissed) {
return null;
}
// Collapsed state - show only icon with tooltip
if (!sidebarOpen) {
return (
<div className="p-2 pb-0 flex justify-center">
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://agenticjumpstart.com"
target="_blank"
rel="noopener noreferrer"
className="group cursor-pointer flex items-center justify-center w-10 h-10 bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-all border border-primary/30"
data-testid="course-promo-badge-collapsed"
>
<Sparkles className="size-4 shrink-0" />
</a>
</TooltipTrigger>
<TooltipContent side="right" className="flex items-center gap-2">
<span>Become a 10x Dev</span>
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDismissed(true);
}}
className="p-0.5 rounded-full hover:bg-primary/30 transition-colors cursor-pointer"
aria-label="Dismiss"
>
<X className="size-3" />
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
// Expanded state - show full badge
return (
<div className="p-2 pb-0">
<a
href="https://agenticjumpstart.com"
target="_blank"
rel="noopener noreferrer"
className="group cursor-pointer flex items-center justify-between w-full px-2 lg:px-3 py-2.5 bg-primary/10 text-primary rounded-lg font-medium text-sm hover:bg-primary/20 transition-all border border-primary/30"
data-testid="course-promo-badge"
>
<div className="flex items-center gap-2">
<Sparkles className="size-4 shrink-0" />
<span className="hidden lg:block">Become a 10x Dev</span>
</div>
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDismissed(true);
}}
className="hidden lg:block p-1 rounded-full hover:bg-primary/30 transition-colors cursor-pointer"
aria-label="Dismiss"
>
<X className="size-3.5" />
</span>
</a>
</div>
);
}

View File

@@ -1,3 +1,4 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
@@ -84,7 +85,7 @@ export function DescriptionImageDropZone({
// Construct server URL for loading saved images
const getImageServerUrl = useCallback((imagePath: string): string => {
const serverUrl = import.meta.env.VITE_SERVER_URL || "http://localhost:3008";
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const projectPath = currentProject?.path || "";
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
}, [currentProject?.path]);

View File

@@ -1,3 +1,4 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
@@ -86,18 +87,16 @@ function DialogOverlay({
);
}
export type DialogContentProps = Omit<
React.ComponentProps<typeof DialogPrimitive.Content>,
"ref"
> & {
function DialogContent({
className,
children,
showCloseButton = true,
compact = false,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
compact?: boolean;
};
const DialogContent = React.forwardRef<
HTMLDivElement,
DialogContentProps
>(({ className, children, showCloseButton = true, compact = false, ...props }, ref) => {
}) {
// Check if className contains a custom max-width
const hasCustomMaxWidth =
typeof className === "string" && className.includes("max-w-");
@@ -106,7 +105,6 @@ const DialogContent = React.forwardRef<
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogContentPrimitive
ref={ref}
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
@@ -150,9 +148,7 @@ const DialogContent = React.forwardRef<
</DialogContentPrimitive>
</DialogPortal>
);
});
DialogContent.displayName = "DialogContent";
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (

View File

@@ -1,3 +1,4 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"

View File

@@ -1,3 +1,4 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,3 +1,4 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";

View File

@@ -1,3 +1,4 @@
"use client";
import * as React from "react";
import { useEffect, useCallback, useRef } from "react";

View File

@@ -1,3 +1,4 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,3 +1,4 @@
"use client";
import * as React from "react";
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, parseShortcut, formatShortcut } from "@/store/app-store";

View File

@@ -1,3 +1,4 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"

View File

@@ -1,3 +1,4 @@
"use client";
import { useState, useMemo, useEffect, useRef } from "react";
import {
@@ -24,7 +25,6 @@ import {
Circle,
Play,
Loader2,
Coins,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
@@ -35,35 +35,10 @@ import {
type LogEntryType,
type ToolCategory,
} from "@/lib/log-parser";
import type { TokenUsage } from "@/store/app-store";
interface LogViewerProps {
output: string;
className?: string;
tokenUsage?: TokenUsage;
}
/**
* Formats token counts for compact display (e.g., 12500 -> "12.5K")
*/
function formatTokenCount(count: number): string {
if (count >= 1000000) {
return `${(count / 1000000).toFixed(1)}M`;
}
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}K`;
}
return count.toString();
}
/**
* Formats cost for display (e.g., 0.0847 -> "$0.0847")
*/
function formatCost(cost: number): string {
if (cost < 0.01) {
return `$${cost.toFixed(4)}`;
}
return `$${cost.toFixed(2)}`;
}
const getLogIcon = (type: LogEntryType) => {
@@ -351,7 +326,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
return (
<div
className={cn(
"rounded-lg border transition-all duration-200",
"rounded-lg border-l-4 transition-all duration-200",
bgColor,
borderColor,
"hover:brightness-110"
@@ -405,7 +380,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
{formattedContent.map((part, index) => (
<div key={index}>
{part.type === "json" ? (
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto scrollbar-styled text-xs text-primary">
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto text-xs text-primary">
{part.content}
</pre>
) : (
@@ -439,13 +414,11 @@ interface ToolCategoryStats {
other: number;
}
export function LogViewer({ output, className, tokenUsage }: LogViewerProps) {
export function LogViewer({ output, className }: LogViewerProps) {
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());
// Track if user has "Expand All" mode active - new entries will auto-expand when this is true
const [expandAllMode, setExpandAllMode] = useState(false);
// Parse entries and compute initial expanded state together
const { entries, initialExpandedIds } = useMemo(() => {
@@ -470,27 +443,16 @@ export function LogViewer({ output, className, tokenUsage }: LogViewerProps) {
const appliedInitialRef = useRef<Set<string>>(new Set());
// Apply initial expanded state for new entries
// Also auto-expand all entries when expandAllMode is active
const effectiveExpandedIds = useMemo(() => {
const result = new Set(expandedIds);
// If expand all mode is active, expand all filtered entries
if (expandAllMode) {
entries.forEach((entry) => {
result.add(entry.id);
});
} else {
// Otherwise, only auto-expand entries based on initial state (shouldCollapseByDefault)
initialExpandedIds.forEach((id) => {
if (!appliedInitialRef.current.has(id)) {
appliedInitialRef.current.add(id);
result.add(id);
}
});
}
initialExpandedIds.forEach((id) => {
if (!appliedInitialRef.current.has(id)) {
appliedInitialRef.current.add(id);
result.add(id);
}
});
return result;
}, [expandedIds, initialExpandedIds, expandAllMode, entries]);
}, [expandedIds, initialExpandedIds]);
// Calculate stats for tool categories
const stats = useMemo(() => {
@@ -546,10 +508,6 @@ export function LogViewer({ output, className, tokenUsage }: LogViewerProps) {
}, [entries, hiddenTypes, hiddenCategories, searchQuery]);
const toggleEntry = (id: string) => {
// When user manually collapses an entry, turn off expand all mode
if (effectiveExpandedIds.has(id)) {
setExpandAllMode(false);
}
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
@@ -562,14 +520,10 @@ export function LogViewer({ output, className, tokenUsage }: LogViewerProps) {
};
const expandAll = () => {
// Enable expand all mode so new entries will also be expanded
setExpandAllMode(true);
setExpandedIds(new Set(filteredEntries.map((e) => e.id)));
};
const collapseAll = () => {
// Disable expand all mode when collapsing all
setExpandAllMode(false);
setExpandedIds(new Set());
};
@@ -612,7 +566,7 @@ export function LogViewer({ output, className, tokenUsage }: LogViewerProps) {
<Info className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No log entries yet. Logs will appear here as the process runs.</p>
{output && output.trim() && (
<div className="mt-4 p-3 bg-zinc-900/50 rounded text-xs font-mono text-left max-h-40 overflow-auto scrollbar-styled">
<div className="mt-4 p-3 bg-zinc-900/50 rounded text-xs font-mono text-left max-h-40 overflow-auto">
<pre className="whitespace-pre-wrap">{output}</pre>
</div>
)}
@@ -641,40 +595,6 @@ export function LogViewer({ output, className, tokenUsage }: LogViewerProps) {
return (
<div className={cn("flex flex-col", className)}>
{/* Token Usage Summary Header */}
{tokenUsage && tokenUsage.totalTokens > 0 && (
<div className="mb-3 p-2 bg-zinc-900/50 rounded-lg border border-zinc-700/50">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<Coins className="w-3.5 h-3.5 text-amber-400" />
<span className="font-medium">{formatTokenCount(tokenUsage.totalTokens)}</span>
<span className="text-muted-foreground/60">tokens</span>
</span>
<span className="text-muted-foreground/30">|</span>
<span className="flex items-center gap-1">
<span className="text-green-400">IN:</span>
<span>{formatTokenCount(tokenUsage.inputTokens)}</span>
</span>
<span className="flex items-center gap-1">
<span className="text-blue-400">OUT:</span>
<span>{formatTokenCount(tokenUsage.outputTokens)}</span>
</span>
{tokenUsage.cacheReadInputTokens > 0 && (
<>
<span className="text-muted-foreground/30">|</span>
<span className="flex items-center gap-1">
<span className="text-purple-400">Cache:</span>
<span>{formatTokenCount(tokenUsage.cacheReadInputTokens)}</span>
</span>
</>
)}
<span className="text-muted-foreground/30">|</span>
<span className="flex items-center gap-1 text-amber-400 font-medium">
{formatCost(tokenUsage.costUSD)}
</span>
</div>
</div>
)}
{/* Sticky header with search, stats, and filters */}
{/* Use -top-4 to compensate for parent's p-4 padding, pt-4 to restore visual spacing */}
<div className="sticky -top-4 z-10 bg-zinc-950/95 backdrop-blur-sm pt-4 pb-2 space-y-2 -mx-4 px-4">
@@ -780,16 +700,10 @@ export function LogViewer({ output, className, tokenUsage }: LogViewerProps) {
</span>
<button
onClick={expandAll}
className={cn(
"text-xs px-2 py-1 rounded transition-colors",
expandAllMode
? "text-primary bg-primary/20 hover:bg-primary/30"
: "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50"
)}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
data-testid="log-expand-all"
title={expandAllMode ? "Expand All (Active - new items will auto-expand)" : "Expand All"}
>
Expand All{expandAllMode ? " (On)" : ""}
Expand All
</button>
<button
onClick={collapseAll}

View File

@@ -1,3 +1,4 @@
"use client";
import ReactMarkdown from "react-markdown";
import { cn } from "@/lib/utils";

View File

@@ -1,3 +1,4 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"

View File

@@ -1,3 +1,4 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";

View File

@@ -1,3 +1,4 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";

View File

@@ -1,3 +1,4 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"

View File

@@ -1,3 +1,4 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"

View File

@@ -1,3 +1,4 @@
"use client";
import CodeMirror from "@uiw/react-codemirror";
import { xml } from "@codemirror/lang-xml";

View File

@@ -1,3 +1,4 @@
"use client";
import { useState, useCallback } from "react";
import { useAppStore } from "@/store/app-store";

View File

@@ -1,3 +1,4 @@
"use client";
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useAppStore, type AgentModel } from "@/store/app-store";
@@ -755,8 +756,8 @@ export function AgentView() {
/>
)}
{/* Selected Images Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
{selectedImages.length > 0 && !showImageDropZone && (
{/* Selected Images Preview */}
{selectedImages.length > 0 && (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">

View File

@@ -1,3 +1,4 @@
"use client";
import { useCallback, useState } from "react";
import {

View File

@@ -1,5 +1,6 @@
"use client";
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { useEffect, useState, useCallback, useMemo } from "react";
import {
PointerSensor,
useSensor,
@@ -9,9 +10,7 @@ import {
} from "@dnd-kit/core";
import { useAppStore, Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import type { AutoModeEvent } from "@/types/electron";
import { pathsEqual } from "@/lib/utils";
import { getBlockingDependencies } from "@/lib/dependency-resolver";
import { pathsEqual, cn } from "@/lib/utils";
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
import { RefreshCw } from "lucide-react";
import { useAutoMode } from "@/hooks/use-auto-mode";
@@ -26,7 +25,7 @@ import {
AddFeatureDialog,
AgentOutputModal,
CompletedFeaturesModal,
ArchiveAllVerifiedDialog,
DeleteAllVerifiedDialog,
DeleteCompletedFeatureDialog,
EditFeatureDialog,
FeatureSuggestionsDialog,
@@ -77,10 +76,7 @@ export function BoardView() {
setCurrentWorktree,
getWorktrees,
setWorktrees,
useWorktrees,
enableDependencyBlocking,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
getEffectiveTheme,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const {
@@ -98,7 +94,7 @@ export function BoardView() {
const [featuresWithContext, setFeaturesWithContext] = useState<Set<string>>(
new Set()
);
const [showArchiveAllVerifiedDialog, setShowArchiveAllVerifiedDialog] =
const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] =
useState(false);
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
useState(false);
@@ -269,17 +265,6 @@ export function BoardView() {
fetchBranches();
}, [currentProject, worktreeRefreshKey]);
// Calculate unarchived card counts per branch
const branchCardCounts = useMemo(() => {
return hookFeatures.reduce((counts, feature) => {
if (feature.status !== "completed") {
const branch = feature.branchName ?? "main";
counts[branch] = (counts[branch] || 0) + 1;
}
return counts;
}, {} as Record<string, number>);
}, [hookFeatures]);
// Custom collision detection that prioritizes columns over cards
const collisionDetectionStrategy = useCallback((args: any) => {
// First, check if pointer is within a column
@@ -301,27 +286,6 @@ export function BoardView() {
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } =
useBoardPersistence({ currentProject });
// Memoize the removed worktrees handler to prevent infinite loops
const handleRemovedWorktrees = useCallback(
(removedWorktrees: Array<{ path: string; branch: string }>) => {
// Reset features that were assigned to the removed worktrees (by branch)
hookFeatures.forEach((feature) => {
const matchesRemovedWorktree = removedWorktrees.some((removed) => {
// Match by branch name since worktreePath is no longer stored
return feature.branchName === removed.branch;
});
if (matchesRemovedWorktree) {
// Reset the feature's branch assignment - update both local state and persist
const updates = { branchName: null as unknown as string | undefined };
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
}
});
},
[hookFeatures, updateFeature, persistFeatureUpdate]
);
// Get in-progress features for keyboard shortcuts (needed before actions hook)
const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => {
@@ -330,12 +294,13 @@ export function BoardView() {
});
}, [hookFeatures, runningAutoTasks]);
// Get current worktree info (path) for filtering features
// Get current worktree info (path and branch) for filtering features
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject
? getCurrentWorktree(currentProject.path)
: null;
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null;
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
const worktrees = useMemo(
() =>
@@ -345,25 +310,8 @@ export function BoardView() {
[currentProject, worktreesByProject]
);
// Get the branch for the currently selected worktree
// Find the worktree that matches the current selection, or use main worktree
const selectedWorktree = useMemo(() => {
if (currentWorktreePath === null) {
// Primary worktree selected - find the main worktree
return worktrees.find((w) => w.isMain);
} else {
// Specific worktree selected - find it by path
return worktrees.find(
(w) => !w.isMain && pathsEqual(w.path, currentWorktreePath)
);
}
}, [worktrees, currentWorktreePath]);
// Get the current branch from the selected worktree (not from store which may be stale)
const currentWorktreeBranch = selectedWorktree?.branch ?? null;
// Get the branch for the currently selected worktree (for defaulting new features)
// Use the branch from selectedWorktree, or fall back to main worktree's branch
// Use the branch from currentWorktreeInfo, or fall back to main worktree's branch
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main";
@@ -387,7 +335,7 @@ export function BoardView() {
handleOutputModalNumberKeyPress,
handleForceStopFeature,
handleStartNextFeatures,
handleArchiveAllVerified,
handleDeleteAllVerified,
} = useBoardActions({
currentProject,
features: hookFeatures,
@@ -415,222 +363,6 @@ export function BoardView() {
currentWorktreeBranch,
});
// Client-side auto mode: periodically check for backlog items and move them to in-progress
// Use a ref to track the latest auto mode state so async operations always check the current value
const autoModeRunningRef = useRef(autoMode.isRunning);
useEffect(() => {
autoModeRunningRef.current = autoMode.isRunning;
}, [autoMode.isRunning]);
// Use a ref to track the latest features to avoid effect re-runs when features change
const hookFeaturesRef = useRef(hookFeatures);
useEffect(() => {
hookFeaturesRef.current = hookFeatures;
}, [hookFeatures]);
// Use a ref to track running tasks to avoid effect re-runs that clear pendingFeaturesRef
const runningAutoTasksRef = useRef(runningAutoTasks);
useEffect(() => {
runningAutoTasksRef.current = runningAutoTasks;
}, [runningAutoTasks]);
// Keep latest start handler without retriggering the auto mode effect
const handleStartImplementationRef = useRef(handleStartImplementation);
useEffect(() => {
handleStartImplementationRef.current = handleStartImplementation;
}, [handleStartImplementation]);
// Track features that are pending (started but not yet confirmed running)
const pendingFeaturesRef = useRef<Set<string>>(new Set());
// Listen to auto mode events to remove features from pending when they start running
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
if (!currentProject) return;
// Only process events for the current project
const eventProjectPath =
"projectPath" in event ? event.projectPath : undefined;
if (eventProjectPath && eventProjectPath !== currentProject.path) {
return;
}
switch (event.type) {
case "auto_mode_feature_start":
// Feature is now confirmed running - remove from pending
if (event.featureId) {
pendingFeaturesRef.current.delete(event.featureId);
}
break;
case "auto_mode_feature_complete":
case "auto_mode_error":
// Feature completed or errored - remove from pending if still there
if (event.featureId) {
pendingFeaturesRef.current.delete(event.featureId);
}
break;
}
});
return unsubscribe;
}, [currentProject]);
useEffect(() => {
if (!autoMode.isRunning || !currentProject) {
return;
}
let isChecking = false;
let isActive = true; // Track if this effect is still active
const checkAndStartFeatures = async () => {
// Check if auto mode is still running and effect is still active
// Use ref to get the latest value, not the closure value
if (!isActive || !autoModeRunningRef.current || !currentProject) {
return;
}
// Prevent concurrent executions
if (isChecking) {
return;
}
isChecking = true;
try {
// Double-check auto mode is still running before proceeding
if (!isActive || !autoModeRunningRef.current || !currentProject) {
return;
}
// Count currently running tasks + pending features
// Use ref to get the latest running tasks without causing effect re-runs
const currentRunning =
runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
const availableSlots = maxConcurrency - currentRunning;
// No available slots, skip check
if (availableSlots <= 0) {
return;
}
// Filter backlog features by the currently selected worktree branch
// This logic mirrors use-board-column-features.ts for consistency
// Use ref to get the latest features without causing effect re-runs
const currentFeatures = hookFeaturesRef.current;
const backlogFeatures = currentFeatures.filter((f) => {
if (f.status !== "backlog") return false;
const featureBranch = f.branchName;
// Features without branchName are considered unassigned (show only on primary worktree)
if (!featureBranch) {
// No branch assigned - show only when viewing primary worktree
const isViewingPrimary = currentWorktreePath === null;
return isViewingPrimary;
}
if (currentWorktreeBranch === null) {
// We're viewing main but branch hasn't been initialized yet
// Show features assigned to primary worktree's branch
return currentProject.path
? isPrimaryWorktreeBranch(currentProject.path, featureBranch)
: false;
}
// Match by branch name
return featureBranch === currentWorktreeBranch;
});
if (backlogFeatures.length === 0) {
return;
}
// Sort by priority (lower number = higher priority, priority 1 is highest)
const sortedBacklog = [...backlogFeatures].sort(
(a, b) => (a.priority || 999) - (b.priority || 999)
);
// Filter out features with blocking dependencies if dependency blocking is enabled
const eligibleFeatures = enableDependencyBlocking
? sortedBacklog.filter((f) => {
const blockingDeps = getBlockingDependencies(f, currentFeatures);
return blockingDeps.length === 0;
})
: sortedBacklog;
// Start features up to available slots
const featuresToStart = eligibleFeatures.slice(0, availableSlots);
const startImplementation = handleStartImplementationRef.current;
if (!startImplementation) {
return;
}
for (const feature of featuresToStart) {
// Check again before starting each feature
if (!isActive || !autoModeRunningRef.current || !currentProject) {
return;
}
// Simplified: No worktree creation on client - server derives workDir from feature.branchName
// If feature has no branchName and primary worktree is selected, assign primary branch
if (currentWorktreePath === null && !feature.branchName) {
const primaryBranch =
(currentProject.path
? getPrimaryWorktreeBranch(currentProject.path)
: null) || "main";
await persistFeatureUpdate(feature.id, {
branchName: primaryBranch,
});
}
// Final check before starting implementation
if (!isActive || !autoModeRunningRef.current || !currentProject) {
return;
}
// Start the implementation - server will derive workDir from feature.branchName
const started = await startImplementation(feature);
// If successfully started, track it as pending until we receive the start event
if (started) {
pendingFeaturesRef.current.add(feature.id);
}
}
} finally {
isChecking = false;
}
};
// Check immediately, then every 3 seconds
checkAndStartFeatures();
const interval = setInterval(checkAndStartFeatures, 1000);
return () => {
// Mark as inactive to prevent any pending async operations from continuing
isActive = false;
clearInterval(interval);
// Clear pending features when effect unmounts or dependencies change
pendingFeaturesRef.current.clear();
};
}, [
autoMode.isRunning,
currentProject,
// runningAutoTasks is accessed via runningAutoTasksRef to prevent effect re-runs
// that would clear pendingFeaturesRef and cause concurrency issues
maxConcurrency,
// hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs
currentWorktreeBranch,
currentWorktreePath,
getPrimaryWorktreeBranch,
isPrimaryWorktreeBranch,
enableDependencyBlocking,
persistFeatureUpdate,
]);
// Use keyboard shortcuts hook (after actions hook)
useBoardKeyboardShortcuts({
features: hookFeatures,
@@ -647,6 +379,8 @@ export function BoardView() {
runningAutoTasks,
persistFeatureUpdate,
handleStartImplementation,
projectPath: currentProject?.path || null,
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
});
// Use column features hook
@@ -667,9 +401,7 @@ export function BoardView() {
// Find feature for pending plan approval
const pendingApprovalFeature = useMemo(() => {
if (!pendingPlanApproval) return null;
return (
hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null
);
return hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null;
}, [pendingPlanApproval, hookFeatures]);
// Handle plan approval
@@ -695,10 +427,10 @@ export function BoardView() {
if (result.success) {
// Immediately update local feature state to hide "Approve Plan" button
// Get current feature to preserve version
const currentFeature = hookFeatures.find((f) => f.id === featureId);
const currentFeature = hookFeatures.find(f => f.id === featureId);
updateFeature(featureId, {
planSpec: {
status: "approved",
status: 'approved',
content: editedPlan || pendingPlanApproval.planContent,
version: currentFeature?.planSpec?.version || 1,
approvedAt: new Date().toISOString(),
@@ -717,14 +449,7 @@ export function BoardView() {
setPendingPlanApproval(null);
}
},
[
pendingPlanApproval,
currentProject,
setPendingPlanApproval,
updateFeature,
loadFeatures,
hookFeatures,
]
[pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures]
);
// Handle plan rejection
@@ -751,11 +476,11 @@ export function BoardView() {
if (result.success) {
// Immediately update local feature state
// Get current feature to preserve version
const currentFeature = hookFeatures.find((f) => f.id === featureId);
const currentFeature = hookFeatures.find(f => f.id === featureId);
updateFeature(featureId, {
status: "backlog",
status: 'backlog',
planSpec: {
status: "rejected",
status: 'rejected',
content: pendingPlanApproval.planContent,
version: currentFeature?.planSpec?.version || 1,
reviewedByUser: true,
@@ -773,14 +498,7 @@ export function BoardView() {
setPendingPlanApproval(null);
}
},
[
pendingPlanApproval,
currentProject,
setPendingPlanApproval,
updateFeature,
loadFeatures,
hookFeatures,
]
[pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures]
);
// Handle opening approval dialog from feature card button
@@ -791,7 +509,7 @@ export function BoardView() {
// Determine the planning mode for approval (skip should never have a plan requiring approval)
const mode = feature.planningMode;
const approvalMode: "lite" | "spec" | "full" =
mode === "lite" || mode === "spec" || mode === "full" ? mode : "spec";
mode === 'lite' || mode === 'spec' || mode === 'full' ? mode : 'spec';
// Re-open the approval dialog with the feature's plan data
setPendingPlanApproval({
@@ -804,6 +522,9 @@ export function BoardView() {
[currentProject, setPendingPlanApproval]
);
const effectiveTheme = getEffectiveTheme();
const isCleanTheme = effectiveTheme === "clean";
if (!currentProject) {
return (
<div
@@ -837,13 +558,8 @@ export function BoardView() {
maxConcurrency={maxConcurrency}
onConcurrencyChange={setMaxConcurrency}
isAutoModeRunning={autoMode.isRunning}
onAutoModeToggle={(enabled) => {
if (enabled) {
autoMode.start();
} else {
autoMode.stop();
}
}}
onStartAutoMode={() => autoMode.start()}
onStopAutoMode={() => autoMode.stop()}
onAddFeature={() => setShowAddDialog(true)}
addFeatureShortcut={{
key: shortcuts.addFeature,
@@ -874,11 +590,10 @@ export function BoardView() {
setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true);
}}
onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasks}
branchCardCounts={branchCardCounts}
features={hookFeatures.map((f) => ({
id: f.id,
worktreePath: f.worktreePath,
branchName: f.branchName,
}))}
/>
@@ -886,7 +601,7 @@ export function BoardView() {
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Search Bar Row */}
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
<div className={cn("flex items-center justify-between shrink-0", isCleanTheme ? "px-8 py-4" : "px-4 pt-4 pb-2")}>
<BoardSearchBar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
@@ -935,7 +650,7 @@ export function BoardView() {
onStartNextFeatures={handleStartNextFeatures}
onShowSuggestions={() => setShowSuggestionsDialog(true)}
suggestionsCount={suggestionsCount}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
onDeleteAllVerified={() => setShowDeleteAllVerifiedDialog(true)}
/>
</div>
@@ -973,10 +688,8 @@ export function BoardView() {
onAdd={handleAddFeature}
categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
defaultSkipTests={defaultSkipTests}
defaultBranch={selectedWorktreeBranch}
currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
@@ -989,8 +702,6 @@ export function BoardView() {
onUpdate={handleUpdateFeature}
categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
@@ -1007,14 +718,14 @@ export function BoardView() {
onNumberKeyPress={handleOutputModalNumberKeyPress}
/>
{/* Archive All Verified Dialog */}
<ArchiveAllVerifiedDialog
open={showArchiveAllVerifiedDialog}
onOpenChange={setShowArchiveAllVerifiedDialog}
{/* Delete All Verified Dialog */}
<DeleteAllVerifiedDialog
open={showDeleteAllVerifiedDialog}
onOpenChange={setShowDeleteAllVerifiedDialog}
verifiedCount={getColumnFeatures("verified").length}
onConfirm={async () => {
await handleArchiveAllVerified();
setShowArchiveAllVerifiedDialog(false);
await handleDeleteAllVerified();
setShowDeleteAllVerifiedDialog(false);
}}
/>
@@ -1111,24 +822,21 @@ export function BoardView() {
onOpenChange={setShowDeleteWorktreeDialog}
projectPath={currentProject.path}
worktree={selectedWorktreeForAction}
affectedFeatureCount={
selectedWorktreeForAction
? hookFeatures.filter(
(f) => f.branchName === selectedWorktreeForAction.branch
).length
: 0
}
onDeleted={(deletedWorktree, _deletedBranch) => {
// Reset features that were assigned to the deleted worktree (by branch)
// Reset features that were assigned to the deleted worktree
hookFeatures.forEach((feature) => {
// Match by branch name since worktreePath is no longer stored
if (feature.branchName === deletedWorktree.branch) {
// Reset the feature's branch assignment - update both local state and persist
const updates = {
const matchesByPath =
feature.worktreePath &&
pathsEqual(feature.worktreePath, deletedWorktree.path);
const matchesByBranch =
feature.branchName === deletedWorktree.branch;
if (matchesByPath || matchesByBranch) {
// Reset the feature's worktree assignment
persistFeatureUpdate(feature.id, {
branchName: null as unknown as string | undefined,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
worktreePath: null as unknown as string | undefined,
});
}
});

View File

@@ -1,8 +1,10 @@
"use client";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ImageIcon, Archive, Minimize2, Square, Maximize2 } from "lucide-react";
import { 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;
@@ -21,8 +23,41 @@ export function BoardControls({
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">

View File

@@ -0,0 +1,158 @@
"use client";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Slider } from "@/components/ui/slider";
import { Play, StopCircle, Plus, Users } from "lucide-react";
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
import { useAppStore } from "@/store/app-store";
interface BoardHeaderProps {
projectName: string;
maxConcurrency: number;
onConcurrencyChange: (value: number) => void;
isAutoModeRunning: boolean;
onStartAutoMode: () => void;
onStopAutoMode: () => void;
onAddFeature: () => void;
addFeatureShortcut: KeyboardShortcut;
isMounted: boolean;
}
export function BoardHeader({
projectName,
maxConcurrency,
onConcurrencyChange,
isAutoModeRunning,
onStartAutoMode,
onStopAutoMode,
onAddFeature,
addFeatureShortcut,
isMounted,
}: BoardHeaderProps) {
const { getEffectiveTheme } = useAppStore();
const effectiveTheme = getEffectiveTheme();
const isCleanTheme = effectiveTheme === "clean";
if (isCleanTheme) {
return (
<header className="h-16 flex items-center justify-between px-8 border-b border-white/5 bg-[#0b101a]/40 backdrop-blur-md z-20 shrink-0">
<div>
<h2 className="text-lg font-bold text-white tracking-tight">Kanban Board</h2>
<p className="text-[10px] text-slate-500 uppercase tracking-[0.2em] font-bold mono">
{projectName}
</p>
</div>
<div className="flex items-center gap-5">
{/* Concurrency Display (Visual only to match mockup for now, or interactive if needed) */}
<div className="flex items-center bg-white/5 border border-white/10 rounded-full px-4 py-1.5 gap-3">
<Users className="w-4 h-4 text-slate-500" />
<div className="toggle-track">
<div className="toggle-thumb"></div>
</div>
<span className="mono text-xs font-bold text-slate-400">{maxConcurrency}</span>
</div>
{/* Auto Mode Button */}
{isAutoModeRunning ? (
<button
className="flex items-center gap-2 glass px-5 py-2 rounded-xl text-xs font-bold hover:bg-white/10 transition text-rose-400 border-rose-500/30"
onClick={onStopAutoMode}
>
<StopCircle className="w-3.5 h-3.5" /> Stop
</button>
) : (
<button
className="flex items-center gap-2 glass px-5 py-2 rounded-xl text-xs font-bold hover:bg-white/10 transition"
onClick={onStartAutoMode}
>
<Play className="w-3.5 h-3.5 text-cyan-400 fill-cyan-400" /> Auto Mode
</button>
)}
{/* Add Feature Button */}
<button
className="btn-cyan px-6 py-2 rounded-xl text-xs font-black flex items-center gap-2 shadow-lg shadow-cyan-500/20"
onClick={onAddFeature}
>
<Plus className="w-4 h-4 stroke-[3.5px]" /> ADD FEATURE
</button>
</div>
</header>
);
}
return (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div>
<h1 className="text-xl font-bold">Kanban Board</h1>
<p className="text-sm text-muted-foreground">{projectName}</p>
</div>
<div className="flex gap-2 items-center">
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
{isMounted && (
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border"
data-testid="concurrency-slider-container"
>
<Users className="w-4 h-4 text-muted-foreground" />
<Slider
value={[maxConcurrency]}
onValueChange={(value) => onConcurrencyChange(value[0])}
min={1}
max={10}
step={1}
className="w-20"
data-testid="concurrency-slider"
/>
<span
className="text-sm text-muted-foreground min-w-[2ch] text-center"
data-testid="concurrency-value"
>
{maxConcurrency}
</span>
</div>
)}
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
<>
{isAutoModeRunning ? (
<Button
variant="destructive"
size="sm"
onClick={onStopAutoMode}
data-testid="stop-auto-mode"
>
<StopCircle className="w-4 h-4 mr-2" />
Stop Auto Mode
</Button>
) : (
<Button
variant="secondary"
size="sm"
onClick={onStartAutoMode}
data-testid="start-auto-mode"
>
<Play className="w-4 h-4 mr-2" />
Auto Mode
</Button>
)}
</>
)}
<HotkeyButton
size="sm"
onClick={onAddFeature}
hotkey={addFeatureShortcut}
hotkeyActive={false}
data-testid="add-feature-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
</HotkeyButton>
</div>
</div>
);
}

View File

@@ -1,7 +1,9 @@
"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;
@@ -19,6 +21,9 @@ export function BoardSearchBar({
currentProjectPath,
}: BoardSearchBarProps) {
const searchInputRef = useRef<HTMLInputElement>(null);
const { getEffectiveTheme } = useAppStore();
const effectiveTheme = getEffectiveTheme();
const isCleanTheme = effectiveTheme === "clean";
// Focus search input when "/" is pressed
useEffect(() => {
@@ -38,6 +43,25 @@ export function BoardSearchBar({
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">

View File

@@ -1,3 +1,4 @@
"use client";
import { useState, useEffect, useMemo, memo } from "react";
import { useSortable } from "@dnd-kit/sortable";
@@ -57,7 +58,12 @@ import {
Wand2,
Archive,
Lock,
Coins,
Target,
Square,
Terminal,
RefreshCw,
Layers,
Edit3,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { getElectronAPI } from "@/lib/electron";
@@ -91,29 +97,6 @@ function formatThinkingLevel(level: ThinkingLevel | undefined): string {
return labels[level];
}
/**
* Formats token counts for compact display (e.g., 12500 -> "12.5K")
*/
function formatTokenCount(count: number): string {
if (count >= 1000000) {
return `${(count / 1000000).toFixed(1)}M`;
}
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}K`;
}
return count.toString();
}
/**
* Formats cost for display (e.g., 0.0847 -> "$0.0847")
*/
function formatCost(cost: number): string {
if (cost < 0.01) {
return `$${cost.toFixed(4)}`;
}
return `$${cost.toFixed(2)}`;
}
interface KanbanCardProps {
feature: Feature;
onEdit: () => void;
@@ -172,7 +155,9 @@ export const KanbanCard = memo(function KanbanCard({
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [currentTime, setCurrentTime] = useState(() => Date.now());
const { kanbanCardDetailLevel, enableDependencyBlocking, features, useWorktrees } = useAppStore();
const { kanbanCardDetailLevel, enableDependencyBlocking, features, useWorktrees, getEffectiveTheme } = useAppStore();
const effectiveTheme = getEffectiveTheme();
const isCleanTheme = effectiveTheme === "clean";
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
const blockingDependencies = useMemo(() => {
@@ -183,9 +168,9 @@ export const KanbanCard = memo(function KanbanCard({
}, [enableDependencyBlocking, feature, features]);
const showSteps =
kanbanCardDetailLevel === "standard" ||
kanbanCardDetailLevel === "detailed";
const showAgentInfo = kanbanCardDetailLevel === "detailed";
(kanbanCardDetailLevel === "standard" ||
kanbanCardDetailLevel === "detailed") && !isCleanTheme; // Hide steps in clean theme
const showAgentInfo = kanbanCardDetailLevel === "detailed" || isCleanTheme; // Always show model info in clean theme
const isJustFinished = useMemo(() => {
if (
@@ -284,7 +269,7 @@ export const KanbanCard = memo(function KanbanCard({
feature.status === "backlog" ||
feature.status === "waiting_approval" ||
feature.status === "verified" ||
(feature.status === "in_progress" && !isCurrentAutoTask);
(feature.skipTests && !isCurrentAutoTask);
const {
attributes,
listeners,
@@ -314,17 +299,261 @@ export const KanbanCard = memo(function KanbanCard({
).borderColor = `color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
}
// CLEAN THEME IMPLEMENTATION
if (isCleanTheme) {
return (
<>
<div
ref={setNodeRef}
style={style}
className={cn(
"glass kanban-card flex flex-col gap-4 group relative",
// Verified state
feature.status === "verified" && "opacity-60 hover:opacity-100 transition-all",
// Running card state
isCurrentAutoTask && "border-cyan-500/40 bg-cyan-500/[0.08]",
// Dragging state
isDragging && "scale-105 shadow-xl shadow-black/20 opacity-50 z-50",
!isDraggable && "cursor-default"
)}
onDoubleClick={onEdit}
{...attributes}
{...(isDraggable ? listeners : {})}
>
{/* Action Icons - Waiting/Verified (In Flow, First Child) */}
{(feature.status === "waiting_approval" || feature.status === "verified") && (
<div className={cn(
"flex justify-end gap-3.5 transition-opacity",
feature.status === "waiting_approval" ? "opacity-30 group-hover:opacity-100" : "opacity-20"
)}>
<Edit3
className="w-4 h-4 cursor-pointer hover:text-white transition"
onClick={(e) => { e.stopPropagation(); onEdit(); }}
/>
<Trash2
className="w-4 h-4 cursor-pointer hover:text-rose-400 transition"
onClick={(e) => { e.stopPropagation(); handleDeleteClick(e); }}
/>
</div>
)}
{/* Top Bar - Running State */}
{isCurrentAutoTask && (
<div className="flex justify-end items-center gap-2">
<div className="bg-orange-500/15 text-orange-400 text-[9px] px-2.5 py-1 rounded-lg border border-orange-500/20 flex items-center gap-1.5 font-black mono">
<RefreshCw className="w-3 h-3" /> {formatModelName(feature.model ?? DEFAULT_MODEL)}
</div>
<div className="bg-slate-900/50 text-slate-500 text-[9px] px-2 py-1 rounded-lg border border-white/5 font-mono">
{feature.startedAt ? (
<CountUpTimer
startedAt={feature.startedAt}
className="text-inherit"
/>
) : "00:00"}
</div>
</div>
)}
{/* Top Bar - In Progress (Inactive) State */}
{!isCurrentAutoTask && feature.status === "in_progress" && (
<div className="flex justify-end gap-2">
<div className="bg-orange-500/10 text-orange-400 text-[9px] px-2.5 py-1 rounded-lg border border-orange-500/10 flex items-center gap-1.5 font-bold mono">
<RefreshCw className="w-3 h-3" /> {formatModelName(feature.model ?? DEFAULT_MODEL)}
</div>
{/* Duration if available - mocked for now as not in Feature type */}
<div className="bg-slate-900/50 text-slate-500 text-[9px] px-2 py-1 rounded-lg border border-white/5 font-mono">
00:07
</div>
</div>
)}
{/* Delete Icon - Top Right for Backlog (Absolute) */}
{feature.status === "backlog" && (
<div className="absolute top-5 right-6 opacity-0 group-hover:opacity-100 transition-opacity">
<Trash2
className="w-4 h-4 text-slate-600 hover:text-red-400 cursor-pointer"
onClick={handleDeleteClick}
/>
</div>
)}
{/* Description */}
<p className={cn(
"text-[13px] leading-relaxed font-medium line-clamp-3",
isCurrentAutoTask ? "text-white font-semibold" : "text-slate-300",
feature.status === "waiting_approval" && "italic",
feature.status === "verified" && "line-through decoration-slate-600"
)}>
{feature.description || feature.summary || "No description"}
</p>
{/* More link */}
{(feature.description || "").length > 100 && (
<div className="flex items-center gap-1 text-[10px] text-slate-500 -mt-1 cursor-pointer hover:text-slate-300">
<ChevronDown className="w-3 h-3" /> More
</div>
)}
{/* Backlog Info */}
{feature.status === "backlog" && (
<div className="text-[10px] font-bold text-cyan-400/80 mono flex items-center gap-1.5 uppercase tracking-tight">
<Layers className="w-3.5 h-3.5" /> {feature.category || "General"}
</div>
)}
{/* Buttons */}
<div className="flex gap-2 mt-auto">
{/* Backlog Buttons */}
{feature.status === "backlog" && (
<>
<button
onClick={(e) => { e.stopPropagation(); onEdit(); }}
className="flex-1 glass py-2.5 rounded-xl text-[11px] font-bold flex items-center justify-center gap-2 hover:bg-white/10 transition"
>
<Edit3 className="w-3.5 h-3.5" /> Edit
</button>
{onImplement && (
<button
onClick={(e) => { e.stopPropagation(); onImplement(); }}
className="flex-1 bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/20 py-2.5 rounded-xl text-[11px] font-bold flex items-center justify-center gap-2 transition"
>
<Target className="w-3.5 h-3.5" /> Make
</button>
)}
</>
)}
{/* In Progress Buttons */}
{feature.status === "in_progress" && (
<>
{onViewOutput && (
<button
onClick={(e) => { e.stopPropagation(); onViewOutput(); }}
className={cn(
"flex-[4] py-3 rounded-xl text-[11px] font-bold flex items-center justify-center gap-2",
isCurrentAutoTask ? "btn-cyan font-black tracking-widest" : "bg-cyan-500/15 text-cyan-400 border border-cyan-500/20"
)}
>
<Terminal className={cn("w-4 h-4", isCurrentAutoTask && "stroke-[2.5px]")} /> LOGS
{agentInfo?.toolCallCount ? (
<span className={cn("px-1.5 rounded ml-1", isCurrentAutoTask ? "bg-black/10" : "bg-cyan-500/10")}>{agentInfo.toolCallCount}</span>
) : null}
</button>
)}
{onForceStop && (
<button
onClick={(e) => { e.stopPropagation(); onForceStop(); }}
className={cn(
"flex-1 rounded-xl flex items-center justify-center transition",
isCurrentAutoTask ? "bg-rose-500 hover:bg-rose-600 text-white shadow-lg shadow-rose-500/20" : "bg-rose-500/20 text-rose-500/50 border border-rose-500/20"
)}
>
<Square className="w-4 h-4 fill-current" />
</button>
)}
</>
)}
{/* Waiting Buttons */}
{feature.status === "waiting_approval" && (
<>
{onFollowUp && (
<button
onClick={(e) => { e.stopPropagation(); onFollowUp(); }}
className="flex-1 glass border-white/10 py-3 rounded-xl text-[11px] font-bold flex items-center justify-center gap-2 hover:bg-white/10 transition"
>
<Wand2 className="w-4 h-4" /> Refine
</button>
)}
{onCommit && (
<button
onClick={(e) => { e.stopPropagation(); onCommit(); }}
className="flex-1 btn-cyan py-3 rounded-xl text-[11px] font-black flex items-center justify-center gap-2 tracking-widest"
>
<GitCommit className="w-4 h-4 stroke-[2.5px]" /> COMMIT
</button>
)}
</>
)}
{/* Verified Buttons */}
{feature.status === "verified" && (
<>
{onViewOutput && (
<button
onClick={(e) => { e.stopPropagation(); onViewOutput(); }}
className="px-7 glass border-white/10 py-3 rounded-xl text-[11px] font-bold text-slate-500 hover:text-slate-300 transition"
>
Logs
</button>
)}
{onComplete && (
<button
onClick={(e) => { e.stopPropagation(); onComplete(); }}
className="flex-1 bg-emerald-500/15 text-emerald-400 border border-emerald-500/20 py-3 rounded-xl text-[11px] font-black flex items-center justify-center gap-2 tracking-widest"
>
<CheckCircle2 className="w-4 h-4 stroke-[2.5px]" /> COMPLETE
</button>
)}
</>
)}
</div>
</div>
{/* Delete Confirmation Dialog */}
<DeleteConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleConfirmDelete}
title="Delete Feature"
description="Are you sure you want to delete this feature? This action cannot be undone."
testId="delete-confirmation-dialog"
confirmTestId="confirm-delete-button"
/>
{/* Summary Modal - Reusing existing logic */}
<Dialog open={isSummaryDialogOpen} onOpenChange={setIsSummaryDialogOpen}>
<DialogContent
className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col"
data-testid={`summary-dialog-${feature.id}`}
>
{/* ... Existing dialog content ... */}
<DialogHeader>
<DialogTitle>Summary</DialogTitle>
<DialogDescription>{feature.summary}</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-4 bg-card rounded-lg border border-border/50">
<Markdown>
{feature.summary ||
summary ||
agentInfo?.summary ||
"No summary available"}
</Markdown>
</div>
<DialogFooter>
<Button onClick={() => setIsSummaryDialogOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
const cardElement = (
<Card
ref={setNodeRef}
style={isCurrentAutoTask ? style : borderStyle}
className={cn(
// Clean theme kanban-card class
"kanban-card",
"cursor-grab active:cursor-grabbing relative kanban-card-content select-none",
"transition-all duration-200 ease-out",
// Premium shadow system
"shadow-sm hover:shadow-md hover:shadow-black/10",
// Subtle lift on hover
"hover:-translate-y-0.5",
// Running card state for clean theme
isCurrentAutoTask && "is-running kanban-card-active",
!isCurrentAutoTask &&
cardBorderEnabled &&
cardBorderOpacity === 100 &&
@@ -748,7 +977,10 @@ export const KanbanCard = memo(function KanbanCard({
{/* Model/Preset Info for Backlog Cards */}
{showAgentInfo && feature.status === "backlog" && (
<div className="mb-3 space-y-2 overflow-hidden">
<div
className="mb-3 space-y-2 overflow-hidden"
style={isCleanTheme ? { order: 1 } : undefined}
>
<div className="flex items-center gap-2 text-[11px] flex-wrap">
<div className="flex items-center gap-1 text-[var(--status-info)]">
<Cpu className="w-3 h-3" />
@@ -770,7 +1002,10 @@ export const KanbanCard = memo(function KanbanCard({
{/* Agent Info Panel */}
{showAgentInfo && feature.status !== "backlog" && agentInfo && (
<div className="mb-3 space-y-2 overflow-hidden">
<div
className="mb-3 space-y-2 overflow-hidden"
style={isCleanTheme ? { order: 1 } : undefined}
>
{/* Model & Phase */}
<div className="flex items-center gap-2 text-[11px] flex-wrap">
<div className="flex items-center gap-1 text-[var(--status-info)]">
@@ -897,43 +1132,16 @@ export const KanbanCard = memo(function KanbanCard({
)}
</div>
)}
{/* Token Usage Display */}
{feature.tokenUsage && feature.tokenUsage.totalTokens > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1 cursor-help">
<Coins className="w-2.5 h-2.5 text-amber-400" />
{formatTokenCount(feature.tokenUsage.totalTokens)} tokens
<span className="text-amber-400/80">
({formatCost(feature.tokenUsage.costUSD)})
</span>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
<div className="space-y-1">
<p>Input: {formatTokenCount(feature.tokenUsage.inputTokens)}</p>
<p>Output: {formatTokenCount(feature.tokenUsage.outputTokens)}</p>
{feature.tokenUsage.cacheReadInputTokens > 0 && (
<p>Cache read: {formatTokenCount(feature.tokenUsage.cacheReadInputTokens)}</p>
)}
<p className="font-medium pt-1 border-t border-border/30">
Cost: {formatCost(feature.tokenUsage.costUSD)}
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</>
)}
</div>
)}
{/* Actions */}
<div className="flex flex-wrap gap-1.5">
<div
className="flex flex-wrap gap-1.5"
style={isCleanTheme ? { order: 2 } : undefined}
>
{isCurrentAutoTask && (
<>
{/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */}

View File

@@ -0,0 +1,198 @@
"use client";
import { memo } from "react";
import { useDroppable } from "@dnd-kit/core";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
import { useAppStore } from "@/store/app-store";
interface KanbanColumnProps {
id: string;
title: string;
colorClass: string;
count: number;
children: ReactNode;
headerAction?: ReactNode;
opacity?: number;
showBorder?: boolean;
hideScrollbar?: boolean;
}
export const KanbanColumn = memo(function KanbanColumn({
id,
title,
colorClass,
count,
children,
headerAction,
opacity = 100,
showBorder = true,
hideScrollbar = false,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
const { getEffectiveTheme } = useAppStore();
const effectiveTheme = getEffectiveTheme();
const isCleanTheme = effectiveTheme === "clean";
// Map column IDs to clean theme classes
const getColumnClasses = () => {
switch (id) {
case "in_progress":
return "col-in-progress";
case "waiting_approval":
return "col-waiting";
case "verified":
return "col-verified";
default:
return "";
}
};
// Map column IDs to status dot glow classes
const getStatusDotClasses = () => {
switch (id) {
case "in_progress":
return "status-dot-in-progress glow-cyan";
case "waiting_approval":
return "status-dot-waiting glow-orange";
case "verified":
return "status-dot-verified glow-green";
default:
return "";
}
};
// Clean theme column styles
if (isCleanTheme) {
const isBacklog = id === "backlog";
// Explicitly match mockup classes for status dots
const getCleanStatusDotClass = () => {
switch (id) {
case "backlog":
return "status-dot bg-slate-600";
case "in_progress":
return "status-dot bg-cyan-400 glow-cyan";
case "waiting_approval":
return "status-dot bg-orange-500 glow-orange";
case "verified":
return "status-dot bg-emerald-500 glow-green";
default:
return "status-dot bg-slate-600";
}
};
// Explicitly match mockup classes for badges
const getBadgeClass = () => {
switch (id) {
case "in_progress":
return "mono text-[10px] bg-cyan-500/10 px-2.5 py-0.5 rounded-full text-cyan-400 border border-cyan-500/20";
case "verified":
return "mono text-[10px] bg-emerald-500/10 px-2.5 py-0.5 rounded-full text-emerald-500 border border-emerald-500/20";
case "backlog":
case "waiting_approval":
default:
return "mono text-[10px] bg-white/5 px-2.5 py-0.5 rounded-full text-slate-500 border border-white/5";
}
};
return (
<div
ref={setNodeRef}
className={cn(
"flex flex-col h-full w-80 gap-5",
!isBacklog && "rounded-[2.5rem] p-3",
getColumnClasses()
)}
data-testid={`kanban-column-${id}`}
data-column-id={id}
>
{/* Header */}
<div className="flex items-center justify-between px-2 shrink-0">
<div className="flex items-center gap-3">
<span className={getCleanStatusDotClass()} />
<h3 className={cn(
"text-[11px] font-black uppercase tracking-widest",
id === "backlog" ? "text-slate-400" :
id === "in_progress" ? "text-slate-200" : "text-slate-300"
)}>
{title}
</h3>
{headerAction}
</div>
<span className={getBadgeClass()}>
{count}
</span>
</div>
{/* Content */}
<div
className={cn(
"flex-1 overflow-y-auto custom-scrollbar space-y-4",
isBacklog ? "pr-2" : "pr-1",
hideScrollbar && "scrollbar-hide"
)}
>
{children}
</div>
</div>
);
}
return (
<div
ref={setNodeRef}
className={cn(
"relative flex flex-col h-full rounded-xl transition-all duration-200 w-72 clean:w-80",
showBorder && "border border-border/60",
isOver && "ring-2 ring-primary/30 ring-offset-1 ring-offset-background",
getColumnClasses()
)}
data-testid={`kanban-column-${id}`}
data-column-id={id}
>
{/* Background layer with opacity */}
<div
className={cn(
"absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200",
isOver ? "bg-accent/80" : "bg-card/80"
)}
style={{ opacity: opacity / 100 }}
/>
{/* Column Header */}
<div
className={cn(
"relative z-10 flex items-center gap-3 px-3 py-2.5",
showBorder && "border-b border-border/40"
)}
>
<div className={cn("w-2.5 h-2.5 rounded-full shrink-0 status-dot", colorClass, getStatusDotClasses())} />
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
{headerAction}
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
{count}
</span>
</div>
{/* Column Content */}
<div
className={cn(
"relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5",
hideScrollbar &&
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
// Smooth scrolling
"scroll-smooth"
)}
>
{children}
</div>
{/* Drop zone indicator when dragging over */}
{isOver && (
<div className="absolute inset-0 rounded-xl bg-primary/5 pointer-events-none z-5 border-2 border-dashed border-primary/20" />
)}
</div>
);
});

View File

@@ -1,3 +1,4 @@
"use client";
import { useState, useEffect } from "react";
import {
@@ -13,6 +14,7 @@ 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,
@@ -43,7 +45,6 @@ import {
ProfileQuickSelect,
TestingTabContent,
PrioritySelector,
BranchSelector,
PlanningModeSelector,
} from "../shared";
import {
@@ -52,7 +53,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useNavigate } from "@tanstack/react-router";
interface AddFeatureDialogProps {
open: boolean;
@@ -66,17 +66,15 @@ interface AddFeatureDialogProps {
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string; // Can be empty string to use current branch
branchName: string;
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
}) => void;
categorySuggestions: string[];
branchSuggestions: string[];
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
defaultSkipTests: boolean;
defaultBranch?: string;
currentBranch?: string;
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
@@ -88,16 +86,12 @@ export function AddFeatureDialog({
onAdd,
categorySuggestions,
branchSuggestions,
branchCardCounts,
defaultSkipTests,
defaultBranch = "main",
currentBranch,
isMaximized,
showProfilesOnly,
aiProfiles,
}: AddFeatureDialogProps) {
const navigate = useNavigate();
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
const [newFeature, setNewFeature] = useState({
category: "",
description: "",
@@ -107,7 +101,7 @@ export function AddFeatureDialog({
skipTests: false,
model: "opus" as AgentModel,
thinkingLevel: "none" as ThinkingLevel,
branchName: "",
branchName: "main",
priority: 2 as number, // Default to medium priority
});
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
@@ -118,16 +112,11 @@ export function AddFeatureDialog({
const [enhancementMode, setEnhancementMode] = useState<
"improve" | "technical" | "simplify" | "acceptance"
>("improve");
const [planningMode, setPlanningMode] = useState<PlanningMode>("skip");
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();
const { enhancementModel, defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
// Sync defaults when dialog opens
useEffect(() => {
@@ -135,19 +124,12 @@ export function AddFeatureDialog({
setNewFeature((prev) => ({
...prev,
skipTests: defaultSkipTests,
branchName: defaultBranch || "",
branchName: defaultBranch,
}));
setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
}
}, [
open,
defaultSkipTests,
defaultBranch,
defaultPlanningMode,
defaultRequirePlanApproval,
]);
}, [open, defaultSkipTests, defaultBranch, defaultPlanningMode, defaultRequirePlanApproval]);
const handleAdd = () => {
if (!newFeature.description.trim()) {
@@ -155,25 +137,12 @@ export function AddFeatureDialog({
return;
}
// Validate branch selection when "other branch" is selected
if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
toast.error("Please select a branch name");
return;
}
const category = newFeature.category || "Uncategorized";
const selectedModel = newFeature.model;
const normalizedThinking = modelSupportsThinking(selectedModel)
? newFeature.thinkingLevel
: "none";
// Use current branch if toggle is on
// If currentBranch is provided (non-primary worktree), use it
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
const finalBranchName = useCurrentBranch
? currentBranch || ""
: newFeature.branchName || "";
onAdd({
category,
description: newFeature.description,
@@ -183,7 +152,7 @@ export function AddFeatureDialog({
skipTests: newFeature.skipTests,
model: selectedModel,
thinkingLevel: normalizedThinking,
branchName: finalBranchName,
branchName: newFeature.branchName,
priority: newFeature.priority,
planningMode,
requirePlanApproval,
@@ -200,9 +169,8 @@ export function AddFeatureDialog({
model: "opus",
priority: 2,
thinkingLevel: "none",
branchName: "",
branchName: defaultBranch,
});
setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setNewFeaturePreviewMap(new Map());
@@ -404,18 +372,22 @@ export function AddFeatureDialog({
/>
</div>
{useWorktrees && (
<BranchSelector
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={newFeature.branchName}
onBranchNameChange={(value) =>
setNewFeature({ ...newFeature, branchName: value })
}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
testIdPrefix="feature"
/>
<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 */}
@@ -465,7 +437,7 @@ export function AddFeatureDialog({
showManageLink
onManageLinkClick={() => {
onOpenChange(false);
navigate({ to: "/profiles" });
useAppStore.getState().setCurrentView("profiles");
}}
/>
@@ -495,10 +467,7 @@ export function AddFeatureDialog({
</TabsContent>
{/* Options Tab */}
<TabsContent
value="options"
className="space-y-4 overflow-y-auto cursor-default"
>
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
{/* Planning Mode Section */}
<PlanningModeSelector
mode={planningMode}
@@ -532,9 +501,6 @@ export function AddFeatureDialog({
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open}
data-testid="confirm-add-feature"
disabled={
useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()
}
>
Add Feature
</HotkeyButton>

View File

@@ -1,3 +1,4 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
@@ -44,8 +45,6 @@ export function AgentOutputModal({
const autoScrollRef = useRef(true);
const projectPathRef = useRef<string>("");
const useWorktrees = useAppStore((state) => state.useWorktrees);
const features = useAppStore((state) => state.features);
const feature = features.find((f) => f.id === featureId);
// Auto-scroll to bottom when output changes
useEffect(() => {
@@ -389,7 +388,7 @@ export function AgentOutputModal({
No output yet. The agent will stream output here as it works.
</div>
) : viewMode === "parsed" ? (
<LogViewer output={output} tokenUsage={feature?.tokenUsage} />
<LogViewer output={output} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">
{output}

View File

@@ -1,5 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
@@ -48,26 +49,18 @@ export function CreatePRDialog({
const [prUrl, setPrUrl] = useState<string | null>(null);
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
// Track whether an operation completed that warrants a refresh
const operationCompletedRef = useRef(false);
// Reset state when dialog opens or worktree changes
useEffect(() => {
if (open) {
// Reset form fields
// 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);
// Also reset result states when opening for a new worktree
// This prevents showing stale PR URLs from previous worktrees
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
// Reset operation tracking
operationCompletedRef.current = false;
} else {
// Reset everything when dialog closes
setTitle("");
@@ -79,7 +72,6 @@ export function CreatePRDialog({
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
operationCompletedRef.current = false;
}
}, [open, worktree?.path]);
@@ -106,8 +98,6 @@ export function CreatePRDialog({
if (result.success && result.result) {
if (result.result.prCreated && result.result.prUrl) {
setPrUrl(result.result.prUrl);
// Mark operation as completed for refresh on close
operationCompletedRef.current = true;
toast.success("Pull request created!", {
description: `PR created from ${result.result.branch}`,
action: {
@@ -115,8 +105,7 @@ export function CreatePRDialog({
onClick: () => window.open(result.result!.prUrl!, "_blank"),
},
});
// Don't call onCreated() here - keep dialog open to show success message
// onCreated() will be called when user closes the dialog
onCreated();
} else {
// Branch was pushed successfully
const prError = result.result.prError;
@@ -128,8 +117,6 @@ export function CreatePRDialog({
if (prError === "gh_cli_not_available" || !result.result.ghCliAvailable) {
setBrowserUrl(result.result.browserUrl ?? null);
setShowBrowserFallback(true);
// Mark operation as completed - branch was pushed successfully
operationCompletedRef.current = true;
toast.success("Branch pushed", {
description: result.result.committed
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
@@ -155,8 +142,6 @@ export function CreatePRDialog({
// Show error but also provide browser option
setBrowserUrl(result.result.browserUrl ?? null);
setShowBrowserFallback(true);
// Mark operation as completed - branch was pushed even though PR creation failed
operationCompletedRef.current = true;
toast.error("PR creation failed", {
description: errorMessage,
duration: 8000,
@@ -197,13 +182,19 @@ export function CreatePRDialog({
};
const handleClose = () => {
// Only call onCreated() if an actual operation completed
// This prevents unnecessary refreshes when user cancels
if (operationCompletedRef.current) {
onCreated();
}
onOpenChange(false);
// State reset is handled by useEffect when open becomes false
// 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;
@@ -237,18 +228,13 @@ export function CreatePRDialog({
Your PR is ready for review
</p>
</div>
<div className="flex gap-2 justify-center">
<Button
onClick={() => window.open(prUrl, "_blank")}
className="gap-2"
>
<ExternalLink className="w-4 h-4" />
View Pull Request
</Button>
<Button variant="outline" onClick={handleClose}>
Close
</Button>
</div>
<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">

Some files were not shown because too many files have changed in this diff Show More