Compare commits
1 Commits
feature/to
...
clean-them
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4907a610e |
66
.github/actions/setup-project/action.yml
vendored
@@ -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 }}
|
|
||||||
43
.github/workflows/e2e-tests.yml
vendored
@@ -18,15 +18,34 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup project
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-project
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
check-lockfile: "true"
|
node-version: "22"
|
||||||
rebuild-node-pty-path: "apps/server"
|
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
|
- name: Install Playwright browsers
|
||||||
run: npx playwright install --with-deps chromium
|
run: npx playwright install --with-deps chromium
|
||||||
working-directory: apps/ui
|
working-directory: apps/app
|
||||||
|
|
||||||
- name: Build server
|
- name: Build server
|
||||||
run: npm run build --workspace=apps/server
|
run: npm run build --workspace=apps/server
|
||||||
@@ -52,20 +71,20 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Run E2E tests
|
- name: Run E2E tests
|
||||||
# Playwright automatically starts the Vite frontend via webServer config
|
# Playwright automatically starts the Next.js frontend via webServer config
|
||||||
# (see apps/ui/playwright.config.ts) - no need to start it manually
|
# (see apps/app/playwright.config.ts) - no need to start it manually
|
||||||
run: npm run test --workspace=apps/ui
|
run: npm run test --workspace=apps/app
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
VITE_SERVER_URL: http://localhost:3008
|
NEXT_PUBLIC_SERVER_URL: http://localhost:3008
|
||||||
VITE_SKIP_SETUP: "true"
|
NEXT_PUBLIC_SKIP_SETUP: "true"
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: apps/ui/playwright-report/
|
path: apps/app/playwright-report/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
@@ -73,5 +92,5 @@ jobs:
|
|||||||
if: failure()
|
if: failure()
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: test-results
|
||||||
path: apps/ui/test-results/
|
path: apps/app/test-results/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|||||||
33
.github/workflows/pr-check.yml
vendored
@@ -17,10 +17,33 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup project
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-project
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
check-lockfile: "true"
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
- name: Run build:electron (dir only - faster CI)
|
- name: Check for SSH URLs in lockfile
|
||||||
run: npm run build:electron:dir
|
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
@@ -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
|
||||||
27
.github/workflows/test.yml
vendored
@@ -17,11 +17,30 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup project
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-project
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
check-lockfile: "true"
|
node-version: "22"
|
||||||
rebuild-node-pty-path: "apps/server"
|
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
|
- name: Run server tests with coverage
|
||||||
run: npm run test:server:coverage
|
run: npm run test:server:coverage
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<p align="center">
|
<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>
|
</p>
|
||||||
|
|
||||||
> **[!TIP]**
|
> **[!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.
|
Join the **Agentic Jumpstart** to connect with other builders exploring **agentic coding** and autonomous development workflows.
|
||||||
|
|
||||||
In the Discord, you can:
|
In the Discord, you can:
|
||||||
|
|
||||||
- 💬 Discuss agentic coding patterns and best practices
|
- 💬 Discuss agentic coding patterns and best practices
|
||||||
- 🧠 Share ideas for AI-driven development workflows
|
- 🧠 Share ideas for AI-driven development workflows
|
||||||
- 🛠️ Get help setting up or extending Automaker
|
- 🛠️ 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:**
|
**Summary of Terms:**
|
||||||
|
|
||||||
- **Allowed:**
|
- **Allowed:**
|
||||||
|
|
||||||
- **Build Anything:** You can clone and use Automaker locally or in your organization to build ANY product (commercial or free).
|
- **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.
|
- **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).
|
- **Modify:** You can modify the code for internal use within your organization (commercial or non-profit).
|
||||||
|
|
||||||
- **Restricted (The "No Monetization of the Tool" Rule):**
|
- **Restricted (The "No Monetization of the Tool" Rule):**
|
||||||
|
|
||||||
- **No Resale:** You cannot resell Automaker itself.
|
- **No Resale:** You cannot resell Automaker itself.
|
||||||
- **No SaaS:** You cannot host Automaker as a service for others.
|
- **No SaaS:** You cannot host Automaker as a service for others.
|
||||||
- **No Monetizing Mods:** You cannot distribute modified versions of Automaker for money.
|
- **No Monetizing Mods:** You cannot distribute modified versions of Automaker for money.
|
||||||
|
|
||||||
- **Liability:**
|
- **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.
|
- **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:**
|
- **Contributing:**
|
||||||
|
|||||||
310
REFACTORING_CANDIDATES.md
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
# Large Files - Refactoring Candidates
|
||||||
|
|
||||||
|
This document tracks files in the AutoMaker codebase that exceed 3000 lines or are significantly large (1000+ lines) and should be considered for refactoring into smaller, more maintainable components.
|
||||||
|
|
||||||
|
**Last Updated:** 2025-12-15
|
||||||
|
**Total Large Files:** 8
|
||||||
|
**Combined Size:** 15,027 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 CRITICAL - Over 3000 Lines
|
||||||
|
|
||||||
|
### 1. board-view.tsx - 3,325 lines
|
||||||
|
**Path:** `apps/app/src/components/views/board-view.tsx`
|
||||||
|
**Type:** React Component (TSX)
|
||||||
|
**Priority:** VERY HIGH
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Main Kanban board view component that serves as the centerpiece of the application.
|
||||||
|
|
||||||
|
**Current Responsibilities:**
|
||||||
|
- Feature/task card management and drag-and-drop operations using @dnd-kit
|
||||||
|
- Adding, editing, and deleting features
|
||||||
|
- Running autonomous agents to implement features
|
||||||
|
- Displaying feature status across multiple columns (Backlog, In Progress, Waiting Approval, Verified)
|
||||||
|
- Model/AI profile selection for feature implementation
|
||||||
|
- Advanced options configuration (thinking level, model selection, skip tests)
|
||||||
|
- Search/filtering functionality for cards
|
||||||
|
- Output modal for viewing agent results
|
||||||
|
- Feature suggestions dialog
|
||||||
|
- Board background customization
|
||||||
|
- Integration with Electron APIs for IPC communication
|
||||||
|
- Keyboard shortcuts support
|
||||||
|
- 40+ state variables for managing UI state
|
||||||
|
|
||||||
|
**Refactoring Recommendations:**
|
||||||
|
Extract into smaller components:
|
||||||
|
- `AddFeatureDialog.tsx` - Feature creation dialog with image upload
|
||||||
|
- `EditFeatureDialog.tsx` - Feature editing dialog
|
||||||
|
- `AgentOutputModal.tsx` - Already exists, verify separation
|
||||||
|
- `FeatureSuggestionsDialog.tsx` - Already exists, verify separation
|
||||||
|
- `BoardHeader.tsx` - Header with controls and search
|
||||||
|
- `BoardSearchBar.tsx` - Search and filter functionality
|
||||||
|
- `ConcurrencyControl.tsx` - Concurrency slider component
|
||||||
|
- `BoardActions.tsx` - Action buttons (add feature, auto mode, etc.)
|
||||||
|
- `DragDropContext.tsx` - Wrap drag-and-drop logic
|
||||||
|
- Custom hooks:
|
||||||
|
- `useBoardFeatures.ts` - Feature loading and management
|
||||||
|
- `useBoardDragDrop.ts` - Drag and drop handlers
|
||||||
|
- `useBoardActions.ts` - Feature action handlers (run, verify, delete, etc.)
|
||||||
|
- `useBoardKeyboardShortcuts.ts` - Keyboard shortcut logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 HIGH PRIORITY - 2000+ Lines
|
||||||
|
|
||||||
|
### 2. sidebar.tsx - 2,396 lines
|
||||||
|
**Path:** `apps/app/src/components/layout/sidebar.tsx`
|
||||||
|
**Type:** React Component (TSX)
|
||||||
|
**Priority:** HIGH
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Main navigation sidebar with comprehensive project management.
|
||||||
|
|
||||||
|
**Current Responsibilities:**
|
||||||
|
- Project folder navigation and selection
|
||||||
|
- View mode switching (Board, Agent, Settings, etc.)
|
||||||
|
- Project operations (create, delete, rename)
|
||||||
|
- Theme and appearance controls
|
||||||
|
- Terminal, Wiki, and other view launchers
|
||||||
|
- Drag-and-drop project reordering
|
||||||
|
- Settings and configuration access
|
||||||
|
|
||||||
|
**Refactoring Recommendations:**
|
||||||
|
Split into focused components:
|
||||||
|
- `ProjectSelector.tsx` - Project list and selection
|
||||||
|
- `NavigationTabs.tsx` - View mode tabs
|
||||||
|
- `ProjectActions.tsx` - Create, delete, rename operations
|
||||||
|
- `SettingsMenu.tsx` - Settings dropdown
|
||||||
|
- `ThemeSelector.tsx` - Theme controls
|
||||||
|
- `ViewLaunchers.tsx` - Terminal, Wiki launchers
|
||||||
|
- Custom hooks:
|
||||||
|
- `useProjectManagement.ts` - Project CRUD operations
|
||||||
|
- `useSidebarState.ts` - Sidebar state management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. electron.ts - 2,356 lines
|
||||||
|
**Path:** `apps/app/src/lib/electron.ts`
|
||||||
|
**Type:** TypeScript Utility/API Bridge
|
||||||
|
**Priority:** HIGH
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Electron IPC bridge and type definitions for frontend-backend communication.
|
||||||
|
|
||||||
|
**Current Responsibilities:**
|
||||||
|
- File system operations (read, write, directory listing)
|
||||||
|
- Project management APIs
|
||||||
|
- Feature management APIs
|
||||||
|
- Terminal/shell execution
|
||||||
|
- Auto mode and agent execution APIs
|
||||||
|
- Worktree management
|
||||||
|
- Provider status APIs
|
||||||
|
- Event handling and subscriptions
|
||||||
|
|
||||||
|
**Refactoring Recommendations:**
|
||||||
|
Modularize into domain-specific API modules:
|
||||||
|
- `api/file-system-api.ts` - File operations
|
||||||
|
- `api/project-api.ts` - Project CRUD
|
||||||
|
- `api/feature-api.ts` - Feature management
|
||||||
|
- `api/execution-api.ts` - Auto mode and agent execution
|
||||||
|
- `api/provider-api.ts` - Provider status and management
|
||||||
|
- `api/worktree-api.ts` - Git worktree operations
|
||||||
|
- `api/terminal-api.ts` - Terminal/shell APIs
|
||||||
|
- `types/electron-types.ts` - Shared type definitions
|
||||||
|
- `electron.ts` - Main export aggregator
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. app-store.ts - 2,174 lines
|
||||||
|
**Path:** `apps/app/src/store/app-store.ts`
|
||||||
|
**Type:** TypeScript State Management (Zustand Store)
|
||||||
|
**Priority:** HIGH
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Centralized application state store using Zustand.
|
||||||
|
|
||||||
|
**Current Responsibilities:**
|
||||||
|
- Global app state types and interfaces
|
||||||
|
- Project and feature management state
|
||||||
|
- Theme and appearance settings
|
||||||
|
- API keys configuration
|
||||||
|
- Keyboard shortcuts configuration
|
||||||
|
- Terminal themes configuration
|
||||||
|
- Auto mode settings
|
||||||
|
- All store mutations and selectors
|
||||||
|
|
||||||
|
**Refactoring Recommendations:**
|
||||||
|
Split into domain-specific stores:
|
||||||
|
- `stores/projects-store.ts` - Project state and actions
|
||||||
|
- `stores/features-store.ts` - Feature state and actions
|
||||||
|
- `stores/ui-store.ts` - UI state (theme, sidebar, modals)
|
||||||
|
- `stores/settings-store.ts` - User settings and preferences
|
||||||
|
- `stores/execution-store.ts` - Auto mode and running tasks
|
||||||
|
- `stores/provider-store.ts` - Provider configuration
|
||||||
|
- `types/store-types.ts` - Shared type definitions
|
||||||
|
- `app-store.ts` - Main store aggregator with combined selectors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 MEDIUM PRIORITY - 1000-2000 Lines
|
||||||
|
|
||||||
|
### 5. auto-mode-service.ts - 1,232 lines
|
||||||
|
**Path:** `apps/server/src/services/auto-mode-service.ts`
|
||||||
|
**Type:** TypeScript Service (Backend)
|
||||||
|
**Priority:** MEDIUM-HIGH
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Core autonomous feature implementation service.
|
||||||
|
|
||||||
|
**Current Responsibilities:**
|
||||||
|
- Worktree creation and management
|
||||||
|
- Feature execution with Claude Agent SDK
|
||||||
|
- Concurrent execution with concurrency limits
|
||||||
|
- Progress streaming via events
|
||||||
|
- Verification and merge workflows
|
||||||
|
- Provider management
|
||||||
|
- Error handling and classification
|
||||||
|
|
||||||
|
**Refactoring Recommendations:**
|
||||||
|
Extract into service modules:
|
||||||
|
- `services/worktree-manager.ts` - Worktree operations
|
||||||
|
- `services/feature-executor.ts` - Feature execution logic
|
||||||
|
- `services/concurrency-manager.ts` - Concurrency control
|
||||||
|
- `services/verification-service.ts` - Verification workflows
|
||||||
|
- `utils/error-classifier.ts` - Error handling utilities
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. spec-view.tsx - 1,230 lines
|
||||||
|
**Path:** `apps/app/src/components/views/spec-view.tsx`
|
||||||
|
**Type:** React Component (TSX)
|
||||||
|
**Priority:** MEDIUM
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Specification editor view component for feature specification management.
|
||||||
|
|
||||||
|
**Refactoring Recommendations:**
|
||||||
|
Extract editor components and hooks:
|
||||||
|
- `SpecEditor.tsx` - Main editor component
|
||||||
|
- `SpecToolbar.tsx` - Editor toolbar
|
||||||
|
- `SpecSidebar.tsx` - Spec navigation sidebar
|
||||||
|
- `useSpecEditor.ts` - Editor state management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. kanban-card.tsx - 1,180 lines
|
||||||
|
**Path:** `apps/app/src/components/views/kanban-card.tsx`
|
||||||
|
**Type:** React Component (TSX)
|
||||||
|
**Priority:** MEDIUM
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Individual Kanban card component with rich feature display and interaction.
|
||||||
|
|
||||||
|
**Refactoring Recommendations:**
|
||||||
|
Split into smaller card components:
|
||||||
|
- `KanbanCardHeader.tsx` - Card title and metadata
|
||||||
|
- `KanbanCardBody.tsx` - Card content
|
||||||
|
- `KanbanCardActions.tsx` - Action buttons
|
||||||
|
- `KanbanCardStatus.tsx` - Status indicators
|
||||||
|
- `useKanbanCard.ts` - Card interaction logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. analysis-view.tsx - 1,134 lines
|
||||||
|
**Path:** `apps/app/src/components/views/analysis-view.tsx`
|
||||||
|
**Type:** React Component (TSX)
|
||||||
|
**Priority:** MEDIUM
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Analysis view component for displaying and managing feature analysis data.
|
||||||
|
|
||||||
|
**Refactoring Recommendations:**
|
||||||
|
Extract visualization and data components:
|
||||||
|
- `AnalysisChart.tsx` - Chart/graph components
|
||||||
|
- `AnalysisTable.tsx` - Data table
|
||||||
|
- `AnalysisFilters.tsx` - Filter controls
|
||||||
|
- `useAnalysisData.ts` - Data fetching and processing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refactoring Strategy
|
||||||
|
|
||||||
|
### Phase 1: Critical (Immediate)
|
||||||
|
1. **board-view.tsx** - Break into dialogs, header, and custom hooks
|
||||||
|
- Extract all dialogs first (AddFeature, EditFeature)
|
||||||
|
- Move to custom hooks for business logic
|
||||||
|
- Split remaining UI into smaller components
|
||||||
|
|
||||||
|
### Phase 2: High Priority (Next Sprint)
|
||||||
|
2. **sidebar.tsx** - Componentize navigation and project management
|
||||||
|
3. **electron.ts** - Modularize into API domains
|
||||||
|
4. **app-store.ts** - Split into domain stores
|
||||||
|
|
||||||
|
### Phase 3: Medium Priority (Future)
|
||||||
|
5. **auto-mode-service.ts** - Extract service modules
|
||||||
|
6. **spec-view.tsx** - Break into editor components
|
||||||
|
7. **kanban-card.tsx** - Split card into sub-components
|
||||||
|
8. **analysis-view.tsx** - Extract visualization components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## General Refactoring Guidelines
|
||||||
|
|
||||||
|
### When Refactoring Large Components:
|
||||||
|
|
||||||
|
1. **Extract Dialogs/Modals First**
|
||||||
|
- Move dialog components to separate files
|
||||||
|
- Keep dialog state management in parent initially
|
||||||
|
- Later extract to custom hooks if complex
|
||||||
|
|
||||||
|
2. **Create Custom Hooks for Business Logic**
|
||||||
|
- Move data fetching to `useFetch*` hooks
|
||||||
|
- Move complex state logic to `use*State` hooks
|
||||||
|
- Move side effects to `use*Effect` hooks
|
||||||
|
|
||||||
|
3. **Split UI into Presentational Components**
|
||||||
|
- Header/toolbar components
|
||||||
|
- Content area components
|
||||||
|
- Footer/action components
|
||||||
|
|
||||||
|
4. **Move Utils and Helpers**
|
||||||
|
- Extract pure functions to utility files
|
||||||
|
- Move constants to separate constant files
|
||||||
|
- Create type files for shared interfaces
|
||||||
|
|
||||||
|
### When Refactoring Large Files:
|
||||||
|
|
||||||
|
1. **Identify Domains/Concerns**
|
||||||
|
- Group related functionality
|
||||||
|
- Find natural boundaries
|
||||||
|
|
||||||
|
2. **Extract Gradually**
|
||||||
|
- Start with least coupled code
|
||||||
|
- Work towards core functionality
|
||||||
|
- Test after each extraction
|
||||||
|
|
||||||
|
3. **Maintain Type Safety**
|
||||||
|
- Export types from extracted modules
|
||||||
|
- Use shared type files for common interfaces
|
||||||
|
- Ensure no type errors after refactoring
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progress Tracking
|
||||||
|
|
||||||
|
- [ ] board-view.tsx (3,325 lines)
|
||||||
|
- [ ] sidebar.tsx (2,396 lines)
|
||||||
|
- [ ] electron.ts (2,356 lines)
|
||||||
|
- [ ] app-store.ts (2,174 lines)
|
||||||
|
- [ ] auto-mode-service.ts (1,232 lines)
|
||||||
|
- [ ] spec-view.tsx (1,230 lines)
|
||||||
|
- [ ] kanban-card.tsx (1,180 lines)
|
||||||
|
- [ ] analysis-view.tsx (1,134 lines)
|
||||||
|
|
||||||
|
**Target:** All files under 500 lines, most under 300 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated: 2025-12-15*
|
||||||
18
apps/ui/.gitignore → apps/app/.gitignore
vendored
@@ -13,9 +13,12 @@
|
|||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
# Vite
|
# next.js
|
||||||
/dist/
|
/.next/
|
||||||
/dist-electron/
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -30,8 +33,12 @@ yarn-error.log*
|
|||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
# Playwright
|
# Playwright
|
||||||
/test-results/
|
/test-results/
|
||||||
@@ -40,8 +47,5 @@ yarn-error.log*
|
|||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
|
|
||||||
# Electron
|
# Electron
|
||||||
/release/
|
/dist/
|
||||||
/server-bundle/
|
/server-bundle/
|
||||||
|
|
||||||
# TanStack Router generated
|
|
||||||
src/routeTree.gen.ts
|
|
||||||
5
apps/app/electron/.eslintrc.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-require-imports": "off",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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.
|
* This version spawns the backend server and uses HTTP API for most operations.
|
||||||
* Only native features (dialogs, shell) use IPC.
|
* Only native features (dialogs, shell) use IPC.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import path from "path";
|
const path = require("path");
|
||||||
import { spawn, ChildProcess } from "child_process";
|
const { spawn } = require("child_process");
|
||||||
import fs from "fs";
|
const fs = require("fs");
|
||||||
import http, { Server } from "http";
|
const http = require("http");
|
||||||
import { app, BrowserWindow, ipcMain, dialog, shell } from "electron";
|
const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron");
|
||||||
|
|
||||||
// Development environment
|
|
||||||
const isDev = !app.isPackaged;
|
|
||||||
const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
|
|
||||||
|
|
||||||
// Load environment variables from .env file (development only)
|
// Load environment variables from .env file (development only)
|
||||||
if (isDev) {
|
if (!app.isPackaged) {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
require("dotenv").config({ path: path.join(__dirname, "../.env") });
|
require("dotenv").config({ path: path.join(__dirname, "../.env") });
|
||||||
} catch (error) {
|
} 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 mainWindow = null;
|
||||||
let serverProcess: ChildProcess | null = null;
|
let serverProcess = null;
|
||||||
let staticServer: Server | null = null;
|
let staticServer = null;
|
||||||
const SERVER_PORT = 3008;
|
const SERVER_PORT = 3008;
|
||||||
const STATIC_PORT = 3007;
|
const STATIC_PORT = 3007;
|
||||||
|
|
||||||
/**
|
// Get icon path - works in both dev and production, cross-platform
|
||||||
* Get icon path - works in both dev and production, cross-platform
|
function getIconPath() {
|
||||||
*/
|
// Different icon formats for different platforms
|
||||||
function getIconPath(): string | null {
|
let iconFile;
|
||||||
let iconFile: string;
|
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
iconFile = "icon.ico";
|
iconFile = "icon.ico";
|
||||||
} else if (process.platform === "darwin") {
|
} else if (process.platform === "darwin") {
|
||||||
iconFile = "logo_larger.png";
|
iconFile = "logo_larger.png";
|
||||||
} else {
|
} else {
|
||||||
|
// Linux
|
||||||
iconFile = "logo_larger.png";
|
iconFile = "logo_larger.png";
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconPath = isDev
|
const iconPath = path.join(__dirname, "../public", iconFile);
|
||||||
? path.join(__dirname, "../public", iconFile)
|
|
||||||
: path.join(__dirname, "../dist/public", iconFile);
|
|
||||||
|
|
||||||
|
// Verify the icon exists
|
||||||
if (!fs.existsSync(iconPath)) {
|
if (!fs.existsSync(iconPath)) {
|
||||||
console.warn(`[Electron] Icon not found at: ${iconPath}`);
|
console.warn(`[Electron] Icon not found at: ${iconPath}`);
|
||||||
return null;
|
return null;
|
||||||
@@ -59,29 +53,28 @@ function getIconPath(): string | null {
|
|||||||
/**
|
/**
|
||||||
* Start static file server for production builds
|
* Start static file server for production builds
|
||||||
*/
|
*/
|
||||||
async function startStaticServer(): Promise<void> {
|
async function startStaticServer() {
|
||||||
const staticPath = path.join(__dirname, "../dist");
|
const staticPath = path.join(__dirname, "../out");
|
||||||
|
|
||||||
staticServer = http.createServer((request, response) => {
|
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("/")) {
|
if (filePath.endsWith("/")) {
|
||||||
filePath = path.join(filePath, "index.html");
|
filePath = path.join(filePath, "index.html");
|
||||||
} else if (!path.extname(filePath)) {
|
} else if (!path.extname(filePath)) {
|
||||||
// For client-side routing, serve index.html for paths without extensions
|
filePath += ".html";
|
||||||
const possibleFile = filePath + ".html";
|
|
||||||
if (!fs.existsSync(filePath) && !fs.existsSync(possibleFile)) {
|
|
||||||
filePath = path.join(staticPath, "index.html");
|
|
||||||
} else if (fs.existsSync(possibleFile)) {
|
|
||||||
filePath = possibleFile;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
fs.stat(filePath, (err, stats) => {
|
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");
|
filePath = path.join(staticPath, "index.html");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read and serve the file
|
||||||
fs.readFile(filePath, (error, content) => {
|
fs.readFile(filePath, (error, content) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
response.writeHead(500);
|
response.writeHead(500);
|
||||||
@@ -89,8 +82,9 @@ async function startStaticServer(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set content type based on file extension
|
||||||
const ext = path.extname(filePath);
|
const ext = path.extname(filePath);
|
||||||
const contentTypes: Record<string, string> = {
|
const contentTypes = {
|
||||||
".html": "text/html",
|
".html": "text/html",
|
||||||
".js": "application/javascript",
|
".js": "application/javascript",
|
||||||
".css": "text/css",
|
".css": "text/css",
|
||||||
@@ -106,44 +100,53 @@ async function startStaticServer(): Promise<void> {
|
|||||||
".eot": "application/vnd.ms-fontobject",
|
".eot": "application/vnd.ms-fontobject",
|
||||||
};
|
};
|
||||||
|
|
||||||
response.writeHead(200, {
|
response.writeHead(200, { "Content-Type": contentTypes[ext] || "application/octet-stream" });
|
||||||
"Content-Type": contentTypes[ext] || "application/octet-stream",
|
|
||||||
});
|
|
||||||
response.end(content);
|
response.end(content);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
staticServer!.listen(STATIC_PORT, () => {
|
staticServer.listen(STATIC_PORT, (err) => {
|
||||||
console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`);
|
if (err) {
|
||||||
resolve();
|
reject(err);
|
||||||
|
} else {
|
||||||
|
console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
staticServer!.on("error", reject);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the backend server
|
* Start the backend server
|
||||||
*/
|
*/
|
||||||
async function startServer(): Promise<void> {
|
async function startServer() {
|
||||||
let command: string;
|
const isDev = !app.isPackaged;
|
||||||
let args: string[];
|
|
||||||
let serverPath: string;
|
|
||||||
|
|
||||||
|
// Server entry point - use tsx in dev, compiled version in production
|
||||||
|
let command, args, serverPath;
|
||||||
if (isDev) {
|
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";
|
command = "node";
|
||||||
serverPath = path.join(__dirname, "../../server/src/index.ts");
|
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");
|
const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx");
|
||||||
|
|
||||||
let tsxCliPath: string;
|
let tsxCliPath;
|
||||||
if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) {
|
if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) {
|
||||||
tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs");
|
tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs");
|
||||||
} else if (fs.existsSync(path.join(rootNodeModules, "dist/cli.mjs"))) {
|
} else if (fs.existsSync(path.join(rootNodeModules, "dist/cli.mjs"))) {
|
||||||
tsxCliPath = path.join(rootNodeModules, "dist/cli.mjs");
|
tsxCliPath = path.join(rootNodeModules, "dist/cli.mjs");
|
||||||
} else {
|
} else {
|
||||||
|
// Last resort: try require.resolve
|
||||||
try {
|
try {
|
||||||
tsxCliPath = require.resolve("tsx/cli.mjs", {
|
tsxCliPath = require.resolve("tsx/cli.mjs", {
|
||||||
paths: [path.join(__dirname, "../../server")],
|
paths: [path.join(__dirname, "../../server")],
|
||||||
@@ -157,21 +160,26 @@ async function startServer(): Promise<void> {
|
|||||||
|
|
||||||
args = [tsxCliPath, "watch", serverPath];
|
args = [tsxCliPath, "watch", serverPath];
|
||||||
} else {
|
} else {
|
||||||
|
// In production, use compiled JavaScript
|
||||||
command = "node";
|
command = "node";
|
||||||
serverPath = path.join(process.resourcesPath, "server", "index.js");
|
serverPath = path.join(process.resourcesPath, "server", "index.js");
|
||||||
args = [serverPath];
|
args = [serverPath];
|
||||||
|
|
||||||
|
// Verify server files exist
|
||||||
if (!fs.existsSync(serverPath)) {
|
if (!fs.existsSync(serverPath)) {
|
||||||
throw new Error(`Server not found at: ${serverPath}`);
|
throw new Error(`Server not found at: ${serverPath}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set environment variables for server
|
||||||
const serverNodeModules = app.isPackaged
|
const serverNodeModules = app.isPackaged
|
||||||
? path.join(process.resourcesPath, "server", "node_modules")
|
? path.join(process.resourcesPath, "server", "node_modules")
|
||||||
: path.join(__dirname, "../../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");
|
const defaultWorkspaceDir = path.join(app.getPath("documents"), "Automaker");
|
||||||
|
|
||||||
|
// Ensure workspace directory exists
|
||||||
if (!fs.existsSync(defaultWorkspaceDir)) {
|
if (!fs.existsSync(defaultWorkspaceDir)) {
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(defaultWorkspaceDir, { recursive: true });
|
fs.mkdirSync(defaultWorkspaceDir, { recursive: true });
|
||||||
@@ -199,11 +207,11 @@ async function startServer(): Promise<void> {
|
|||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
||||||
serverProcess.stdout?.on("data", (data) => {
|
serverProcess.stdout.on("data", (data) => {
|
||||||
console.log(`[Server] ${data.toString().trim()}`);
|
console.log(`[Server] ${data.toString().trim()}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
serverProcess.stderr?.on("data", (data) => {
|
serverProcess.stderr.on("data", (data) => {
|
||||||
console.error(`[Server Error] ${data.toString().trim()}`);
|
console.error(`[Server Error] ${data.toString().trim()}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,16 +225,19 @@ async function startServer(): Promise<void> {
|
|||||||
serverProcess = null;
|
serverProcess = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wait for server to be ready
|
||||||
await waitForServer();
|
await waitForServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for server to be available
|
* 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++) {
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const req = http.get(
|
const req = http.get(
|
||||||
`http://localhost:${SERVER_PORT}/api/health`,
|
`http://localhost:${SERVER_PORT}/api/health`,
|
||||||
(res) => {
|
(res) => {
|
||||||
@@ -256,13 +267,13 @@ async function waitForServer(maxAttempts = 30): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* Create the main window
|
* Create the main window
|
||||||
*/
|
*/
|
||||||
function createWindow(): void {
|
function createWindow() {
|
||||||
const iconPath = getIconPath();
|
const iconPath = getIconPath();
|
||||||
const windowOptions: Electron.BrowserWindowConstructorOptions = {
|
const windowOptions = {
|
||||||
width: 1600,
|
width: 1400,
|
||||||
height: 950,
|
height: 900,
|
||||||
minWidth: 1280,
|
minWidth: 1024,
|
||||||
minHeight: 768,
|
minHeight: 700,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, "preload.js"),
|
preload: path.join(__dirname, "preload.js"),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -272,22 +283,16 @@ function createWindow(): void {
|
|||||||
backgroundColor: "#0a0a0a",
|
backgroundColor: "#0a0a0a",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only set icon if it exists
|
||||||
if (iconPath) {
|
if (iconPath) {
|
||||||
windowOptions.icon = iconPath;
|
windowOptions.icon = iconPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
mainWindow = new BrowserWindow(windowOptions);
|
mainWindow = new BrowserWindow(windowOptions);
|
||||||
|
|
||||||
// Load Vite dev server in development or static server in production
|
// Load Next.js dev server in development or static server in production
|
||||||
if (VITE_DEV_SERVER_URL) {
|
const isDev = !app.isPackaged;
|
||||||
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
|
||||||
} else if (isDev) {
|
|
||||||
// Fallback for dev without Vite server URL
|
|
||||||
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
|
|
||||||
} else {
|
|
||||||
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDev && process.env.OPEN_DEVTOOLS === "true") {
|
if (isDev && process.env.OPEN_DEVTOOLS === "true") {
|
||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools();
|
||||||
}
|
}
|
||||||
@@ -296,6 +301,7 @@ function createWindow(): void {
|
|||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle external links - open in default browser
|
||||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
shell.openExternal(url);
|
shell.openExternal(url);
|
||||||
return { action: "deny" };
|
return { action: "deny" };
|
||||||
@@ -304,13 +310,14 @@ function createWindow(): void {
|
|||||||
|
|
||||||
// App lifecycle
|
// App lifecycle
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
// Set app icon (dock icon on macOS)
|
||||||
if (process.platform === "darwin" && app.dock) {
|
if (process.platform === "darwin" && app.dock) {
|
||||||
const iconPath = getIconPath();
|
const iconPath = getIconPath();
|
||||||
if (iconPath) {
|
if (iconPath) {
|
||||||
try {
|
try {
|
||||||
app.dock.setIcon(iconPath);
|
app.dock.setIcon(iconPath);
|
||||||
} catch (error) {
|
} 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", () => {
|
app.on("before-quit", () => {
|
||||||
|
// Kill server process
|
||||||
if (serverProcess) {
|
if (serverProcess) {
|
||||||
console.log("[Electron] Stopping server...");
|
console.log("[Electron] Stopping server...");
|
||||||
serverProcess.kill();
|
serverProcess.kill();
|
||||||
serverProcess = null;
|
serverProcess = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close static server
|
||||||
if (staticServer) {
|
if (staticServer) {
|
||||||
console.log("[Electron] Stopping static server...");
|
console.log("[Electron] Stopping static server...");
|
||||||
staticServer.close();
|
staticServer.close();
|
||||||
@@ -364,9 +373,6 @@ app.on("before-quit", () => {
|
|||||||
|
|
||||||
// Native file dialogs
|
// Native file dialogs
|
||||||
ipcMain.handle("dialog:openDirectory", async () => {
|
ipcMain.handle("dialog:openDirectory", async () => {
|
||||||
if (!mainWindow) {
|
|
||||||
return { canceled: true, filePaths: [] };
|
|
||||||
}
|
|
||||||
const result = await dialog.showOpenDialog(mainWindow, {
|
const result = await dialog.showOpenDialog(mainWindow, {
|
||||||
properties: ["openDirectory", "createDirectory"],
|
properties: ["openDirectory", "createDirectory"],
|
||||||
});
|
});
|
||||||
@@ -374,9 +380,6 @@ ipcMain.handle("dialog:openDirectory", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("dialog:openFile", async (_, options = {}) => {
|
ipcMain.handle("dialog:openFile", async (_, options = {}) => {
|
||||||
if (!mainWindow) {
|
|
||||||
return { canceled: true, filePaths: [] };
|
|
||||||
}
|
|
||||||
const result = await dialog.showOpenDialog(mainWindow, {
|
const result = await dialog.showOpenDialog(mainWindow, {
|
||||||
properties: ["openFile"],
|
properties: ["openFile"],
|
||||||
...options,
|
...options,
|
||||||
@@ -385,34 +388,31 @@ ipcMain.handle("dialog:openFile", async (_, options = {}) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("dialog:saveFile", async (_, options = {}) => {
|
ipcMain.handle("dialog:saveFile", async (_, options = {}) => {
|
||||||
if (!mainWindow) {
|
|
||||||
return { canceled: true, filePath: undefined };
|
|
||||||
}
|
|
||||||
const result = await dialog.showSaveDialog(mainWindow, options);
|
const result = await dialog.showSaveDialog(mainWindow, options);
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Shell operations
|
// Shell operations
|
||||||
ipcMain.handle("shell:openExternal", async (_, url: string) => {
|
ipcMain.handle("shell:openExternal", async (_, url) => {
|
||||||
try {
|
try {
|
||||||
await shell.openExternal(url);
|
await shell.openExternal(url);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
await shell.openPath(filePath);
|
await shell.openPath(filePath);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: (error as Error).message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// App info
|
// App info
|
||||||
ipcMain.handle("app:getPath", async (_, name: Parameters<typeof app.getPath>[0]) => {
|
ipcMain.handle("app:getPath", async (_, name) => {
|
||||||
return app.getPath(name);
|
return app.getPath(name);
|
||||||
});
|
});
|
||||||
|
|
||||||
37
apps/app/electron/preload.js
Normal 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)");
|
||||||
20
apps/app/eslint.config.mjs
Normal 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
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "export",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/ui",
|
"name": "@automaker/app",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||||
@@ -13,29 +13,25 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"main": "dist-electron/main.js",
|
"main": "electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "next dev -p 3007",
|
||||||
"dev:web": "cross-env VITE_SKIP_ELECTRON=true vite",
|
"dev:web": "next dev -p 3007",
|
||||||
"dev:electron": "vite",
|
"dev:electron": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron .\"",
|
||||||
"dev:electron:debug": "cross-env OPEN_DEVTOOLS=true vite",
|
"dev:electron:debug": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && OPEN_DEVTOOLS=true electron .\"",
|
||||||
"build": "vite build",
|
"build": "next build",
|
||||||
"build:electron": "node scripts/prepare-server.mjs && vite build && electron-builder",
|
"build:electron": "node scripts/prepare-server.js && next build && electron-builder",
|
||||||
"build:electron:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --dir",
|
"build:electron:win": "node scripts/prepare-server.js && next build && electron-builder --win",
|
||||||
"build:electron:win": "node scripts/prepare-server.mjs && vite build && electron-builder --win",
|
"build:electron:mac": "node scripts/prepare-server.js && next build && electron-builder --mac",
|
||||||
"build:electron:win:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --win --dir",
|
"build:electron:linux": "node scripts/prepare-server.js && next build && electron-builder --linux",
|
||||||
"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",
|
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"preview": "vite preview",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"pretest": "node scripts/setup-e2e-fixtures.mjs",
|
"pretest": "node scripts/setup-e2e-fixtures.js",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
"test:headed": "playwright test --headed",
|
"test:headed": "playwright test --headed",
|
||||||
"dev:electron:wsl": "cross-env vite",
|
"dev:electron:wsl": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron . --no-sandbox --disable-gpu\"",
|
||||||
"dev:electron:wsl:gpu": "cross-env MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA vite"
|
"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": {
|
"dependencies": {
|
||||||
"@codemirror/lang-xml": "^6.1.0",
|
"@codemirror/lang-xml": "^6.1.0",
|
||||||
@@ -49,15 +45,12 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@tanstack/react-router": "^1.141.6",
|
|
||||||
"@uiw/react-codemirror": "^4.25.4",
|
"@uiw/react-codemirror": "^4.25.4",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-webgl": "^0.18.0",
|
"@xterm/addon-webgl": "^0.18.0",
|
||||||
@@ -67,9 +60,10 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"geist": "^1.5.1",
|
"geist": "^1.5.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.556.0",
|
||||||
"react": "19.2.3",
|
"next": "^16.0.10",
|
||||||
"react-dom": "19.2.3",
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -89,39 +83,32 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron/rebuild": "^4.0.2",
|
"@electron/rebuild": "^4.0.2",
|
||||||
"@eslint/js": "^9.0.0",
|
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tanstack/router-plugin": "^1.141.7",
|
"@types/node": "^20",
|
||||||
"@types/node": "^22",
|
"@types/react": "^19",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react-dom": "^19",
|
||||||
"@types/react-dom": "^19.2.3",
|
"concurrently": "^9.2.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.50.0",
|
|
||||||
"@typescript-eslint/parser": "^8.50.0",
|
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
|
||||||
"cross-env": "^10.1.0",
|
|
||||||
"electron": "39.2.7",
|
"electron": "39.2.7",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9",
|
||||||
"tailwindcss": "^4.1.18",
|
"eslint-config-next": "16.0.7",
|
||||||
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vite": "^7.3.0",
|
"wait-on": "^9.0.3"
|
||||||
"vite-plugin-electron": "^0.29.0",
|
|
||||||
"vite-plugin-electron-renderer": "^0.14.6"
|
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.automaker.app",
|
"appId": "com.automaker.app",
|
||||||
"productName": "Automaker",
|
"productName": "Automaker",
|
||||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||||
"npmRebuild": false,
|
"afterPack": "./scripts/rebuild-server-natives.js",
|
||||||
"afterPack": "./scripts/rebuild-server-natives.cjs",
|
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "dist"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"electron/**/*",
|
||||||
"dist-electron/**/*",
|
"out/**/*",
|
||||||
"public/**/*",
|
"public/**/*",
|
||||||
"!node_modules/**/*"
|
"!node_modules/**/*"
|
||||||
],
|
],
|
||||||
@@ -3,15 +3,14 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
const port = process.env.TEST_PORT || 3007;
|
const port = process.env.TEST_PORT || 3007;
|
||||||
const serverPort = process.env.TEST_SERVER_PORT || 3008;
|
const serverPort = process.env.TEST_SERVER_PORT || 3008;
|
||||||
const reuseServer = process.env.TEST_REUSE_SERVER === "true";
|
const reuseServer = process.env.TEST_REUSE_SERVER === "true";
|
||||||
const mockAgent =
|
const mockAgent = process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true";
|
||||||
process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true";
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests",
|
testDir: "./tests",
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
reporter: "html",
|
reporter: "html",
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
use: {
|
use: {
|
||||||
@@ -44,17 +43,15 @@ export default defineConfig({
|
|||||||
ALLOWED_PROJECT_DIRS: "/Users,/home,/tmp,/var/folders",
|
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}`,
|
url: `http://localhost:${port}`,
|
||||||
reuseExistingServer: true,
|
reuseExistingServer: true,
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
VITE_SKIP_SETUP: "true",
|
NEXT_PUBLIC_SKIP_SETUP: "true",
|
||||||
// Skip electron plugin in CI - no display available for Electron
|
|
||||||
VITE_SKIP_ELECTRON: process.env.CI === "true" ? "true" : undefined,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
7
apps/app/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 391 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 317 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 128 B After Width: | Height: | Size: 128 B |
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 385 B |
@@ -12,7 +12,7 @@ import { fileURLToPath } from "url";
|
|||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
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 WORKSPACE_ROOT = path.resolve(__dirname, "../../..");
|
||||||
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, "test/fixtures/projectA");
|
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, "test/fixtures/projectA");
|
||||||
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, ".automaker/app_spec.txt");
|
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, ".automaker/app_spec.txt");
|
||||||
97
apps/app/src/app/api/claude/test/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
191
apps/app/src/app/api/gemini/test/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
apps/app/src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
5189
apps/app/src/app/globals.css
Normal file
42
apps/app/src/app/layout.tsx
Normal 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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import { ImageIcon, Upload, Loader2, Trash2 } from "lucide-react";
|
import { ImageIcon, Upload, Loader2, Trash2 } from "lucide-react";
|
||||||
@@ -71,7 +72,7 @@ export function BoardBackgroundModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentProject && backgroundSettings.imagePath) {
|
if (currentProject && backgroundSettings.imagePath) {
|
||||||
const serverUrl =
|
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
|
// Add cache-busting query parameter to force browser to reload image
|
||||||
const cacheBuster = imageVersion
|
const cacheBuster = imageVersion
|
||||||
? `&v=${imageVersion}`
|
? `&v=${imageVersion}`
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -128,7 +129,7 @@ export function FileBrowserDialog({
|
|||||||
try {
|
try {
|
||||||
// Get server URL from environment or default
|
// Get server URL from environment or default
|
||||||
const serverUrl =
|
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`, {
|
const response = await fetch(`${serverUrl}/api/fs/browse`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Sparkles, Clock, Loader2 } from "lucide-react";
|
"use client";
|
||||||
|
|
||||||
|
import { Sparkles, Clock } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -8,49 +10,66 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { cn } from "@/lib/utils";
|
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,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
projectOverview,
|
projectOverview,
|
||||||
onProjectOverviewChange,
|
onProjectOverviewChange,
|
||||||
generateFeatures,
|
generateFeatures,
|
||||||
onGenerateFeaturesChange,
|
onGenerateFeaturesChange,
|
||||||
analyzeProject,
|
|
||||||
onAnalyzeProjectChange,
|
|
||||||
featureCount,
|
featureCount,
|
||||||
onFeatureCountChange,
|
onFeatureCountChange,
|
||||||
onCreateSpec,
|
onCreateSpec,
|
||||||
onSkip,
|
onSkip,
|
||||||
isCreatingSpec,
|
isCreatingSpec,
|
||||||
showSkipButton = false,
|
}: ProjectSetupDialogProps) {
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
onOpenChange(open);
|
onOpenChange(open);
|
||||||
if (!open && !isCreatingSpec && onSkip) {
|
if (!open && !isCreatingSpec) {
|
||||||
onSkip();
|
onSkip();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>Set Up Your Project</DialogTitle>
|
||||||
<DialogDescription className="text-muted-foreground">
|
<DialogDescription className="text-muted-foreground">
|
||||||
{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.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -68,52 +87,21 @@ export function CreateSpecDialog({
|
|||||||
onChange={(e) => onProjectOverviewChange(e.target.value)}
|
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..."
|
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
|
autoFocus
|
||||||
disabled={isCreatingSpec}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start space-x-3 pt-2">
|
<div className="flex items-start space-x-3 pt-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="create-analyze-project"
|
id="sidebar-generate-features"
|
||||||
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"
|
|
||||||
checked={generateFeatures}
|
checked={generateFeatures}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
onGenerateFeaturesChange(checked === true)
|
onGenerateFeaturesChange(checked === true)
|
||||||
}
|
}
|
||||||
disabled={isCreatingSpec}
|
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="create-generate-features"
|
htmlFor="sidebar-generate-features"
|
||||||
className={`text-sm font-medium ${
|
className="text-sm font-medium cursor-pointer"
|
||||||
isCreatingSpec ? "" : "cursor-pointer"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
Generate feature list
|
Generate feature list
|
||||||
</label>
|
</label>
|
||||||
@@ -137,10 +125,7 @@ export function CreateSpecDialog({
|
|||||||
featureCount === option.value ? "default" : "outline"
|
featureCount === option.value ? "default" : "outline"
|
||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => onFeatureCountChange(option.value)}
|
||||||
onFeatureCountChange(option.value as FeatureCount)
|
|
||||||
}
|
|
||||||
disabled={isCreatingSpec}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 transition-all",
|
"flex-1 transition-all",
|
||||||
featureCount === option.value
|
featureCount === option.value
|
||||||
@@ -153,10 +138,14 @@ export function CreateSpecDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{selectedOption?.warning && (
|
{FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount)
|
||||||
|
?.warning && (
|
||||||
<p className="text-xs text-amber-500 flex items-center gap-1">
|
<p className="text-xs text-amber-500 flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{selectedOption.warning}
|
{
|
||||||
|
FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount)
|
||||||
|
?.warning
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -164,37 +153,13 @@ export function CreateSpecDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{showSkipButton && onSkip ? (
|
<Button variant="ghost" onClick={onSkip}>
|
||||||
<Button variant="ghost" onClick={onSkip} disabled={isCreatingSpec}>
|
Skip for now
|
||||||
Skip for now
|
</Button>
|
||||||
</Button>
|
<Button onClick={onCreateSpec} disabled={!projectOverview.trim()}>
|
||||||
) : (
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
<Button
|
Generate Spec
|
||||||
variant="ghost"
|
</Button>
|
||||||
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>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||||
import { useNavigate, useLocation } from "@tanstack/react-router";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useAppStore, formatShortcut, type ThemeMode } from "@/store/app-store";
|
import { useAppStore, formatShortcut, type ThemeMode } from "@/store/app-store";
|
||||||
|
import { CoursePromoBadge } from "@/components/ui/course-promo-badge";
|
||||||
import {
|
import {
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -80,8 +82,10 @@ import { themeOptions } from "@/config/theme-options";
|
|||||||
import type { SpecRegenerationEvent } from "@/types/electron";
|
import type { SpecRegenerationEvent } from "@/types/electron";
|
||||||
import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog";
|
import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog";
|
||||||
import { NewProjectModal } from "@/components/new-project-modal";
|
import { NewProjectModal } from "@/components/new-project-modal";
|
||||||
import { CreateSpecDialog } from "@/components/views/spec-view/dialogs";
|
import {
|
||||||
import type { FeatureCount } from "@/components/views/spec-view/types";
|
ProjectSetupDialog,
|
||||||
|
type FeatureCount,
|
||||||
|
} from "@/components/layout/project-setup-dialog";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
@@ -219,17 +223,16 @@ const BugReportButton = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
projects,
|
projects,
|
||||||
trashedProjects,
|
trashedProjects,
|
||||||
currentProject,
|
currentProject,
|
||||||
|
currentView,
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
projectHistory,
|
projectHistory,
|
||||||
upsertAndSetCurrentProject,
|
upsertAndSetCurrentProject,
|
||||||
setCurrentProject,
|
setCurrentProject,
|
||||||
|
setCurrentView,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
restoreTrashedProject,
|
restoreTrashedProject,
|
||||||
deleteTrashedProject,
|
deleteTrashedProject,
|
||||||
@@ -248,13 +251,14 @@ export function Sidebar() {
|
|||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
// Environment variable flags for hiding sidebar items
|
// Environment variable flags for hiding sidebar items
|
||||||
const hideTerminal = import.meta.env.VITE_HIDE_TERMINAL === "true";
|
// Note: Next.js requires static access to process.env variables (no dynamic keys)
|
||||||
const hideWiki = import.meta.env.VITE_HIDE_WIKI === "true";
|
const hideTerminal = process.env.NEXT_PUBLIC_HIDE_TERMINAL === "true";
|
||||||
|
const hideWiki = process.env.NEXT_PUBLIC_HIDE_WIKI === "true";
|
||||||
const hideRunningAgents =
|
const hideRunningAgents =
|
||||||
import.meta.env.VITE_HIDE_RUNNING_AGENTS === "true";
|
process.env.NEXT_PUBLIC_HIDE_RUNNING_AGENTS === "true";
|
||||||
const hideContext = import.meta.env.VITE_HIDE_CONTEXT === "true";
|
const hideContext = process.env.NEXT_PUBLIC_HIDE_CONTEXT === "true";
|
||||||
const hideSpecEditor = import.meta.env.VITE_HIDE_SPEC_EDITOR === "true";
|
const hideSpecEditor = process.env.NEXT_PUBLIC_HIDE_SPEC_EDITOR === "true";
|
||||||
const hideAiProfiles = import.meta.env.VITE_HIDE_AI_PROFILES === "true";
|
const hideAiProfiles = process.env.NEXT_PUBLIC_HIDE_AI_PROFILES === "true";
|
||||||
|
|
||||||
// Get customizable keyboard shortcuts
|
// Get customizable keyboard shortcuts
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
@@ -287,7 +291,6 @@ export function Sidebar() {
|
|||||||
const [setupProjectPath, setSetupProjectPath] = useState("");
|
const [setupProjectPath, setSetupProjectPath] = useState("");
|
||||||
const [projectOverview, setProjectOverview] = useState("");
|
const [projectOverview, setProjectOverview] = useState("");
|
||||||
const [generateFeatures, setGenerateFeatures] = useState(true);
|
const [generateFeatures, setGenerateFeatures] = useState(true);
|
||||||
const [analyzeProject, setAnalyzeProject] = useState(true);
|
|
||||||
const [featureCount, setFeatureCount] = useState<FeatureCount>(50);
|
const [featureCount, setFeatureCount] = useState<FeatureCount>(50);
|
||||||
const [showSpecIndicator, setShowSpecIndicator] = useState(true);
|
const [showSpecIndicator, setShowSpecIndicator] = useState(true);
|
||||||
|
|
||||||
@@ -426,6 +429,7 @@ export function Sidebar() {
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
setCurrentView,
|
||||||
creatingSpecProjectPath,
|
creatingSpecProjectPath,
|
||||||
setupProjectPath,
|
setupProjectPath,
|
||||||
setSpecCreatingForProject,
|
setSpecCreatingForProject,
|
||||||
@@ -494,7 +498,7 @@ export function Sidebar() {
|
|||||||
setupProjectPath,
|
setupProjectPath,
|
||||||
projectOverview.trim(),
|
projectOverview.trim(),
|
||||||
generateFeatures,
|
generateFeatures,
|
||||||
analyzeProject,
|
undefined, // analyzeProject - use default
|
||||||
generateFeatures ? featureCount : undefined // only pass maxFeatures if generating features
|
generateFeatures ? featureCount : undefined // only pass maxFeatures if generating features
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -523,7 +527,6 @@ export function Sidebar() {
|
|||||||
setupProjectPath,
|
setupProjectPath,
|
||||||
projectOverview,
|
projectOverview,
|
||||||
generateFeatures,
|
generateFeatures,
|
||||||
analyzeProject,
|
|
||||||
featureCount,
|
featureCount,
|
||||||
setSpecCreatingForProject,
|
setSpecCreatingForProject,
|
||||||
]);
|
]);
|
||||||
@@ -1174,7 +1177,7 @@ export function Sidebar() {
|
|||||||
if (item.shortcut) {
|
if (item.shortcut) {
|
||||||
shortcutsList.push({
|
shortcutsList.push({
|
||||||
key: item.shortcut,
|
key: item.shortcut,
|
||||||
action: () => navigate({ to: `/${item.id}` as const }),
|
action: () => setCurrentView(item.id as any),
|
||||||
description: `Navigate to ${item.label}`,
|
description: `Navigate to ${item.label}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1184,7 +1187,7 @@ export function Sidebar() {
|
|||||||
// Add settings shortcut
|
// Add settings shortcut
|
||||||
shortcutsList.push({
|
shortcutsList.push({
|
||||||
key: shortcuts.settings,
|
key: shortcuts.settings,
|
||||||
action: () => navigate({ to: "/settings" }),
|
action: () => setCurrentView("settings"),
|
||||||
description: "Navigate to Settings",
|
description: "Navigate to Settings",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1193,7 +1196,7 @@ export function Sidebar() {
|
|||||||
}, [
|
}, [
|
||||||
shortcuts,
|
shortcuts,
|
||||||
currentProject,
|
currentProject,
|
||||||
navigate,
|
setCurrentView,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
projects.length,
|
projects.length,
|
||||||
handleOpenFolder,
|
handleOpenFolder,
|
||||||
@@ -1207,15 +1210,15 @@ export function Sidebar() {
|
|||||||
useKeyboardShortcuts(navigationShortcuts);
|
useKeyboardShortcuts(navigationShortcuts);
|
||||||
|
|
||||||
const isActiveRoute = (id: string) => {
|
const isActiveRoute = (id: string) => {
|
||||||
// Map view IDs to route paths
|
return currentView === id;
|
||||||
const routePath = id === "welcome" ? "/" : `/${id}`;
|
|
||||||
return location.pathname === routePath;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-shrink-0 flex flex-col z-30 relative",
|
"flex-shrink-0 flex flex-col z-30 relative",
|
||||||
|
// Clean theme sidebar-glass class
|
||||||
|
"sidebar-glass",
|
||||||
// Glass morphism background with gradient
|
// Glass morphism background with gradient
|
||||||
"bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl",
|
"bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl",
|
||||||
// Premium border with subtle glow
|
// Premium border with subtle glow
|
||||||
@@ -1288,7 +1291,7 @@ export function Sidebar() {
|
|||||||
"flex items-center gap-3 titlebar-no-drag cursor-pointer group",
|
"flex items-center gap-3 titlebar-no-drag cursor-pointer group",
|
||||||
!sidebarOpen && "flex-col gap-1"
|
!sidebarOpen && "flex-col gap-1"
|
||||||
)}
|
)}
|
||||||
onClick={() => navigate({ to: "/" })}
|
onClick={() => setCurrentView("welcome")}
|
||||||
data-testid="logo-button"
|
data-testid="logo-button"
|
||||||
>
|
>
|
||||||
{!sidebarOpen ? (
|
{!sidebarOpen ? (
|
||||||
@@ -1846,13 +1849,15 @@ export function Sidebar() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => navigate({ to: `/${item.id}` as const })}
|
onClick={() => setCurrentView(item.id as any)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag",
|
"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",
|
"transition-all duration-200 ease-out",
|
||||||
isActive
|
isActive
|
||||||
? [
|
? [
|
||||||
// Active: Premium gradient with glow
|
// Active: Premium gradient with glow
|
||||||
|
// Clean theme nav-active class
|
||||||
|
"nav-active",
|
||||||
"bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10",
|
"bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10",
|
||||||
"text-foreground font-medium",
|
"text-foreground font-medium",
|
||||||
"border border-brand-500/30",
|
"border border-brand-500/30",
|
||||||
@@ -1871,6 +1876,9 @@ export function Sidebar() {
|
|||||||
title={!sidebarOpen ? item.label : undefined}
|
title={!sidebarOpen ? item.label : undefined}
|
||||||
data-testid={`nav-${item.id}`}
|
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
|
<Icon
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-[18px] h-[18px] shrink-0 transition-all duration-200",
|
"w-[18px] h-[18px] shrink-0 transition-all duration-200",
|
||||||
@@ -1890,6 +1898,8 @@ export function Sidebar() {
|
|||||||
{item.shortcut && sidebarOpen && (
|
{item.shortcut && sidebarOpen && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
// Clean theme shortcut-badge class
|
||||||
|
"shortcut-badge",
|
||||||
"hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200",
|
"hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200",
|
||||||
isActive
|
isActive
|
||||||
? "bg-brand-500/20 text-brand-400"
|
? "bg-brand-500/20 text-brand-400"
|
||||||
@@ -1915,7 +1925,7 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
{item.shortcut && (
|
{item.shortcut && (
|
||||||
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
<span className="shortcut-badge ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
||||||
{formatShortcut(item.shortcut, true)}
|
{formatShortcut(item.shortcut, true)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1941,11 +1951,13 @@ export function Sidebar() {
|
|||||||
"bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent"
|
"bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* Course Promo Badge */}
|
||||||
|
<CoursePromoBadge sidebarOpen={sidebarOpen} />
|
||||||
{/* Wiki Link */}
|
{/* Wiki Link */}
|
||||||
{!hideWiki && (
|
{!hideWiki && (
|
||||||
<div className="p-2 pb-0">
|
<div className="p-2 pb-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: "/wiki" })}
|
onClick={() => setCurrentView("wiki")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag",
|
"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",
|
"transition-all duration-200 ease-out",
|
||||||
@@ -1968,6 +1980,9 @@ export function Sidebar() {
|
|||||||
title={!sidebarOpen ? "Wiki" : undefined}
|
title={!sidebarOpen ? "Wiki" : undefined}
|
||||||
data-testid="wiki-link"
|
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
|
<BookOpen
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-[18px] h-[18px] shrink-0 transition-all duration-200",
|
"w-[18px] h-[18px] shrink-0 transition-all duration-200",
|
||||||
@@ -2005,7 +2020,7 @@ export function Sidebar() {
|
|||||||
{!hideRunningAgents && (
|
{!hideRunningAgents && (
|
||||||
<div className="p-2 pb-0">
|
<div className="p-2 pb-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: "/running-agents" })}
|
onClick={() => setCurrentView("running-agents")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag",
|
"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",
|
"transition-all duration-200 ease-out",
|
||||||
@@ -2028,6 +2043,9 @@ export function Sidebar() {
|
|||||||
title={!sidebarOpen ? "Running Agents" : undefined}
|
title={!sidebarOpen ? "Running Agents" : undefined}
|
||||||
data-testid="running-agents-link"
|
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">
|
<div className="relative">
|
||||||
<Activity
|
<Activity
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -2041,6 +2059,8 @@ export function Sidebar() {
|
|||||||
{!sidebarOpen && runningAgentsCount > 0 && (
|
{!sidebarOpen && runningAgentsCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
// Clean theme running-agents-badge class
|
||||||
|
"running-agents-badge",
|
||||||
"absolute -top-1.5 -right-1.5 flex items-center justify-center",
|
"absolute -top-1.5 -right-1.5 flex items-center justify-center",
|
||||||
"min-w-4 h-4 px-1 text-[9px] font-bold rounded-full",
|
"min-w-4 h-4 px-1 text-[9px] font-bold rounded-full",
|
||||||
"bg-brand-500 text-white shadow-sm",
|
"bg-brand-500 text-white shadow-sm",
|
||||||
@@ -2064,6 +2084,8 @@ export function Sidebar() {
|
|||||||
{sidebarOpen && runningAgentsCount > 0 && (
|
{sidebarOpen && runningAgentsCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
// Clean theme running-agents-badge class
|
||||||
|
"running-agents-badge",
|
||||||
"hidden lg:flex items-center justify-center",
|
"hidden lg:flex items-center justify-center",
|
||||||
"min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full",
|
"min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full",
|
||||||
"bg-brand-500 text-white shadow-sm",
|
"bg-brand-500 text-white shadow-sm",
|
||||||
@@ -2100,7 +2122,7 @@ export function Sidebar() {
|
|||||||
{/* Settings Link */}
|
{/* Settings Link */}
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: "/settings" })}
|
onClick={() => setCurrentView("settings")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag",
|
"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",
|
"transition-all duration-200 ease-out",
|
||||||
@@ -2123,6 +2145,9 @@ export function Sidebar() {
|
|||||||
title={!sidebarOpen ? "Settings" : undefined}
|
title={!sidebarOpen ? "Settings" : undefined}
|
||||||
data-testid="settings-button"
|
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
|
<Settings
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-[18px] h-[18px] shrink-0 transition-all duration-200",
|
"w-[18px] h-[18px] shrink-0 transition-all duration-200",
|
||||||
@@ -2261,23 +2286,18 @@ export function Sidebar() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* New Project Setup Dialog */}
|
{/* New Project Setup Dialog */}
|
||||||
<CreateSpecDialog
|
<ProjectSetupDialog
|
||||||
open={showSetupDialog}
|
open={showSetupDialog}
|
||||||
onOpenChange={setShowSetupDialog}
|
onOpenChange={setShowSetupDialog}
|
||||||
projectOverview={projectOverview}
|
projectOverview={projectOverview}
|
||||||
onProjectOverviewChange={setProjectOverview}
|
onProjectOverviewChange={setProjectOverview}
|
||||||
generateFeatures={generateFeatures}
|
generateFeatures={generateFeatures}
|
||||||
onGenerateFeaturesChange={setGenerateFeatures}
|
onGenerateFeaturesChange={setGenerateFeatures}
|
||||||
analyzeProject={analyzeProject}
|
|
||||||
onAnalyzeProjectChange={setAnalyzeProject}
|
|
||||||
featureCount={featureCount}
|
featureCount={featureCount}
|
||||||
onFeatureCountChange={setFeatureCount}
|
onFeatureCountChange={setFeatureCount}
|
||||||
onCreateSpec={handleCreateInitialSpec}
|
onCreateSpec={handleCreateInitialSpec}
|
||||||
onSkip={handleSkipSetup}
|
onSkip={handleSkipSetup}
|
||||||
isCreatingSpec={isCreatingSpec}
|
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 */}
|
{/* New Project Onboarding Dialog */}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -110,10 +116,8 @@ export function SessionManager({
|
|||||||
new Set()
|
new Set()
|
||||||
);
|
);
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [sessionToDelete, setSessionToDelete] =
|
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
|
||||||
useState<SessionListItem | null>(null);
|
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false);
|
||||||
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] =
|
|
||||||
useState(false);
|
|
||||||
|
|
||||||
// Check running state for all sessions
|
// Check running state for all sessions
|
||||||
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
||||||
@@ -230,7 +234,11 @@ export function SessionManager({
|
|||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!editingName.trim() || !api?.sessions) return;
|
if (!editingName.trim() || !api?.sessions) return;
|
||||||
|
|
||||||
const result = await api.sessions.update(sessionId, editingName, undefined);
|
const result = await api.sessions.update(
|
||||||
|
sessionId,
|
||||||
|
editingName,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setEditingSessionId(null);
|
setEditingSessionId(null);
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Check, ChevronsUpDown, LucideIcon } from "lucide-react";
|
import { Check, ChevronsUpDown, LucideIcon } from "lucide-react";
|
||||||
@@ -34,7 +35,6 @@ interface AutocompleteProps {
|
|||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
error?: boolean;
|
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
allowCreate?: boolean;
|
allowCreate?: boolean;
|
||||||
createLabel?: (value: string) => string;
|
createLabel?: (value: string) => string;
|
||||||
@@ -58,7 +58,6 @@ export function Autocomplete({
|
|||||||
emptyMessage = "No results found.",
|
emptyMessage = "No results found.",
|
||||||
className,
|
className,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
error = false,
|
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
allowCreate = false,
|
allowCreate = false,
|
||||||
createLabel = (v) => `Create "${v}"`,
|
createLabel = (v) => `Create "${v}"`,
|
||||||
@@ -131,7 +130,6 @@ export function Autocomplete({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between",
|
"w-full justify-between",
|
||||||
Icon && "font-mono text-sm",
|
Icon && "font-mono text-sm",
|
||||||
error && "border-destructive focus-visible:ring-destructive",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { GitBranch } from "lucide-react";
|
import { GitBranch } from "lucide-react";
|
||||||
@@ -7,11 +8,9 @@ interface BranchAutocompleteProps {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
branches: string[];
|
branches: string[];
|
||||||
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
error?: boolean;
|
|
||||||
"data-testid"?: string;
|
"data-testid"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,32 +18,20 @@ export function BranchAutocomplete({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
branches,
|
branches,
|
||||||
branchCardCounts,
|
|
||||||
placeholder = "Select a branch...",
|
placeholder = "Select a branch...",
|
||||||
className,
|
className,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
error = false,
|
|
||||||
"data-testid": testId,
|
"data-testid": testId,
|
||||||
}: BranchAutocompleteProps) {
|
}: BranchAutocompleteProps) {
|
||||||
// Always include "main" at the top of suggestions
|
// Always include "main" at the top of suggestions
|
||||||
const branchOptions: AutocompleteOption[] = React.useMemo(() => {
|
const branchOptions: AutocompleteOption[] = React.useMemo(() => {
|
||||||
const branchSet = new Set(["main", ...branches]);
|
const branchSet = new Set(["main", ...branches]);
|
||||||
return Array.from(branchSet).map((branch) => {
|
return Array.from(branchSet).map((branch) => ({
|
||||||
const cardCount = branchCardCounts?.[branch];
|
value: branch,
|
||||||
// Show card count if available, otherwise show "default" for main branch only
|
label: branch,
|
||||||
const badge = branchCardCounts !== undefined
|
badge: branch === "main" ? "default" : undefined,
|
||||||
? String(cardCount ?? 0)
|
}));
|
||||||
: branch === "main"
|
}, [branches]);
|
||||||
? "default"
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
value: branch,
|
|
||||||
label: branch,
|
|
||||||
badge,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [branches, branchCardCounts]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
@@ -56,7 +43,6 @@ export function BranchAutocomplete({
|
|||||||
emptyMessage="No branches found."
|
emptyMessage="No branches found."
|
||||||
className={className}
|
className={className}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
error={error}
|
|
||||||
icon={GitBranch}
|
icon={GitBranch}
|
||||||
allowCreate
|
allowCreate
|
||||||
createLabel={(v) => `Create "${v}"`}
|
createLabel={(v) => `Create "${v}"`}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Autocomplete } from "@/components/ui/autocomplete";
|
import { Autocomplete } from "@/components/ui/autocomplete";
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Command as CommandPrimitive } from "cmdk"
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Clock } from "lucide-react";
|
import { Clock } from "lucide-react";
|
||||||
88
apps/app/src/components/ui/course-promo-badge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback } from "react";
|
import React, { useState, useRef, useCallback } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -84,7 +85,7 @@ export function DescriptionImageDropZone({
|
|||||||
|
|
||||||
// Construct server URL for loading saved images
|
// Construct server URL for loading saved images
|
||||||
const getImageServerUrl = useCallback((imagePath: string): string => {
|
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 || "";
|
const projectPath = currentProject?.path || "";
|
||||||
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
|
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
|
||||||
}, [currentProject?.path]);
|
}, [currentProject?.path]);
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
@@ -86,18 +87,16 @@ function DialogOverlay({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DialogContentProps = Omit<
|
function DialogContent({
|
||||||
React.ComponentProps<typeof DialogPrimitive.Content>,
|
className,
|
||||||
"ref"
|
children,
|
||||||
> & {
|
showCloseButton = true,
|
||||||
|
compact = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
};
|
}) {
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
DialogContentProps
|
|
||||||
>(({ className, children, showCloseButton = true, compact = false, ...props }, ref) => {
|
|
||||||
// Check if className contains a custom max-width
|
// Check if className contains a custom max-width
|
||||||
const hasCustomMaxWidth =
|
const hasCustomMaxWidth =
|
||||||
typeof className === "string" && className.includes("max-w-");
|
typeof className === "string" && className.includes("max-w-");
|
||||||
@@ -106,7 +105,6 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogContentPrimitive
|
<DialogContentPrimitive
|
||||||
ref={ref}
|
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
|
"fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
|
||||||
@@ -150,9 +148,7 @@ const DialogContent = React.forwardRef<
|
|||||||
</DialogContentPrimitive>
|
</DialogContentPrimitive>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
DialogContent.displayName = "DialogContent";
|
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback } from "react";
|
import React, { useState, useRef, useCallback } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useEffect, useCallback, useRef } from "react";
|
import { useEffect, useCallback, useRef } from "react";
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback } from "react";
|
import React, { useState, useRef, useCallback } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, parseShortcut, formatShortcut } from "@/store/app-store";
|
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, parseShortcut, formatShortcut } from "@/store/app-store";
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useEffect, useRef } from "react";
|
import { useState, useMemo, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -24,7 +25,6 @@ import {
|
|||||||
Circle,
|
Circle,
|
||||||
Play,
|
Play,
|
||||||
Loader2,
|
Loader2,
|
||||||
Coins,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -35,35 +35,10 @@ import {
|
|||||||
type LogEntryType,
|
type LogEntryType,
|
||||||
type ToolCategory,
|
type ToolCategory,
|
||||||
} from "@/lib/log-parser";
|
} from "@/lib/log-parser";
|
||||||
import type { TokenUsage } from "@/store/app-store";
|
|
||||||
|
|
||||||
interface LogViewerProps {
|
interface LogViewerProps {
|
||||||
output: string;
|
output: string;
|
||||||
className?: 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) => {
|
const getLogIcon = (type: LogEntryType) => {
|
||||||
@@ -351,7 +326,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border transition-all duration-200",
|
"rounded-lg border-l-4 transition-all duration-200",
|
||||||
bgColor,
|
bgColor,
|
||||||
borderColor,
|
borderColor,
|
||||||
"hover:brightness-110"
|
"hover:brightness-110"
|
||||||
@@ -405,7 +380,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
|
|||||||
{formattedContent.map((part, index) => (
|
{formattedContent.map((part, index) => (
|
||||||
<div key={index}>
|
<div key={index}>
|
||||||
{part.type === "json" ? (
|
{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}
|
{part.content}
|
||||||
</pre>
|
</pre>
|
||||||
) : (
|
) : (
|
||||||
@@ -439,13 +414,11 @@ interface ToolCategoryStats {
|
|||||||
other: number;
|
other: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LogViewer({ output, className, tokenUsage }: LogViewerProps) {
|
export function LogViewer({ output, className }: LogViewerProps) {
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [hiddenTypes, setHiddenTypes] = useState<Set<LogEntryType>>(new Set());
|
const [hiddenTypes, setHiddenTypes] = useState<Set<LogEntryType>>(new Set());
|
||||||
const [hiddenCategories, setHiddenCategories] = useState<Set<ToolCategory>>(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
|
// Parse entries and compute initial expanded state together
|
||||||
const { entries, initialExpandedIds } = useMemo(() => {
|
const { entries, initialExpandedIds } = useMemo(() => {
|
||||||
@@ -470,27 +443,16 @@ export function LogViewer({ output, className, tokenUsage }: LogViewerProps) {
|
|||||||
const appliedInitialRef = useRef<Set<string>>(new Set());
|
const appliedInitialRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
// Apply initial expanded state for new entries
|
// Apply initial expanded state for new entries
|
||||||
// Also auto-expand all entries when expandAllMode is active
|
|
||||||
const effectiveExpandedIds = useMemo(() => {
|
const effectiveExpandedIds = useMemo(() => {
|
||||||
const result = new Set(expandedIds);
|
const result = new Set(expandedIds);
|
||||||
|
initialExpandedIds.forEach((id) => {
|
||||||
// If expand all mode is active, expand all filtered entries
|
if (!appliedInitialRef.current.has(id)) {
|
||||||
if (expandAllMode) {
|
appliedInitialRef.current.add(id);
|
||||||
entries.forEach((entry) => {
|
result.add(id);
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [expandedIds, initialExpandedIds, expandAllMode, entries]);
|
}, [expandedIds, initialExpandedIds]);
|
||||||
|
|
||||||
// Calculate stats for tool categories
|
// Calculate stats for tool categories
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -546,10 +508,6 @@ export function LogViewer({ output, className, tokenUsage }: LogViewerProps) {
|
|||||||
}, [entries, hiddenTypes, hiddenCategories, searchQuery]);
|
}, [entries, hiddenTypes, hiddenCategories, searchQuery]);
|
||||||
|
|
||||||
const toggleEntry = (id: string) => {
|
const toggleEntry = (id: string) => {
|
||||||
// When user manually collapses an entry, turn off expand all mode
|
|
||||||
if (effectiveExpandedIds.has(id)) {
|
|
||||||
setExpandAllMode(false);
|
|
||||||
}
|
|
||||||
setExpandedIds((prev) => {
|
setExpandedIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(id)) {
|
if (next.has(id)) {
|
||||||
@@ -562,14 +520,10 @@ export function LogViewer({ output, className, tokenUsage }: LogViewerProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const expandAll = () => {
|
const expandAll = () => {
|
||||||
// Enable expand all mode so new entries will also be expanded
|
|
||||||
setExpandAllMode(true);
|
|
||||||
setExpandedIds(new Set(filteredEntries.map((e) => e.id)));
|
setExpandedIds(new Set(filteredEntries.map((e) => e.id)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const collapseAll = () => {
|
const collapseAll = () => {
|
||||||
// Disable expand all mode when collapsing all
|
|
||||||
setExpandAllMode(false);
|
|
||||||
setExpandedIds(new Set());
|
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" />
|
<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>
|
<p className="text-sm">No log entries yet. Logs will appear here as the process runs.</p>
|
||||||
{output && output.trim() && (
|
{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>
|
<pre className="whitespace-pre-wrap">{output}</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -641,40 +595,6 @@ export function LogViewer({ output, className, tokenUsage }: LogViewerProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col", className)}>
|
<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 */}
|
{/* Sticky header with search, stats, and filters */}
|
||||||
{/* Use -top-4 to compensate for parent's p-4 padding, pt-4 to restore visual spacing */}
|
{/* 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">
|
<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>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={expandAll}
|
onClick={expandAll}
|
||||||
className={cn(
|
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
|
||||||
"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"
|
|
||||||
)}
|
|
||||||
data-testid="log-expand-all"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={collapseAll}
|
onClick={collapseAll}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import CodeMirror from "@uiw/react-codemirror";
|
import CodeMirror from "@uiw/react-codemirror";
|
||||||
import { xml } from "@codemirror/lang-xml";
|
import { xml } from "@codemirror/lang-xml";
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||||
import { useAppStore, type AgentModel } from "@/store/app-store";
|
import { useAppStore, 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 */}
|
{/* Selected Images Preview */}
|
||||||
{selectedImages.length > 0 && !showImageDropZone && (
|
{selectedImages.length > 0 && (
|
||||||
<div className="mb-4 space-y-2">
|
<div className="mb-4 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs font-medium text-foreground">
|
<p className="text-xs font-medium text-foreground">
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
@@ -9,9 +10,7 @@ import {
|
|||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import { useAppStore, Feature } from "@/store/app-store";
|
import { useAppStore, Feature } from "@/store/app-store";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
import type { AutoModeEvent } from "@/types/electron";
|
import { pathsEqual, cn } from "@/lib/utils";
|
||||||
import { pathsEqual } from "@/lib/utils";
|
|
||||||
import { getBlockingDependencies } from "@/lib/dependency-resolver";
|
|
||||||
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
|
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
|
||||||
import { RefreshCw } from "lucide-react";
|
import { RefreshCw } from "lucide-react";
|
||||||
import { useAutoMode } from "@/hooks/use-auto-mode";
|
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||||
@@ -26,7 +25,7 @@ import {
|
|||||||
AddFeatureDialog,
|
AddFeatureDialog,
|
||||||
AgentOutputModal,
|
AgentOutputModal,
|
||||||
CompletedFeaturesModal,
|
CompletedFeaturesModal,
|
||||||
ArchiveAllVerifiedDialog,
|
DeleteAllVerifiedDialog,
|
||||||
DeleteCompletedFeatureDialog,
|
DeleteCompletedFeatureDialog,
|
||||||
EditFeatureDialog,
|
EditFeatureDialog,
|
||||||
FeatureSuggestionsDialog,
|
FeatureSuggestionsDialog,
|
||||||
@@ -77,10 +76,7 @@ export function BoardView() {
|
|||||||
setCurrentWorktree,
|
setCurrentWorktree,
|
||||||
getWorktrees,
|
getWorktrees,
|
||||||
setWorktrees,
|
setWorktrees,
|
||||||
useWorktrees,
|
getEffectiveTheme,
|
||||||
enableDependencyBlocking,
|
|
||||||
isPrimaryWorktreeBranch,
|
|
||||||
getPrimaryWorktreeBranch,
|
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
const {
|
const {
|
||||||
@@ -98,7 +94,7 @@ export function BoardView() {
|
|||||||
const [featuresWithContext, setFeaturesWithContext] = useState<Set<string>>(
|
const [featuresWithContext, setFeaturesWithContext] = useState<Set<string>>(
|
||||||
new Set()
|
new Set()
|
||||||
);
|
);
|
||||||
const [showArchiveAllVerifiedDialog, setShowArchiveAllVerifiedDialog] =
|
const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
|
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@@ -269,17 +265,6 @@ export function BoardView() {
|
|||||||
fetchBranches();
|
fetchBranches();
|
||||||
}, [currentProject, worktreeRefreshKey]);
|
}, [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
|
// Custom collision detection that prioritizes columns over cards
|
||||||
const collisionDetectionStrategy = useCallback((args: any) => {
|
const collisionDetectionStrategy = useCallback((args: any) => {
|
||||||
// First, check if pointer is within a column
|
// First, check if pointer is within a column
|
||||||
@@ -301,27 +286,6 @@ export function BoardView() {
|
|||||||
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } =
|
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } =
|
||||||
useBoardPersistence({ currentProject });
|
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)
|
// Get in-progress features for keyboard shortcuts (needed before actions hook)
|
||||||
const inProgressFeaturesForShortcuts = useMemo(() => {
|
const inProgressFeaturesForShortcuts = useMemo(() => {
|
||||||
return hookFeatures.filter((f) => {
|
return hookFeatures.filter((f) => {
|
||||||
@@ -330,12 +294,13 @@ export function BoardView() {
|
|||||||
});
|
});
|
||||||
}, [hookFeatures, runningAutoTasks]);
|
}, [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
|
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
|
||||||
const currentWorktreeInfo = currentProject
|
const currentWorktreeInfo = currentProject
|
||||||
? getCurrentWorktree(currentProject.path)
|
? getCurrentWorktree(currentProject.path)
|
||||||
: null;
|
: null;
|
||||||
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
||||||
|
const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null;
|
||||||
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
|
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
|
||||||
const worktrees = useMemo(
|
const worktrees = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -345,25 +310,8 @@ export function BoardView() {
|
|||||||
[currentProject, worktreesByProject]
|
[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)
|
// 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 =
|
const selectedWorktreeBranch =
|
||||||
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main";
|
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main";
|
||||||
|
|
||||||
@@ -387,7 +335,7 @@ export function BoardView() {
|
|||||||
handleOutputModalNumberKeyPress,
|
handleOutputModalNumberKeyPress,
|
||||||
handleForceStopFeature,
|
handleForceStopFeature,
|
||||||
handleStartNextFeatures,
|
handleStartNextFeatures,
|
||||||
handleArchiveAllVerified,
|
handleDeleteAllVerified,
|
||||||
} = useBoardActions({
|
} = useBoardActions({
|
||||||
currentProject,
|
currentProject,
|
||||||
features: hookFeatures,
|
features: hookFeatures,
|
||||||
@@ -415,222 +363,6 @@ export function BoardView() {
|
|||||||
currentWorktreeBranch,
|
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)
|
// Use keyboard shortcuts hook (after actions hook)
|
||||||
useBoardKeyboardShortcuts({
|
useBoardKeyboardShortcuts({
|
||||||
features: hookFeatures,
|
features: hookFeatures,
|
||||||
@@ -647,6 +379,8 @@ export function BoardView() {
|
|||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
persistFeatureUpdate,
|
persistFeatureUpdate,
|
||||||
handleStartImplementation,
|
handleStartImplementation,
|
||||||
|
projectPath: currentProject?.path || null,
|
||||||
|
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use column features hook
|
// Use column features hook
|
||||||
@@ -667,9 +401,7 @@ export function BoardView() {
|
|||||||
// Find feature for pending plan approval
|
// Find feature for pending plan approval
|
||||||
const pendingApprovalFeature = useMemo(() => {
|
const pendingApprovalFeature = useMemo(() => {
|
||||||
if (!pendingPlanApproval) return null;
|
if (!pendingPlanApproval) return null;
|
||||||
return (
|
return hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null;
|
||||||
hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null
|
|
||||||
);
|
|
||||||
}, [pendingPlanApproval, hookFeatures]);
|
}, [pendingPlanApproval, hookFeatures]);
|
||||||
|
|
||||||
// Handle plan approval
|
// Handle plan approval
|
||||||
@@ -695,10 +427,10 @@ export function BoardView() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Immediately update local feature state to hide "Approve Plan" button
|
// Immediately update local feature state to hide "Approve Plan" button
|
||||||
// Get current feature to preserve version
|
// Get current feature to preserve version
|
||||||
const currentFeature = hookFeatures.find((f) => f.id === featureId);
|
const currentFeature = hookFeatures.find(f => f.id === featureId);
|
||||||
updateFeature(featureId, {
|
updateFeature(featureId, {
|
||||||
planSpec: {
|
planSpec: {
|
||||||
status: "approved",
|
status: 'approved',
|
||||||
content: editedPlan || pendingPlanApproval.planContent,
|
content: editedPlan || pendingPlanApproval.planContent,
|
||||||
version: currentFeature?.planSpec?.version || 1,
|
version: currentFeature?.planSpec?.version || 1,
|
||||||
approvedAt: new Date().toISOString(),
|
approvedAt: new Date().toISOString(),
|
||||||
@@ -717,14 +449,7 @@ export function BoardView() {
|
|||||||
setPendingPlanApproval(null);
|
setPendingPlanApproval(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures]
|
||||||
pendingPlanApproval,
|
|
||||||
currentProject,
|
|
||||||
setPendingPlanApproval,
|
|
||||||
updateFeature,
|
|
||||||
loadFeatures,
|
|
||||||
hookFeatures,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle plan rejection
|
// Handle plan rejection
|
||||||
@@ -751,11 +476,11 @@ export function BoardView() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Immediately update local feature state
|
// Immediately update local feature state
|
||||||
// Get current feature to preserve version
|
// Get current feature to preserve version
|
||||||
const currentFeature = hookFeatures.find((f) => f.id === featureId);
|
const currentFeature = hookFeatures.find(f => f.id === featureId);
|
||||||
updateFeature(featureId, {
|
updateFeature(featureId, {
|
||||||
status: "backlog",
|
status: 'backlog',
|
||||||
planSpec: {
|
planSpec: {
|
||||||
status: "rejected",
|
status: 'rejected',
|
||||||
content: pendingPlanApproval.planContent,
|
content: pendingPlanApproval.planContent,
|
||||||
version: currentFeature?.planSpec?.version || 1,
|
version: currentFeature?.planSpec?.version || 1,
|
||||||
reviewedByUser: true,
|
reviewedByUser: true,
|
||||||
@@ -773,14 +498,7 @@ export function BoardView() {
|
|||||||
setPendingPlanApproval(null);
|
setPendingPlanApproval(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures]
|
||||||
pendingPlanApproval,
|
|
||||||
currentProject,
|
|
||||||
setPendingPlanApproval,
|
|
||||||
updateFeature,
|
|
||||||
loadFeatures,
|
|
||||||
hookFeatures,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle opening approval dialog from feature card button
|
// 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)
|
// Determine the planning mode for approval (skip should never have a plan requiring approval)
|
||||||
const mode = feature.planningMode;
|
const mode = feature.planningMode;
|
||||||
const approvalMode: "lite" | "spec" | "full" =
|
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
|
// Re-open the approval dialog with the feature's plan data
|
||||||
setPendingPlanApproval({
|
setPendingPlanApproval({
|
||||||
@@ -804,6 +522,9 @@ export function BoardView() {
|
|||||||
[currentProject, setPendingPlanApproval]
|
[currentProject, setPendingPlanApproval]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const effectiveTheme = getEffectiveTheme();
|
||||||
|
const isCleanTheme = effectiveTheme === "clean";
|
||||||
|
|
||||||
if (!currentProject) {
|
if (!currentProject) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -837,13 +558,8 @@ export function BoardView() {
|
|||||||
maxConcurrency={maxConcurrency}
|
maxConcurrency={maxConcurrency}
|
||||||
onConcurrencyChange={setMaxConcurrency}
|
onConcurrencyChange={setMaxConcurrency}
|
||||||
isAutoModeRunning={autoMode.isRunning}
|
isAutoModeRunning={autoMode.isRunning}
|
||||||
onAutoModeToggle={(enabled) => {
|
onStartAutoMode={() => autoMode.start()}
|
||||||
if (enabled) {
|
onStopAutoMode={() => autoMode.stop()}
|
||||||
autoMode.start();
|
|
||||||
} else {
|
|
||||||
autoMode.stop();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onAddFeature={() => setShowAddDialog(true)}
|
onAddFeature={() => setShowAddDialog(true)}
|
||||||
addFeatureShortcut={{
|
addFeatureShortcut={{
|
||||||
key: shortcuts.addFeature,
|
key: shortcuts.addFeature,
|
||||||
@@ -874,11 +590,10 @@ export function BoardView() {
|
|||||||
setSelectedWorktreeForAction(worktree);
|
setSelectedWorktreeForAction(worktree);
|
||||||
setShowCreateBranchDialog(true);
|
setShowCreateBranchDialog(true);
|
||||||
}}
|
}}
|
||||||
onRemovedWorktrees={handleRemovedWorktrees}
|
|
||||||
runningFeatureIds={runningAutoTasks}
|
runningFeatureIds={runningAutoTasks}
|
||||||
branchCardCounts={branchCardCounts}
|
|
||||||
features={hookFeatures.map((f) => ({
|
features={hookFeatures.map((f) => ({
|
||||||
id: f.id,
|
id: f.id,
|
||||||
|
worktreePath: f.worktreePath,
|
||||||
branchName: f.branchName,
|
branchName: f.branchName,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
@@ -886,7 +601,7 @@ export function BoardView() {
|
|||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Search Bar Row */}
|
{/* 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
|
<BoardSearchBar
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSearchChange={setSearchQuery}
|
onSearchChange={setSearchQuery}
|
||||||
@@ -935,7 +650,7 @@ export function BoardView() {
|
|||||||
onStartNextFeatures={handleStartNextFeatures}
|
onStartNextFeatures={handleStartNextFeatures}
|
||||||
onShowSuggestions={() => setShowSuggestionsDialog(true)}
|
onShowSuggestions={() => setShowSuggestionsDialog(true)}
|
||||||
suggestionsCount={suggestionsCount}
|
suggestionsCount={suggestionsCount}
|
||||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
onDeleteAllVerified={() => setShowDeleteAllVerifiedDialog(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -973,10 +688,8 @@ export function BoardView() {
|
|||||||
onAdd={handleAddFeature}
|
onAdd={handleAddFeature}
|
||||||
categorySuggestions={categorySuggestions}
|
categorySuggestions={categorySuggestions}
|
||||||
branchSuggestions={branchSuggestions}
|
branchSuggestions={branchSuggestions}
|
||||||
branchCardCounts={branchCardCounts}
|
|
||||||
defaultSkipTests={defaultSkipTests}
|
defaultSkipTests={defaultSkipTests}
|
||||||
defaultBranch={selectedWorktreeBranch}
|
defaultBranch={selectedWorktreeBranch}
|
||||||
currentBranch={currentWorktreeBranch || undefined}
|
|
||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
showProfilesOnly={showProfilesOnly}
|
showProfilesOnly={showProfilesOnly}
|
||||||
aiProfiles={aiProfiles}
|
aiProfiles={aiProfiles}
|
||||||
@@ -989,8 +702,6 @@ export function BoardView() {
|
|||||||
onUpdate={handleUpdateFeature}
|
onUpdate={handleUpdateFeature}
|
||||||
categorySuggestions={categorySuggestions}
|
categorySuggestions={categorySuggestions}
|
||||||
branchSuggestions={branchSuggestions}
|
branchSuggestions={branchSuggestions}
|
||||||
branchCardCounts={branchCardCounts}
|
|
||||||
currentBranch={currentWorktreeBranch || undefined}
|
|
||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
showProfilesOnly={showProfilesOnly}
|
showProfilesOnly={showProfilesOnly}
|
||||||
aiProfiles={aiProfiles}
|
aiProfiles={aiProfiles}
|
||||||
@@ -1007,14 +718,14 @@ export function BoardView() {
|
|||||||
onNumberKeyPress={handleOutputModalNumberKeyPress}
|
onNumberKeyPress={handleOutputModalNumberKeyPress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Archive All Verified Dialog */}
|
{/* Delete All Verified Dialog */}
|
||||||
<ArchiveAllVerifiedDialog
|
<DeleteAllVerifiedDialog
|
||||||
open={showArchiveAllVerifiedDialog}
|
open={showDeleteAllVerifiedDialog}
|
||||||
onOpenChange={setShowArchiveAllVerifiedDialog}
|
onOpenChange={setShowDeleteAllVerifiedDialog}
|
||||||
verifiedCount={getColumnFeatures("verified").length}
|
verifiedCount={getColumnFeatures("verified").length}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
await handleArchiveAllVerified();
|
await handleDeleteAllVerified();
|
||||||
setShowArchiveAllVerifiedDialog(false);
|
setShowDeleteAllVerifiedDialog(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1111,24 +822,21 @@ export function BoardView() {
|
|||||||
onOpenChange={setShowDeleteWorktreeDialog}
|
onOpenChange={setShowDeleteWorktreeDialog}
|
||||||
projectPath={currentProject.path}
|
projectPath={currentProject.path}
|
||||||
worktree={selectedWorktreeForAction}
|
worktree={selectedWorktreeForAction}
|
||||||
affectedFeatureCount={
|
|
||||||
selectedWorktreeForAction
|
|
||||||
? hookFeatures.filter(
|
|
||||||
(f) => f.branchName === selectedWorktreeForAction.branch
|
|
||||||
).length
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
onDeleted={(deletedWorktree, _deletedBranch) => {
|
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) => {
|
hookFeatures.forEach((feature) => {
|
||||||
// Match by branch name since worktreePath is no longer stored
|
const matchesByPath =
|
||||||
if (feature.branchName === deletedWorktree.branch) {
|
feature.worktreePath &&
|
||||||
// Reset the feature's branch assignment - update both local state and persist
|
pathsEqual(feature.worktreePath, deletedWorktree.path);
|
||||||
const updates = {
|
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,
|
branchName: null as unknown as string | undefined,
|
||||||
};
|
worktreePath: null as unknown as string | undefined,
|
||||||
updateFeature(feature.id, updates);
|
});
|
||||||
persistFeatureUpdate(feature.id, updates);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
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 { cn } from "@/lib/utils";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
|
||||||
interface BoardControlsProps {
|
interface BoardControlsProps {
|
||||||
isMounted: boolean;
|
isMounted: boolean;
|
||||||
@@ -21,8 +23,41 @@ export function BoardControls({
|
|||||||
kanbanCardDetailLevel,
|
kanbanCardDetailLevel,
|
||||||
onDetailLevelChange,
|
onDetailLevelChange,
|
||||||
}: BoardControlsProps) {
|
}: BoardControlsProps) {
|
||||||
|
const { getEffectiveTheme } = useAppStore();
|
||||||
|
const effectiveTheme = getEffectiveTheme();
|
||||||
|
const isCleanTheme = effectiveTheme === "clean";
|
||||||
|
|
||||||
if (!isMounted) return null;
|
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 (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="flex items-center gap-2 ml-4">
|
||||||
158
apps/app/src/components/views/board-view/board-header.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { Play, StopCircle, Plus, Users } from "lucide-react";
|
||||||
|
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
|
||||||
|
interface BoardHeaderProps {
|
||||||
|
projectName: string;
|
||||||
|
maxConcurrency: number;
|
||||||
|
onConcurrencyChange: (value: number) => void;
|
||||||
|
isAutoModeRunning: boolean;
|
||||||
|
onStartAutoMode: () => void;
|
||||||
|
onStopAutoMode: () => void;
|
||||||
|
onAddFeature: () => void;
|
||||||
|
addFeatureShortcut: KeyboardShortcut;
|
||||||
|
isMounted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoardHeader({
|
||||||
|
projectName,
|
||||||
|
maxConcurrency,
|
||||||
|
onConcurrencyChange,
|
||||||
|
isAutoModeRunning,
|
||||||
|
onStartAutoMode,
|
||||||
|
onStopAutoMode,
|
||||||
|
onAddFeature,
|
||||||
|
addFeatureShortcut,
|
||||||
|
isMounted,
|
||||||
|
}: BoardHeaderProps) {
|
||||||
|
const { getEffectiveTheme } = useAppStore();
|
||||||
|
const effectiveTheme = getEffectiveTheme();
|
||||||
|
const isCleanTheme = effectiveTheme === "clean";
|
||||||
|
|
||||||
|
if (isCleanTheme) {
|
||||||
|
return (
|
||||||
|
<header className="h-16 flex items-center justify-between px-8 border-b border-white/5 bg-[#0b101a]/40 backdrop-blur-md z-20 shrink-0">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white tracking-tight">Kanban Board</h2>
|
||||||
|
<p className="text-[10px] text-slate-500 uppercase tracking-[0.2em] font-bold mono">
|
||||||
|
{projectName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
{/* Concurrency Display (Visual only to match mockup for now, or interactive if needed) */}
|
||||||
|
<div className="flex items-center bg-white/5 border border-white/10 rounded-full px-4 py-1.5 gap-3">
|
||||||
|
<Users className="w-4 h-4 text-slate-500" />
|
||||||
|
<div className="toggle-track">
|
||||||
|
<div className="toggle-thumb"></div>
|
||||||
|
</div>
|
||||||
|
<span className="mono text-xs font-bold text-slate-400">{maxConcurrency}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto Mode Button */}
|
||||||
|
{isAutoModeRunning ? (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 glass px-5 py-2 rounded-xl text-xs font-bold hover:bg-white/10 transition text-rose-400 border-rose-500/30"
|
||||||
|
onClick={onStopAutoMode}
|
||||||
|
>
|
||||||
|
<StopCircle className="w-3.5 h-3.5" /> Stop
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 glass px-5 py-2 rounded-xl text-xs font-bold hover:bg-white/10 transition"
|
||||||
|
onClick={onStartAutoMode}
|
||||||
|
>
|
||||||
|
<Play className="w-3.5 h-3.5 text-cyan-400 fill-cyan-400" /> Auto Mode
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Feature Button */}
|
||||||
|
<button
|
||||||
|
className="btn-cyan px-6 py-2 rounded-xl text-xs font-black flex items-center gap-2 shadow-lg shadow-cyan-500/20"
|
||||||
|
onClick={onAddFeature}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 stroke-[3.5px]" /> ADD FEATURE
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Kanban Board</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{projectName}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
|
||||||
|
{isMounted && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border"
|
||||||
|
data-testid="concurrency-slider-container"
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<Slider
|
||||||
|
value={[maxConcurrency]}
|
||||||
|
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
step={1}
|
||||||
|
className="w-20"
|
||||||
|
data-testid="concurrency-slider"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-sm text-muted-foreground min-w-[2ch] text-center"
|
||||||
|
data-testid="concurrency-value"
|
||||||
|
>
|
||||||
|
{maxConcurrency}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
||||||
|
{isMounted && (
|
||||||
|
<>
|
||||||
|
{isAutoModeRunning ? (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={onStopAutoMode}
|
||||||
|
data-testid="stop-auto-mode"
|
||||||
|
>
|
||||||
|
<StopCircle className="w-4 h-4 mr-2" />
|
||||||
|
Stop Auto Mode
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={onStartAutoMode}
|
||||||
|
data-testid="start-auto-mode"
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
Auto Mode
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<HotkeyButton
|
||||||
|
size="sm"
|
||||||
|
onClick={onAddFeature}
|
||||||
|
hotkey={addFeatureShortcut}
|
||||||
|
hotkeyActive={false}
|
||||||
|
data-testid="add-feature-button"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Feature
|
||||||
|
</HotkeyButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useRef, useEffect } from "react";
|
import { useRef, useEffect } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Search, X, Loader2 } from "lucide-react";
|
import { Search, X, Loader2 } from "lucide-react";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
|
||||||
interface BoardSearchBarProps {
|
interface BoardSearchBarProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -19,6 +21,9 @@ export function BoardSearchBar({
|
|||||||
currentProjectPath,
|
currentProjectPath,
|
||||||
}: BoardSearchBarProps) {
|
}: BoardSearchBarProps) {
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { getEffectiveTheme } = useAppStore();
|
||||||
|
const effectiveTheme = getEffectiveTheme();
|
||||||
|
const isCleanTheme = effectiveTheme === "clean";
|
||||||
|
|
||||||
// Focus search input when "/" is pressed
|
// Focus search input when "/" is pressed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,6 +43,25 @@ export function BoardSearchBar({
|
|||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (isCleanTheme) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex-1 max-w-2xl group">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 group-focus-within:text-cyan-400 transition-colors" />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search features by keyword..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-2xl py-2.5 pl-12 pr-12 text-sm focus:outline-none focus:border-cyan-500/50 transition-all mono"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-4 top-1/2 -translate-y-1/2">
|
||||||
|
<span className="shortcut-badge">/</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative max-w-md flex-1 flex items-center gap-2">
|
<div className="relative max-w-md flex-1 flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, memo } from "react";
|
import { useState, useEffect, useMemo, memo } from "react";
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
@@ -57,7 +58,12 @@ import {
|
|||||||
Wand2,
|
Wand2,
|
||||||
Archive,
|
Archive,
|
||||||
Lock,
|
Lock,
|
||||||
Coins,
|
Target,
|
||||||
|
Square,
|
||||||
|
Terminal,
|
||||||
|
RefreshCw,
|
||||||
|
Layers,
|
||||||
|
Edit3,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CountUpTimer } from "@/components/ui/count-up-timer";
|
import { CountUpTimer } from "@/components/ui/count-up-timer";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
@@ -91,29 +97,6 @@ function formatThinkingLevel(level: ThinkingLevel | undefined): string {
|
|||||||
return labels[level];
|
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 {
|
interface KanbanCardProps {
|
||||||
feature: Feature;
|
feature: Feature;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
@@ -172,7 +155,9 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||||
const { kanbanCardDetailLevel, enableDependencyBlocking, features, useWorktrees } = useAppStore();
|
const { kanbanCardDetailLevel, enableDependencyBlocking, features, useWorktrees, getEffectiveTheme } = useAppStore();
|
||||||
|
const effectiveTheme = getEffectiveTheme();
|
||||||
|
const isCleanTheme = effectiveTheme === "clean";
|
||||||
|
|
||||||
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
||||||
const blockingDependencies = useMemo(() => {
|
const blockingDependencies = useMemo(() => {
|
||||||
@@ -183,9 +168,9 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
}, [enableDependencyBlocking, feature, features]);
|
}, [enableDependencyBlocking, feature, features]);
|
||||||
|
|
||||||
const showSteps =
|
const showSteps =
|
||||||
kanbanCardDetailLevel === "standard" ||
|
(kanbanCardDetailLevel === "standard" ||
|
||||||
kanbanCardDetailLevel === "detailed";
|
kanbanCardDetailLevel === "detailed") && !isCleanTheme; // Hide steps in clean theme
|
||||||
const showAgentInfo = kanbanCardDetailLevel === "detailed";
|
const showAgentInfo = kanbanCardDetailLevel === "detailed" || isCleanTheme; // Always show model info in clean theme
|
||||||
|
|
||||||
const isJustFinished = useMemo(() => {
|
const isJustFinished = useMemo(() => {
|
||||||
if (
|
if (
|
||||||
@@ -284,7 +269,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
feature.status === "backlog" ||
|
feature.status === "backlog" ||
|
||||||
feature.status === "waiting_approval" ||
|
feature.status === "waiting_approval" ||
|
||||||
feature.status === "verified" ||
|
feature.status === "verified" ||
|
||||||
(feature.status === "in_progress" && !isCurrentAutoTask);
|
(feature.skipTests && !isCurrentAutoTask);
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
@@ -314,17 +299,261 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
).borderColor = `color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
|
).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 = (
|
const cardElement = (
|
||||||
<Card
|
<Card
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={isCurrentAutoTask ? style : borderStyle}
|
style={isCurrentAutoTask ? style : borderStyle}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
// Clean theme kanban-card class
|
||||||
|
"kanban-card",
|
||||||
"cursor-grab active:cursor-grabbing relative kanban-card-content select-none",
|
"cursor-grab active:cursor-grabbing relative kanban-card-content select-none",
|
||||||
"transition-all duration-200 ease-out",
|
"transition-all duration-200 ease-out",
|
||||||
// Premium shadow system
|
// Premium shadow system
|
||||||
"shadow-sm hover:shadow-md hover:shadow-black/10",
|
"shadow-sm hover:shadow-md hover:shadow-black/10",
|
||||||
// Subtle lift on hover
|
// Subtle lift on hover
|
||||||
"hover:-translate-y-0.5",
|
"hover:-translate-y-0.5",
|
||||||
|
// Running card state for clean theme
|
||||||
|
isCurrentAutoTask && "is-running kanban-card-active",
|
||||||
!isCurrentAutoTask &&
|
!isCurrentAutoTask &&
|
||||||
cardBorderEnabled &&
|
cardBorderEnabled &&
|
||||||
cardBorderOpacity === 100 &&
|
cardBorderOpacity === 100 &&
|
||||||
@@ -748,7 +977,10 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
|
|
||||||
{/* Model/Preset Info for Backlog Cards */}
|
{/* Model/Preset Info for Backlog Cards */}
|
||||||
{showAgentInfo && feature.status === "backlog" && (
|
{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-2 text-[11px] flex-wrap">
|
||||||
<div className="flex items-center gap-1 text-[var(--status-info)]">
|
<div className="flex items-center gap-1 text-[var(--status-info)]">
|
||||||
<Cpu className="w-3 h-3" />
|
<Cpu className="w-3 h-3" />
|
||||||
@@ -770,7 +1002,10 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
|
|
||||||
{/* Agent Info Panel */}
|
{/* Agent Info Panel */}
|
||||||
{showAgentInfo && feature.status !== "backlog" && agentInfo && (
|
{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 */}
|
{/* Model & Phase */}
|
||||||
<div className="flex items-center gap-2 text-[11px] flex-wrap">
|
<div className="flex items-center gap-2 text-[11px] flex-wrap">
|
||||||
<div className="flex items-center gap-1 text-[var(--status-info)]">
|
<div className="flex items-center gap-1 text-[var(--status-info)]">
|
||||||
@@ -897,43 +1132,16 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div
|
||||||
|
className="flex flex-wrap gap-1.5"
|
||||||
|
style={isCleanTheme ? { order: 2 } : undefined}
|
||||||
|
>
|
||||||
{isCurrentAutoTask && (
|
{isCurrentAutoTask && (
|
||||||
<>
|
<>
|
||||||
{/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */}
|
{/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
||||||
|
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
|
||||||
import {
|
import {
|
||||||
DescriptionImageDropZone,
|
DescriptionImageDropZone,
|
||||||
FeatureImagePath as DescriptionImagePath,
|
FeatureImagePath as DescriptionImagePath,
|
||||||
@@ -43,7 +45,6 @@ import {
|
|||||||
ProfileQuickSelect,
|
ProfileQuickSelect,
|
||||||
TestingTabContent,
|
TestingTabContent,
|
||||||
PrioritySelector,
|
PrioritySelector,
|
||||||
BranchSelector,
|
|
||||||
PlanningModeSelector,
|
PlanningModeSelector,
|
||||||
} from "../shared";
|
} from "../shared";
|
||||||
import {
|
import {
|
||||||
@@ -52,7 +53,6 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
interface AddFeatureDialogProps {
|
interface AddFeatureDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -66,17 +66,15 @@ interface AddFeatureDialogProps {
|
|||||||
skipTests: boolean;
|
skipTests: boolean;
|
||||||
model: AgentModel;
|
model: AgentModel;
|
||||||
thinkingLevel: ThinkingLevel;
|
thinkingLevel: ThinkingLevel;
|
||||||
branchName: string; // Can be empty string to use current branch
|
branchName: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
planningMode: PlanningMode;
|
planningMode: PlanningMode;
|
||||||
requirePlanApproval: boolean;
|
requirePlanApproval: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
categorySuggestions: string[];
|
categorySuggestions: string[];
|
||||||
branchSuggestions: string[];
|
branchSuggestions: string[];
|
||||||
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
|
||||||
defaultSkipTests: boolean;
|
defaultSkipTests: boolean;
|
||||||
defaultBranch?: string;
|
defaultBranch?: string;
|
||||||
currentBranch?: string;
|
|
||||||
isMaximized: boolean;
|
isMaximized: boolean;
|
||||||
showProfilesOnly: boolean;
|
showProfilesOnly: boolean;
|
||||||
aiProfiles: AIProfile[];
|
aiProfiles: AIProfile[];
|
||||||
@@ -88,16 +86,12 @@ export function AddFeatureDialog({
|
|||||||
onAdd,
|
onAdd,
|
||||||
categorySuggestions,
|
categorySuggestions,
|
||||||
branchSuggestions,
|
branchSuggestions,
|
||||||
branchCardCounts,
|
|
||||||
defaultSkipTests,
|
defaultSkipTests,
|
||||||
defaultBranch = "main",
|
defaultBranch = "main",
|
||||||
currentBranch,
|
|
||||||
isMaximized,
|
isMaximized,
|
||||||
showProfilesOnly,
|
showProfilesOnly,
|
||||||
aiProfiles,
|
aiProfiles,
|
||||||
}: AddFeatureDialogProps) {
|
}: AddFeatureDialogProps) {
|
||||||
const navigate = useNavigate();
|
|
||||||
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
|
|
||||||
const [newFeature, setNewFeature] = useState({
|
const [newFeature, setNewFeature] = useState({
|
||||||
category: "",
|
category: "",
|
||||||
description: "",
|
description: "",
|
||||||
@@ -107,7 +101,7 @@ export function AddFeatureDialog({
|
|||||||
skipTests: false,
|
skipTests: false,
|
||||||
model: "opus" as AgentModel,
|
model: "opus" as AgentModel,
|
||||||
thinkingLevel: "none" as ThinkingLevel,
|
thinkingLevel: "none" as ThinkingLevel,
|
||||||
branchName: "",
|
branchName: "main",
|
||||||
priority: 2 as number, // Default to medium priority
|
priority: 2 as number, // Default to medium priority
|
||||||
});
|
});
|
||||||
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
|
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
|
||||||
@@ -118,16 +112,11 @@ export function AddFeatureDialog({
|
|||||||
const [enhancementMode, setEnhancementMode] = useState<
|
const [enhancementMode, setEnhancementMode] = useState<
|
||||||
"improve" | "technical" | "simplify" | "acceptance"
|
"improve" | "technical" | "simplify" | "acceptance"
|
||||||
>("improve");
|
>("improve");
|
||||||
const [planningMode, setPlanningMode] = useState<PlanningMode>("skip");
|
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||||
|
|
||||||
// Get enhancement model, planning mode defaults, and worktrees setting from store
|
// Get enhancement model, planning mode defaults, and worktrees setting from store
|
||||||
const {
|
const { enhancementModel, defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
|
||||||
enhancementModel,
|
|
||||||
defaultPlanningMode,
|
|
||||||
defaultRequirePlanApproval,
|
|
||||||
useWorktrees,
|
|
||||||
} = useAppStore();
|
|
||||||
|
|
||||||
// Sync defaults when dialog opens
|
// Sync defaults when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -135,19 +124,12 @@ export function AddFeatureDialog({
|
|||||||
setNewFeature((prev) => ({
|
setNewFeature((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
skipTests: defaultSkipTests,
|
skipTests: defaultSkipTests,
|
||||||
branchName: defaultBranch || "",
|
branchName: defaultBranch,
|
||||||
}));
|
}));
|
||||||
setUseCurrentBranch(true);
|
|
||||||
setPlanningMode(defaultPlanningMode);
|
setPlanningMode(defaultPlanningMode);
|
||||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
}
|
}
|
||||||
}, [
|
}, [open, defaultSkipTests, defaultBranch, defaultPlanningMode, defaultRequirePlanApproval]);
|
||||||
open,
|
|
||||||
defaultSkipTests,
|
|
||||||
defaultBranch,
|
|
||||||
defaultPlanningMode,
|
|
||||||
defaultRequirePlanApproval,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
if (!newFeature.description.trim()) {
|
if (!newFeature.description.trim()) {
|
||||||
@@ -155,25 +137,12 @@ export function AddFeatureDialog({
|
|||||||
return;
|
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 category = newFeature.category || "Uncategorized";
|
||||||
const selectedModel = newFeature.model;
|
const selectedModel = newFeature.model;
|
||||||
const normalizedThinking = modelSupportsThinking(selectedModel)
|
const normalizedThinking = modelSupportsThinking(selectedModel)
|
||||||
? newFeature.thinkingLevel
|
? newFeature.thinkingLevel
|
||||||
: "none";
|
: "none";
|
||||||
|
|
||||||
// Use current branch if toggle is on
|
|
||||||
// If currentBranch is provided (non-primary worktree), use it
|
|
||||||
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
|
|
||||||
const finalBranchName = useCurrentBranch
|
|
||||||
? currentBranch || ""
|
|
||||||
: newFeature.branchName || "";
|
|
||||||
|
|
||||||
onAdd({
|
onAdd({
|
||||||
category,
|
category,
|
||||||
description: newFeature.description,
|
description: newFeature.description,
|
||||||
@@ -183,7 +152,7 @@ export function AddFeatureDialog({
|
|||||||
skipTests: newFeature.skipTests,
|
skipTests: newFeature.skipTests,
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
thinkingLevel: normalizedThinking,
|
thinkingLevel: normalizedThinking,
|
||||||
branchName: finalBranchName,
|
branchName: newFeature.branchName,
|
||||||
priority: newFeature.priority,
|
priority: newFeature.priority,
|
||||||
planningMode,
|
planningMode,
|
||||||
requirePlanApproval,
|
requirePlanApproval,
|
||||||
@@ -200,9 +169,8 @@ export function AddFeatureDialog({
|
|||||||
model: "opus",
|
model: "opus",
|
||||||
priority: 2,
|
priority: 2,
|
||||||
thinkingLevel: "none",
|
thinkingLevel: "none",
|
||||||
branchName: "",
|
branchName: defaultBranch,
|
||||||
});
|
});
|
||||||
setUseCurrentBranch(true);
|
|
||||||
setPlanningMode(defaultPlanningMode);
|
setPlanningMode(defaultPlanningMode);
|
||||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
setNewFeaturePreviewMap(new Map());
|
setNewFeaturePreviewMap(new Map());
|
||||||
@@ -404,18 +372,22 @@ export function AddFeatureDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{useWorktrees && (
|
{useWorktrees && (
|
||||||
<BranchSelector
|
<div className="space-y-2">
|
||||||
useCurrentBranch={useCurrentBranch}
|
<Label htmlFor="branch">Target Branch</Label>
|
||||||
onUseCurrentBranchChange={setUseCurrentBranch}
|
<BranchAutocomplete
|
||||||
branchName={newFeature.branchName}
|
value={newFeature.branchName}
|
||||||
onBranchNameChange={(value) =>
|
onChange={(value) =>
|
||||||
setNewFeature({ ...newFeature, branchName: value })
|
setNewFeature({ ...newFeature, branchName: value })
|
||||||
}
|
}
|
||||||
branchSuggestions={branchSuggestions}
|
branches={branchSuggestions}
|
||||||
branchCardCounts={branchCardCounts}
|
placeholder="Select or create branch..."
|
||||||
currentBranch={currentBranch}
|
data-testid="feature-branch-input"
|
||||||
testIdPrefix="feature"
|
/>
|
||||||
/>
|
<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 */}
|
{/* Priority Selector */}
|
||||||
@@ -465,7 +437,7 @@ export function AddFeatureDialog({
|
|||||||
showManageLink
|
showManageLink
|
||||||
onManageLinkClick={() => {
|
onManageLinkClick={() => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
navigate({ to: "/profiles" });
|
useAppStore.getState().setCurrentView("profiles");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -495,10 +467,7 @@ export function AddFeatureDialog({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Options Tab */}
|
{/* Options Tab */}
|
||||||
<TabsContent
|
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
|
||||||
value="options"
|
|
||||||
className="space-y-4 overflow-y-auto cursor-default"
|
|
||||||
>
|
|
||||||
{/* Planning Mode Section */}
|
{/* Planning Mode Section */}
|
||||||
<PlanningModeSelector
|
<PlanningModeSelector
|
||||||
mode={planningMode}
|
mode={planningMode}
|
||||||
@@ -532,9 +501,6 @@ export function AddFeatureDialog({
|
|||||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||||
hotkeyActive={open}
|
hotkeyActive={open}
|
||||||
data-testid="confirm-add-feature"
|
data-testid="confirm-add-feature"
|
||||||
disabled={
|
|
||||||
useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Add Feature
|
Add Feature
|
||||||
</HotkeyButton>
|
</HotkeyButton>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -44,8 +45,6 @@ export function AgentOutputModal({
|
|||||||
const autoScrollRef = useRef(true);
|
const autoScrollRef = useRef(true);
|
||||||
const projectPathRef = useRef<string>("");
|
const projectPathRef = useRef<string>("");
|
||||||
const useWorktrees = useAppStore((state) => state.useWorktrees);
|
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
|
// Auto-scroll to bottom when output changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -389,7 +388,7 @@ export function AgentOutputModal({
|
|||||||
No output yet. The agent will stream output here as it works.
|
No output yet. The agent will stream output here as it works.
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === "parsed" ? (
|
) : viewMode === "parsed" ? (
|
||||||
<LogViewer output={output} tokenUsage={feature?.tokenUsage} />
|
<LogViewer output={output} />
|
||||||
) : (
|
) : (
|
||||||
<div className="whitespace-pre-wrap break-words text-zinc-300">
|
<div className="whitespace-pre-wrap break-words text-zinc-300">
|
||||||
{output}
|
{output}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -48,26 +49,18 @@ export function CreatePRDialog({
|
|||||||
const [prUrl, setPrUrl] = useState<string | null>(null);
|
const [prUrl, setPrUrl] = useState<string | null>(null);
|
||||||
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
|
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
|
||||||
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
|
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
|
// Reset state when dialog opens or worktree changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
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("");
|
setTitle("");
|
||||||
setBody("");
|
setBody("");
|
||||||
setCommitMessage("");
|
setCommitMessage("");
|
||||||
setBaseBranch("main");
|
setBaseBranch("main");
|
||||||
setIsDraft(false);
|
setIsDraft(false);
|
||||||
setError(null);
|
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 {
|
} else {
|
||||||
// Reset everything when dialog closes
|
// Reset everything when dialog closes
|
||||||
setTitle("");
|
setTitle("");
|
||||||
@@ -79,7 +72,6 @@ export function CreatePRDialog({
|
|||||||
setPrUrl(null);
|
setPrUrl(null);
|
||||||
setBrowserUrl(null);
|
setBrowserUrl(null);
|
||||||
setShowBrowserFallback(false);
|
setShowBrowserFallback(false);
|
||||||
operationCompletedRef.current = false;
|
|
||||||
}
|
}
|
||||||
}, [open, worktree?.path]);
|
}, [open, worktree?.path]);
|
||||||
|
|
||||||
@@ -106,8 +98,6 @@ export function CreatePRDialog({
|
|||||||
if (result.success && result.result) {
|
if (result.success && result.result) {
|
||||||
if (result.result.prCreated && result.result.prUrl) {
|
if (result.result.prCreated && result.result.prUrl) {
|
||||||
setPrUrl(result.result.prUrl);
|
setPrUrl(result.result.prUrl);
|
||||||
// Mark operation as completed for refresh on close
|
|
||||||
operationCompletedRef.current = true;
|
|
||||||
toast.success("Pull request created!", {
|
toast.success("Pull request created!", {
|
||||||
description: `PR created from ${result.result.branch}`,
|
description: `PR created from ${result.result.branch}`,
|
||||||
action: {
|
action: {
|
||||||
@@ -115,8 +105,7 @@ export function CreatePRDialog({
|
|||||||
onClick: () => window.open(result.result!.prUrl!, "_blank"),
|
onClick: () => window.open(result.result!.prUrl!, "_blank"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Don't call onCreated() here - keep dialog open to show success message
|
onCreated();
|
||||||
// onCreated() will be called when user closes the dialog
|
|
||||||
} else {
|
} else {
|
||||||
// Branch was pushed successfully
|
// Branch was pushed successfully
|
||||||
const prError = result.result.prError;
|
const prError = result.result.prError;
|
||||||
@@ -128,8 +117,6 @@ export function CreatePRDialog({
|
|||||||
if (prError === "gh_cli_not_available" || !result.result.ghCliAvailable) {
|
if (prError === "gh_cli_not_available" || !result.result.ghCliAvailable) {
|
||||||
setBrowserUrl(result.result.browserUrl ?? null);
|
setBrowserUrl(result.result.browserUrl ?? null);
|
||||||
setShowBrowserFallback(true);
|
setShowBrowserFallback(true);
|
||||||
// Mark operation as completed - branch was pushed successfully
|
|
||||||
operationCompletedRef.current = true;
|
|
||||||
toast.success("Branch pushed", {
|
toast.success("Branch pushed", {
|
||||||
description: result.result.committed
|
description: result.result.committed
|
||||||
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
|
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
|
||||||
@@ -155,8 +142,6 @@ export function CreatePRDialog({
|
|||||||
// Show error but also provide browser option
|
// Show error but also provide browser option
|
||||||
setBrowserUrl(result.result.browserUrl ?? null);
|
setBrowserUrl(result.result.browserUrl ?? null);
|
||||||
setShowBrowserFallback(true);
|
setShowBrowserFallback(true);
|
||||||
// Mark operation as completed - branch was pushed even though PR creation failed
|
|
||||||
operationCompletedRef.current = true;
|
|
||||||
toast.error("PR creation failed", {
|
toast.error("PR creation failed", {
|
||||||
description: errorMessage,
|
description: errorMessage,
|
||||||
duration: 8000,
|
duration: 8000,
|
||||||
@@ -197,13 +182,19 @@ export function CreatePRDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
// Only call onCreated() if an actual operation completed
|
|
||||||
// This prevents unnecessary refreshes when user cancels
|
|
||||||
if (operationCompletedRef.current) {
|
|
||||||
onCreated();
|
|
||||||
}
|
|
||||||
onOpenChange(false);
|
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;
|
if (!worktree) return null;
|
||||||
@@ -237,18 +228,13 @@ export function CreatePRDialog({
|
|||||||
Your PR is ready for review
|
Your PR is ready for review
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 justify-center">
|
<Button
|
||||||
<Button
|
onClick={() => window.open(prUrl, "_blank")}
|
||||||
onClick={() => window.open(prUrl, "_blank")}
|
className="gap-2"
|
||||||
className="gap-2"
|
>
|
||||||
>
|
<ExternalLink className="w-4 h-4" />
|
||||||
<ExternalLink className="w-4 h-4" />
|
View Pull Request
|
||||||
View Pull Request
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={handleClose}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : shouldShowBrowserFallback ? (
|
) : shouldShowBrowserFallback ? (
|
||||||
<div className="py-6 text-center space-y-4">
|
<div className="py-6 text-center space-y-4">
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||