Merge pull request #475 from AutoMaker-Org/v0.11.0rc

V0.11.0rc
This commit is contained in:
Web Dev Cody
2026-01-14 20:31:11 -05:00
committed by GitHub
179 changed files with 12426 additions and 2735 deletions

View File

@@ -0,0 +1,108 @@
name: Feature Request
description: Suggest a new feature or enhancement for Automaker
title: '[Feature]: '
labels: ['enhancement']
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to suggest a feature! Please fill out the form below to help us understand your request.
- type: dropdown
id: feature-area
attributes:
label: Feature Area
description: Which area of Automaker does this feature relate to?
options:
- UI/UX (User Interface)
- Agent/AI
- Kanban Board
- Git/Worktree Management
- Project Management
- Settings/Configuration
- Documentation
- Performance
- Other
default: 0
validations:
required: true
- type: dropdown
id: priority
attributes:
label: Priority
description: How important is this feature to your workflow?
options:
- Nice to have
- Would improve my workflow
- Critical for my use case
default: 0
validations:
required: true
- type: textarea
id: problem-statement
attributes:
label: Problem Statement
description: Is your feature request related to a problem? Please describe the problem you're trying to solve.
placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when...
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: Proposed Solution
description: Describe the solution you'd like to see implemented.
placeholder: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternatives-considered
attributes:
label: Alternatives Considered
description: Describe any alternative solutions or workarounds you've considered.
placeholder: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: false
- type: textarea
id: use-cases
attributes:
label: Use Cases
description: Describe specific scenarios where this feature would be useful.
placeholder: |
1. When working on...
2. As a user who needs to...
3. In situations where...
validations:
required: false
- type: textarea
id: mockups
attributes:
label: Mockups/Screenshots
description: If applicable, add mockups, wireframes, or screenshots to help illustrate your feature request.
placeholder: Drag and drop images here or paste image URLs
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context, references, or examples about the feature request here.
placeholder: Any additional information that might be helpful...
validations:
required: false
- type: checkboxes
id: terms
attributes:
label: Checklist
options:
- label: I have searched existing issues to ensure this feature hasn't been requested already
required: true
- label: I have provided a clear description of the problem and proposed solution
required: true

3
.gitignore vendored
View File

@@ -90,9 +90,8 @@ pnpm-lock.yaml
yarn.lock
# Fork-specific workflow files (should never be committed)
DEVELOPMENT_WORKFLOW.md
check-sync.sh
# API key files
data/.api-key
data/credentials.json
data/
.codex/

View File

@@ -31,7 +31,12 @@ fi
# Ensure common system paths are in PATH (for systems without nvm)
# This helps find node/npm installed via Homebrew, system packages, etc.
export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin"
if [ -n "$WINDIR" ]; then
export PATH="$PATH:/c/Program Files/nodejs:/c/Program Files (x86)/nodejs"
export PATH="$PATH:$APPDATA/npm:$LOCALAPPDATA/Programs/nodejs"
else
export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin"
fi
# Run lint-staged - works with or without nvm
# Prefer npx, fallback to npm exec, both work with system-installed Node.js

View File

@@ -24,6 +24,7 @@ For complete details on contribution terms and rights assignment, please review
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
- [Pull Request Process](#pull-request-process)
- [Branching Strategy (RC Branches)](#branching-strategy-rc-branches)
- [Branch Naming Convention](#branch-naming-convention)
- [Commit Message Format](#commit-message-format)
- [Submitting a Pull Request](#submitting-a-pull-request)
@@ -186,6 +187,59 @@ automaker/
This section covers everything you need to know about contributing changes through pull requests, from creating your branch to getting your code merged.
### Branching Strategy (RC Branches)
Automaker uses **Release Candidate (RC) branches** for all development work. Understanding this workflow is essential before contributing.
**How it works:**
1. **All development happens on RC branches** - We maintain version-specific RC branches (e.g., `v0.10.0rc`, `v0.11.0rc`) where all active development occurs
2. **RC branches are eventually merged to main** - Once an RC branch is stable and ready for release, it gets merged into `main`
3. **Main branch is for releases only** - The `main` branch contains only released, stable code
**Before creating a PR:**
1. **Check for the latest RC branch** - Before starting work, check the repository for the current RC branch:
```bash
git fetch upstream
git branch -r | grep rc
```
2. **Base your work on the RC branch** - Create your feature branch from the latest RC branch, not from `main`:
```bash
# Find the latest RC branch (e.g., v0.11.0rc)
git checkout upstream/v0.11.0rc
git checkout -b feature/your-feature-name
```
3. **Target the RC branch in your PR** - When opening your pull request, set the base branch to the current RC branch, not `main`
**Example workflow:**
```bash
# 1. Fetch latest changes
git fetch upstream
# 2. Check for RC branches
git branch -r | grep rc
# Output: upstream/v0.11.0rc
# 3. Create your branch from the RC
git checkout -b feature/add-dark-mode upstream/v0.11.0rc
# 4. Make your changes and commit
git commit -m "feat: Add dark mode support"
# 5. Push to your fork
git push origin feature/add-dark-mode
# 6. Open PR targeting the RC branch (v0.11.0rc), NOT main
```
**Important:** PRs opened directly against `main` will be asked to retarget to the current RC branch.
### Branch Naming Convention
We use a consistent branch naming pattern to keep our repository organized:
@@ -275,14 +329,14 @@ Follow these steps to submit your contribution:
#### 1. Prepare Your Changes
Ensure you've synced with the latest upstream changes:
Ensure you've synced with the latest upstream changes from the RC branch:
```bash
# Fetch latest changes from upstream
git fetch upstream
# Rebase your branch on main (if needed)
git rebase upstream/main
# Rebase your branch on the current RC branch (if needed)
git rebase upstream/v0.11.0rc # Use the current RC branch name
```
#### 2. Run Pre-submission Checks
@@ -314,18 +368,19 @@ git push origin feature/your-feature-name
1. Go to your fork on GitHub
2. Click "Compare & pull request" for your branch
3. Ensure the base repository is `AutoMaker-Org/automaker` and base branch is `main`
3. **Important:** Set the base repository to `AutoMaker-Org/automaker` and the base branch to the **current RC branch** (e.g., `v0.11.0rc`), not `main`
4. Fill out the PR template completely
#### PR Requirements Checklist
Your PR should include:
- [ ] **Targets the current RC branch** (not `main`) - see [Branching Strategy](#branching-strategy-rc-branches)
- [ ] **Clear title** describing the change (use conventional commit format)
- [ ] **Description** explaining what changed and why
- [ ] **Link to related issue** (if applicable): `Closes #123` or `Fixes #456`
- [ ] **All CI checks passing** (format, lint, build, tests)
- [ ] **No merge conflicts** with main branch
- [ ] **No merge conflicts** with the RC branch
- [ ] **Tests included** for new functionality
- [ ] **Documentation updated** if adding/changing public APIs

253
DEVELOPMENT_WORKFLOW.md Normal file
View File

@@ -0,0 +1,253 @@
# Development Workflow
This document defines the standard workflow for keeping a branch in sync with the upstream
release candidate (RC) and for shipping feature work. It is paired with `check-sync.sh`.
## Quick Decision Rule
1. Ask the user to select a workflow:
- **Sync Workflow** → you are maintaining the current RC branch with fixes/improvements
and will push the same fixes to both origin and upstream RC when you have local
commits to publish.
- **PR Workflow** → you are starting new feature work on a new branch; upstream updates
happen via PR only.
2. After the user selects, run:
```bash
./check-sync.sh
```
3. Use the status output to confirm alignment. If it reports **diverged**, default to
merging `upstream/<TARGET_RC>` into the current branch and preserving local commits.
For Sync Workflow, when the working tree is clean and you are behind upstream RC,
proceed with the fetch + merge without asking for additional confirmation.
## Target RC Resolution
The target RC is resolved dynamically so the workflow stays current as the RC changes.
Resolution order:
1. Latest `upstream/v*rc` branch (auto-detected)
2. `upstream/HEAD` (fallback)
3. If neither is available, you must pass `--rc <branch>`
Override for a single run:
```bash
./check-sync.sh --rc <rc-branch>
```
## Pre-Flight Checklist
1. Confirm a clean working tree:
```bash
git status
```
2. Confirm the current branch:
```bash
git branch --show-current
```
3. Ensure remotes exist (origin + upstream):
```bash
git remote -v
```
## Sync Workflow (Upstream Sync)
Use this flow when you are updating the current branch with fixes or improvements and
intend to keep origin and upstream RC in lockstep.
1. **Check sync status**
```bash
./check-sync.sh
```
2. **Update from upstream RC before editing (no pulls)**
- **Behind upstream RC** → fetch and merge RC into your branch:
```bash
git fetch upstream
git merge upstream/<TARGET_RC> --no-edit
```
When the working tree is clean and the user selected Sync Workflow, proceed without
an extra confirmation prompt.
- **Diverged** → stop and resolve manually.
3. **Resolve conflicts if needed**
- Handle conflicts intelligently: preserve upstream behavior and your local intent.
4. **Make changes and commit (if you are delivering fixes)**
```bash
git add -A
git commit -m "type: description"
```
5. **Build to verify**
```bash
npm run build:packages
npm run build
```
6. **Push after a successful merge to keep remotes aligned**
- If you only merged upstream RC changes, push **origin only** to sync your fork:
```bash
git push origin <branch>
```
- If you have local fixes to publish, push **origin + upstream**:
```bash
git push origin <branch>
git push upstream <branch>:<TARGET_RC>
```
- Always ask the user which push to perform.
- Origin (origin-only sync):
```bash
git push origin <branch>
```
- Upstream RC (publish the same fixes when you have local commits):
```bash
git push upstream <branch>:<TARGET_RC>
```
7. **Re-check sync**
```bash
./check-sync.sh
```
## PR Workflow (Feature Work)
Use this flow only for new feature work on a new branch. Do not push to upstream RC.
1. **Create or switch to a feature branch**
```bash
git checkout -b <branch>
```
2. **Make changes and commit**
```bash
git add -A
git commit -m "type: description"
```
3. **Merge upstream RC before shipping**
```bash
git merge upstream/<TARGET_RC> --no-edit
```
4. **Build and/or test**
```bash
npm run build:packages
npm run build
```
5. **Push to origin**
```bash
git push -u origin <branch>
```
6. **Create or update the PR**
- Use `gh pr create` or the GitHub UI.
7. **Review and follow-up**
- Apply feedback, commit changes, and push again.
- Re-run `./check-sync.sh` if additional upstream sync is needed.
## Conflict Resolution Checklist
1. Identify which changes are from upstream vs. local.
2. Preserve both behaviors where possible; avoid dropping either side.
3. Prefer minimal, safe integrations over refactors.
4. Re-run build commands after resolving conflicts.
5. Re-run `./check-sync.sh` to confirm status.
## Build/Test Matrix
- **Sync Workflow**: `npm run build:packages` and `npm run build`.
- **PR Workflow**: `npm run build:packages` and `npm run build` (plus relevant tests).
## Post-Sync Verification
1. `git status` should be clean.
2. `./check-sync.sh` should show expected alignment.
3. Verify recent commits with:
```bash
git log --oneline -5
```
## check-sync.sh Usage
- Uses dynamic Target RC resolution (see above).
- Override target RC:
```bash
./check-sync.sh --rc <rc-branch>
```
- Optional preview limit:
```bash
./check-sync.sh --preview 10
```
- The script prints sync status for both origin and upstream and previews recent commits
when you are behind.
## Stop Conditions
Stop and ask for guidance if any of the following are true:
- The working tree is dirty and you are about to merge or push.
- `./check-sync.sh` reports **diverged** during PR Workflow, or a merge cannot be completed.
- The script cannot resolve a target RC and requests `--rc`.
- A build fails after sync or conflict resolution.
## AI Agent Guardrails
- Always run `./check-sync.sh` before merges or pushes.
- Always ask for explicit user approval before any push command.
- Do not ask for additional confirmation before a Sync Workflow fetch + merge when the
working tree is clean and the user has already selected the Sync Workflow.
- Choose Sync vs PR workflow based on intent (RC maintenance vs new feature work), not
on the script's workflow hint.
- Only use force push when the user explicitly requests a history rewrite.
- Ask for explicit approval before dependency installs, branch deletion, or destructive operations.
- When resolving merge conflicts, preserve both upstream changes and local intent where possible.
- Do not create or switch to new branches unless the user explicitly requests it.
## AI Agent Decision Guidance
Agents should provide concrete, task-specific suggestions instead of repeatedly asking
open-ended questions. Use the user's stated goal and the `./check-sync.sh` status to
propose a default path plus one or two alternatives, and only ask for confirmation when
an action requires explicit approval.
Default behavior:
- If the intent is RC maintenance, recommend the Sync Workflow and proceed with
safe preparation steps (status checks, previews). If the branch is behind upstream RC,
fetch and merge without additional confirmation when the working tree is clean, then
push to origin to keep the fork aligned. Push upstream only when there are local fixes
to publish.
- If the intent is new feature work, recommend the PR Workflow and proceed with safe
preparation steps (status checks, identifying scope). Ask for approval before merges,
pushes, or dependency installs.
- If `./check-sync.sh` reports **diverged** during Sync Workflow, merge
`upstream/<TARGET_RC>` into the current branch and preserve local commits.
- If `./check-sync.sh` reports **diverged** during PR Workflow, stop and ask for guidance
with a short explanation of the divergence and the minimal options to resolve it.
If the user's intent is RC maintenance, prefer the Sync Workflow regardless of the
script hint. When the intent is new feature work, use the PR Workflow and avoid upstream
RC pushes.
Suggestion format (keep it short):
- **Recommended**: one sentence with the default path and why it fits the task.
- **Alternatives**: one or two options with the tradeoff or prerequisite.
- **Approval points**: mention any upcoming actions that need explicit approval (exclude sync
workflow pushes and merges).
## Failure Modes and How to Avoid Them
Sync Workflow:
- Wrong RC target: verify the auto-detected RC in `./check-sync.sh` output before merging.
- Diverged from upstream RC: stop and resolve manually before any merge or push.
- Dirty working tree: commit or stash before syncing to avoid accidental merges.
- Missing remotes: ensure both `origin` and `upstream` are configured before syncing.
- Build breaks after sync: run `npm run build:packages` and `npm run build` before pushing.
PR Workflow:
- Branch not synced to current RC: re-run `./check-sync.sh` and merge RC before shipping.
- Pushing the wrong branch: confirm `git branch --show-current` before pushing.
- Unreviewed changes: always commit and push to origin before opening or updating a PR.
- Skipped tests/builds: run the build commands before declaring the PR ready.
## Notes
- Avoid merging with uncommitted changes; commit or stash first.
- Prefer merge over rebase for PR branches; rebases rewrite history and often require a force push,
which should only be done with an explicit user request.
- Use clear, conventional commit messages and split unrelated changes into separate commits.

View File

@@ -59,9 +59,22 @@ FROM node:22-slim AS server
ARG GIT_COMMIT_SHA=unknown
LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}"
# Build arguments for user ID matching (allows matching host user for mounted volumes)
# Override at build time: docker build --build-arg UID=$(id -u) --build-arg GID=$(id -g) ...
ARG UID=1001
ARG GID=1001
# Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch)
# Also install Playwright/Chromium system dependencies (aligns with playwright install-deps on Debian/Ubuntu)
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl bash gosu ca-certificates openssh-client \
# Playwright/Chromium dependencies
libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
libcups2 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 \
libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \
libx11-6 libx11-xcb1 libxcb1 libxext6 libxrender1 libxss1 libxtst6 \
libxshmfence1 libgtk-3-0 libexpat1 libfontconfig1 fonts-liberation \
xdg-utils libpangocairo-1.0-0 libpangoft2-1.0-0 libu2f-udev libvulkan1 \
&& GH_VERSION="2.63.2" \
&& ARCH=$(uname -m) \
&& case "$ARCH" in \
@@ -79,8 +92,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN npm install -g @anthropic-ai/claude-code
# Create non-root user with home directory BEFORE installing Cursor CLI
RUN groupadd -g 1001 automaker && \
useradd -u 1001 -g automaker -m -d /home/automaker -s /bin/bash automaker && \
# Uses UID/GID build args to match host user for mounted volume permissions
# Use -o flag to allow non-unique IDs (GID 1000 may already exist as 'node' group)
RUN groupadd -o -g ${GID} automaker && \
useradd -o -u ${UID} -g automaker -m -d /home/automaker -s /bin/bash automaker && \
mkdir -p /home/automaker/.local/bin && \
mkdir -p /home/automaker/.cursor && \
chown -R automaker:automaker /home/automaker && \
@@ -95,6 +110,12 @@ RUN curl https://cursor.com/install -fsS | bash && \
ls -la /home/automaker/.local/bin/ && \
echo "=== PATH is: $PATH ===" && \
(which cursor-agent && cursor-agent --version) || echo "cursor-agent installed (may need auth setup)"
# Install OpenCode CLI (for multi-provider AI model access)
RUN curl -fsSL https://opencode.ai/install | bash && \
echo "=== Checking OpenCode CLI installation ===" && \
ls -la /home/automaker/.local/bin/ && \
(which opencode && opencode --version) || echo "opencode installed (may need auth setup)"
USER root
# Add PATH to profile so it's available in all interactive shells (for login shells)

View File

@@ -8,9 +8,17 @@
FROM node:22-slim
# Install build dependencies for native modules (node-pty) and runtime tools
# Also install Playwright/Chromium system dependencies (aligns with playwright install-deps on Debian/Ubuntu)
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
git curl bash gosu ca-certificates openssh-client \
# Playwright/Chromium dependencies
libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
libcups2 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 \
libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \
libx11-6 libx11-xcb1 libxcb1 libxext6 libxrender1 libxss1 libxtst6 \
libxshmfence1 libgtk-3-0 libexpat1 libfontconfig1 fonts-liberation \
xdg-utils libpangocairo-1.0-0 libpangoft2-1.0-0 libu2f-udev libvulkan1 \
&& GH_VERSION="2.63.2" \
&& ARCH=$(uname -m) \
&& case "$ARCH" in \
@@ -27,9 +35,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Install Claude CLI globally
RUN npm install -g @anthropic-ai/claude-code
# Create non-root user
RUN groupadd -g 1001 automaker && \
useradd -u 1001 -g automaker -m -d /home/automaker -s /bin/bash automaker && \
# Build arguments for user ID matching (allows matching host user for mounted volumes)
# Override at build time: docker-compose build --build-arg UID=$(id -u) --build-arg GID=$(id -g)
ARG UID=1001
ARG GID=1001
# Create non-root user with configurable UID/GID
# Use -o flag to allow non-unique IDs (GID 1000 may already exist as 'node' group)
RUN groupadd -o -g ${GID} automaker && \
useradd -o -u ${UID} -g automaker -m -d /home/automaker -s /bin/bash automaker && \
mkdir -p /home/automaker/.local/bin && \
mkdir -p /home/automaker/.cursor && \
chown -R automaker:automaker /home/automaker && \

View File

@@ -67,6 +67,7 @@ import { createPipelineRoutes } from './routes/pipeline/index.js';
import { pipelineService } from './services/pipeline-service.js';
import { createIdeationRoutes } from './routes/ideation/index.js';
import { IdeationService } from './services/ideation-service.js';
import { getDevServerService } from './services/dev-server-service.js';
// Load environment variables
dotenv.config();
@@ -176,6 +177,10 @@ const codexUsageService = new CodexUsageService(codexAppServerService);
const mcpTestService = new MCPTestService(settingsService);
const ideationService = new IdeationService(events, settingsService, featureLoader);
// Initialize DevServerService with event emitter for real-time log streaming
const devServerService = getDevServerService();
devServerService.setEventEmitter(events);
// Initialize services
(async () => {
await agentService.initialize();
@@ -217,7 +222,7 @@ app.use('/api/sessions', createSessionsRoutes(agentService));
app.use('/api/features', createFeaturesRoutes(featureLoader));
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes(events));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
app.use('/api/git', createGitRoutes());
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
app.use('/api/models', createModelsRoutes());

View File

@@ -129,10 +129,30 @@ export const TOOL_PRESETS = {
specGeneration: ['Read', 'Glob', 'Grep'] as const,
/** Full tool access for feature implementation */
fullAccess: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch', 'TodoWrite'] as const,
fullAccess: [
'Read',
'Write',
'Edit',
'Glob',
'Grep',
'Bash',
'WebSearch',
'WebFetch',
'TodoWrite',
] as const,
/** Tools for chat/interactive mode */
chat: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch', 'TodoWrite'] as const,
chat: [
'Read',
'Write',
'Edit',
'Glob',
'Grep',
'Bash',
'WebSearch',
'WebFetch',
'TodoWrite',
] as const,
} as const;
/**

View File

@@ -8,12 +8,28 @@ import type { Request, Response, NextFunction } from 'express';
import { validatePath, PathNotAllowedError } from '@automaker/platform';
/**
* Creates a middleware that validates specified path parameters in req.body
* Helper to get parameter value from request (checks body first, then query)
*/
function getParamValue(req: Request, paramName: string): unknown {
// Check body first (for POST/PUT/PATCH requests)
if (req.body && req.body[paramName] !== undefined) {
return req.body[paramName];
}
// Fall back to query params (for GET requests)
if (req.query && req.query[paramName] !== undefined) {
return req.query[paramName];
}
return undefined;
}
/**
* Creates a middleware that validates specified path parameters in req.body or req.query
* @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath')
* @example
* router.post('/create', validatePathParams('projectPath'), handler);
* router.post('/delete', validatePathParams('projectPath', 'worktreePath'), handler);
* router.post('/send', validatePathParams('workingDirectory?', 'imagePaths[]'), handler);
* router.get('/logs', validatePathParams('worktreePath'), handler); // Works with query params too
*
* Special syntax:
* - 'paramName?' - Optional parameter (only validated if present)
@@ -26,8 +42,8 @@ export function validatePathParams(...paramNames: string[]) {
// Handle optional parameters (paramName?)
if (paramName.endsWith('?')) {
const actualName = paramName.slice(0, -1);
const value = req.body[actualName];
if (value) {
const value = getParamValue(req, actualName);
if (value && typeof value === 'string') {
validatePath(value);
}
continue;
@@ -36,18 +52,20 @@ export function validatePathParams(...paramNames: string[]) {
// Handle array parameters (paramName[])
if (paramName.endsWith('[]')) {
const actualName = paramName.slice(0, -2);
const values = req.body[actualName];
const values = getParamValue(req, actualName);
if (Array.isArray(values) && values.length > 0) {
for (const value of values) {
validatePath(value);
if (typeof value === 'string') {
validatePath(value);
}
}
}
continue;
}
// Handle regular parameters
const value = req.body[paramName];
if (value) {
const value = getParamValue(req, paramName);
if (value && typeof value === 'string') {
validatePath(value);
}
}

View File

@@ -22,6 +22,8 @@ import type {
// Only these vars are passed - nothing else from process.env leaks through.
const ALLOWED_ENV_VARS = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
'PATH',
'HOME',
'SHELL',
@@ -99,6 +101,8 @@ export class ClaudeProvider extends BaseProvider {
...(maxThinkingTokens && { maxThinkingTokens }),
// Subagents configuration for specialized task delegation
...(options.agents && { agents: options.agents }),
// Pass through outputFormat for structured JSON outputs
...(options.outputFormat && { outputFormat: options.outputFormat }),
};
// Build prompt payload

View File

@@ -26,22 +26,22 @@
* ```
*/
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { BaseProvider } from './base-provider.js';
import type { ProviderConfig, ExecuteOptions, ProviderMessage } from './types.js';
import {
spawnJSONLProcess,
type SubprocessOptions,
isWslAvailable,
findCliInWsl,
createWslCommand,
findCliInWsl,
isWslAvailable,
spawnJSONLProcess,
windowsToWslPath,
type SubprocessOptions,
type WslCliResult,
} from '@automaker/platform';
import { createLogger, isAbortError } from '@automaker/utils';
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { BaseProvider } from './base-provider.js';
import type { ExecuteOptions, ProviderConfig, ProviderMessage } from './types.js';
/**
* Spawn strategy for CLI tools on Windows
@@ -522,8 +522,13 @@ export abstract class CliProvider extends BaseProvider {
throw new Error(`${this.getCliName()} CLI not found. ${this.getInstallInstructions()}`);
}
const cliArgs = this.buildCliArgs(options);
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
// Many CLI-based providers do not support a separate "system" message.
// If a systemPrompt is provided, embed it into the prompt so downstream models
// still receive critical formatting/schema instructions (e.g., JSON-only outputs).
const effectiveOptions = this.embedSystemPromptIntoPrompt(options);
const cliArgs = this.buildCliArgs(effectiveOptions);
const subprocessOptions = this.buildSubprocessOptions(effectiveOptions, cliArgs);
try {
for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
@@ -555,4 +560,52 @@ export abstract class CliProvider extends BaseProvider {
throw error;
}
}
/**
* Embed system prompt text into the user prompt for CLI providers.
*
* Most CLI providers we integrate with only accept a single prompt via stdin/args.
* When upstream code supplies `options.systemPrompt`, we prepend it to the prompt
* content and clear `systemPrompt` to avoid any accidental double-injection by
* subclasses.
*/
protected embedSystemPromptIntoPrompt(options: ExecuteOptions): ExecuteOptions {
if (!options.systemPrompt) {
return options;
}
// Only string system prompts can be reliably embedded for CLI providers.
// Presets are provider-specific (e.g., Claude SDK) and cannot be represented
// universally. If a preset is provided, we only embed its optional `append`.
const systemText =
typeof options.systemPrompt === 'string'
? options.systemPrompt
: options.systemPrompt.append
? options.systemPrompt.append
: '';
if (!systemText) {
return { ...options, systemPrompt: undefined };
}
// Preserve original prompt structure.
if (typeof options.prompt === 'string') {
return {
...options,
prompt: `${systemText}\n\n---\n\n${options.prompt}`,
systemPrompt: undefined,
};
}
if (Array.isArray(options.prompt)) {
return {
...options,
prompt: [{ type: 'text', text: systemText }, ...options.prompt],
systemPrompt: undefined,
};
}
// Should be unreachable due to ExecuteOptions typing, but keep safe.
return { ...options, systemPrompt: undefined };
}
}

View File

@@ -45,6 +45,7 @@ import {
getCodexTodoToolName,
} from './codex-tool-mapping.js';
import { SettingsService } from '../services/settings-service.js';
import { createTempEnvOverride } from '../lib/auth-utils.js';
import { checkSandboxCompatibility } from '../lib/sdk-options.js';
import { CODEX_MODELS } from './codex-models.js';
@@ -142,6 +143,7 @@ type CodexExecutionMode = typeof CODEX_EXECUTION_MODE_CLI | typeof CODEX_EXECUTI
type CodexExecutionPlan = {
mode: CodexExecutionMode;
cliPath: string | null;
openAiApiKey?: string | null;
};
const ALLOWED_ENV_VARS = [
@@ -166,6 +168,22 @@ function buildEnv(): Record<string, string> {
return env;
}
async function resolveOpenAiApiKey(): Promise<string | null> {
const envKey = process.env[OPENAI_API_KEY_ENV];
if (envKey) {
return envKey;
}
try {
const settingsService = new SettingsService(getCodexSettingsDir());
const credentials = await settingsService.getCredentials();
const storedKey = credentials.apiKeys.openai?.trim();
return storedKey ? storedKey : null;
} catch {
return null;
}
}
function hasMcpServersConfigured(options: ExecuteOptions): boolean {
return Boolean(options.mcpServers && Object.keys(options.mcpServers).length > 0);
}
@@ -181,18 +199,21 @@ function isSdkEligible(options: ExecuteOptions): boolean {
async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<CodexExecutionPlan> {
const cliPath = await findCodexCliPath();
const authIndicators = await getCodexAuthIndicators();
const hasApiKey = Boolean(process.env[OPENAI_API_KEY_ENV]);
const openAiApiKey = await resolveOpenAiApiKey();
const hasApiKey = Boolean(openAiApiKey);
const cliAuthenticated = authIndicators.hasOAuthToken || authIndicators.hasApiKey || hasApiKey;
const sdkEligible = isSdkEligible(options);
const cliAvailable = Boolean(cliPath);
if (hasApiKey) {
return {
mode: CODEX_EXECUTION_MODE_SDK,
cliPath,
openAiApiKey,
};
}
if (sdkEligible) {
if (hasApiKey) {
return {
mode: CODEX_EXECUTION_MODE_SDK,
cliPath,
};
}
if (!cliAvailable) {
throw new Error(ERROR_CODEX_SDK_AUTH_REQUIRED);
}
@@ -209,6 +230,7 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<Codex
return {
mode: CODEX_EXECUTION_MODE_CLI,
cliPath,
openAiApiKey,
};
}
@@ -701,7 +723,14 @@ export class CodexProvider extends BaseProvider {
const executionPlan = await resolveCodexExecutionPlan(options);
if (executionPlan.mode === CODEX_EXECUTION_MODE_SDK) {
yield* executeCodexSdkQuery(options, combinedSystemPrompt);
const cleanupEnv = executionPlan.openAiApiKey
? createTempEnvOverride({ [OPENAI_API_KEY_ENV]: executionPlan.openAiApiKey })
: null;
try {
yield* executeCodexSdkQuery(options, combinedSystemPrompt);
} finally {
cleanupEnv?.();
}
return;
}
@@ -780,11 +809,16 @@ export class CodexProvider extends BaseProvider {
'-', // Read prompt from stdin to avoid shell escaping issues
];
const envOverrides = buildEnv();
if (executionPlan.openAiApiKey && !envOverrides[OPENAI_API_KEY_ENV]) {
envOverrides[OPENAI_API_KEY_ENV] = executionPlan.openAiApiKey;
}
const stream = spawnJSONLProcess({
command: commandPath,
args,
cwd: options.cwd,
env: buildEnv(),
env: envOverrides,
abortController: options.abortController,
timeout: DEFAULT_TIMEOUT_MS,
stdinData: promptText, // Pass prompt via stdin
@@ -971,7 +1005,7 @@ export class CodexProvider extends BaseProvider {
async detectInstallation(): Promise<InstallationStatus> {
const cliPath = await findCodexCliPath();
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
const hasApiKey = Boolean(await resolveOpenAiApiKey());
const authIndicators = await getCodexAuthIndicators();
const installed = !!cliPath;
@@ -1013,7 +1047,7 @@ export class CodexProvider extends BaseProvider {
*/
async checkAuth(): Promise<CodexAuthStatus> {
const cliPath = await findCodexCliPath();
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
const hasApiKey = Boolean(await resolveOpenAiApiKey());
const authIndicators = await getCodexAuthIndicators();
// Check for API key in environment

View File

@@ -30,3 +30,11 @@ export { OpencodeProvider } from './opencode-provider.js';
// Provider factory
export { ProviderFactory } from './provider-factory.js';
// Simple query service - unified interface for basic AI queries
export { simpleQuery, streamingQuery } from './simple-query-service.js';
export type {
SimpleQueryOptions,
SimpleQueryResult,
StreamingQueryOptions,
} from './simple-query-service.js';

View File

@@ -730,7 +730,7 @@ export class OpencodeProvider extends CliProvider {
if (this.detectedStrategy === 'npx') {
// NPX strategy: execute npx with opencode-ai package
command = 'npx';
command = process.platform === 'win32' ? 'npx.cmd' : 'npx';
args = ['opencode-ai@latest', 'models'];
opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`);
} else if (this.useWsl && this.wslCliPath) {
@@ -751,6 +751,8 @@ export class OpencodeProvider extends CliProvider {
encoding: 'utf-8',
timeout: 30000,
windowsHide: true,
// Use shell on Windows for .cmd files
shell: process.platform === 'win32' && command.endsWith('.cmd'),
});
opencodeLogger.debug(
@@ -963,7 +965,7 @@ export class OpencodeProvider extends CliProvider {
if (this.detectedStrategy === 'npx') {
// NPX strategy
command = 'npx';
command = process.platform === 'win32' ? 'npx.cmd' : 'npx';
args = ['opencode-ai@latest', 'auth', 'list'];
opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`);
} else if (this.useWsl && this.wslCliPath) {
@@ -984,6 +986,8 @@ export class OpencodeProvider extends CliProvider {
encoding: 'utf-8',
timeout: 15000,
windowsHide: true,
// Use shell on Windows for .cmd files
shell: process.platform === 'win32' && command.endsWith('.cmd'),
});
opencodeLogger.debug(

View File

@@ -0,0 +1,254 @@
/**
* Simple Query Service - Simplified interface for basic AI queries
*
* Use this for routes that need simple text responses without
* complex event handling. This service abstracts away the provider
* selection and streaming details, providing a clean interface
* for common query patterns.
*
* Benefits:
* - No direct SDK imports needed in route files
* - Consistent provider routing based on model
* - Automatic text extraction from streaming responses
* - Structured output support for JSON schema responses
* - Eliminates duplicate extractTextFromStream() functions
*/
import { ProviderFactory } from './provider-factory.js';
import type {
ProviderMessage,
ContentBlock,
ThinkingLevel,
ReasoningEffort,
} from '@automaker/types';
import { stripProviderPrefix } from '@automaker/types';
/**
* Options for simple query execution
*/
export interface SimpleQueryOptions {
/** The prompt to send to the AI (can be text or multi-part content) */
prompt: string | Array<{ type: string; text?: string; source?: object }>;
/** Model to use (with or without provider prefix) */
model?: string;
/** Working directory for the query */
cwd: string;
/** System prompt (combined with user prompt for some providers) */
systemPrompt?: string;
/** Maximum turns for agentic operations (default: 1) */
maxTurns?: number;
/** Tools to allow (default: [] for simple queries) */
allowedTools?: string[];
/** Abort controller for cancellation */
abortController?: AbortController;
/** Structured output format for JSON responses */
outputFormat?: {
type: 'json_schema';
schema: Record<string, unknown>;
};
/** Thinking level for Claude models */
thinkingLevel?: ThinkingLevel;
/** Reasoning effort for Codex/OpenAI models */
reasoningEffort?: ReasoningEffort;
/** If true, runs in read-only mode (no file writes) */
readOnly?: boolean;
/** Setting sources for CLAUDE.md loading */
settingSources?: Array<'user' | 'project' | 'local'>;
}
/**
* Result from a simple query
*/
export interface SimpleQueryResult {
/** The accumulated text response */
text: string;
/** Structured output if outputFormat was specified and provider supports it */
structured_output?: Record<string, unknown>;
}
/**
* Options for streaming query execution
*/
export interface StreamingQueryOptions extends SimpleQueryOptions {
/** Callback for each text chunk received */
onText?: (text: string) => void;
/** Callback for tool use events */
onToolUse?: (tool: string, input: unknown) => void;
/** Callback for thinking blocks (if available) */
onThinking?: (thinking: string) => void;
}
/**
* Default model to use when none specified
*/
const DEFAULT_MODEL = 'claude-sonnet-4-20250514';
/**
* Execute a simple query and return the text result
*
* Use this for simple, non-streaming queries where you just need
* the final text response. For more complex use cases with progress
* callbacks, use streamingQuery() instead.
*
* @example
* ```typescript
* const result = await simpleQuery({
* prompt: 'Generate a title for: user authentication',
* cwd: process.cwd(),
* systemPrompt: 'You are a title generator...',
* maxTurns: 1,
* allowedTools: [],
* });
* console.log(result.text); // "Add user authentication"
* ```
*/
export async function simpleQuery(options: SimpleQueryOptions): Promise<SimpleQueryResult> {
const model = options.model || DEFAULT_MODEL;
const provider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
let responseText = '';
let structuredOutput: Record<string, unknown> | undefined;
// Build provider options
const providerOptions = {
prompt: options.prompt,
model: bareModel,
originalModel: model,
cwd: options.cwd,
systemPrompt: options.systemPrompt,
maxTurns: options.maxTurns ?? 1,
allowedTools: options.allowedTools ?? [],
abortController: options.abortController,
outputFormat: options.outputFormat,
thinkingLevel: options.thinkingLevel,
reasoningEffort: options.reasoningEffort,
readOnly: options.readOnly,
settingSources: options.settingSources,
};
for await (const msg of provider.executeQuery(providerOptions)) {
// Handle error messages
if (msg.type === 'error') {
const errorMessage = msg.error || 'Provider returned an error';
throw new Error(errorMessage);
}
// Extract text from assistant messages
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
}
// Handle result messages
if (msg.type === 'result') {
if (msg.subtype === 'success') {
// Use result text if longer than accumulated text
if (msg.result && msg.result.length > responseText.length) {
responseText = msg.result;
}
// Capture structured output if present
if (msg.structured_output) {
structuredOutput = msg.structured_output;
}
} else if (msg.subtype === 'error_max_turns') {
// Max turns reached - return what we have
break;
} else if (msg.subtype === 'error_max_structured_output_retries') {
throw new Error('Could not produce valid structured output after retries');
}
}
}
return { text: responseText, structured_output: structuredOutput };
}
/**
* Execute a streaming query with event callbacks
*
* Use this for queries where you need real-time progress updates,
* such as when displaying streaming output to a user.
*
* @example
* ```typescript
* const result = await streamingQuery({
* prompt: 'Analyze this project and suggest improvements',
* cwd: '/path/to/project',
* maxTurns: 250,
* allowedTools: ['Read', 'Glob', 'Grep'],
* onText: (text) => emitProgress(text),
* onToolUse: (tool, input) => emitToolUse(tool, input),
* });
* ```
*/
export async function streamingQuery(options: StreamingQueryOptions): Promise<SimpleQueryResult> {
const model = options.model || DEFAULT_MODEL;
const provider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
let responseText = '';
let structuredOutput: Record<string, unknown> | undefined;
// Build provider options
const providerOptions = {
prompt: options.prompt,
model: bareModel,
originalModel: model,
cwd: options.cwd,
systemPrompt: options.systemPrompt,
maxTurns: options.maxTurns ?? 250,
allowedTools: options.allowedTools ?? ['Read', 'Glob', 'Grep'],
abortController: options.abortController,
outputFormat: options.outputFormat,
thinkingLevel: options.thinkingLevel,
reasoningEffort: options.reasoningEffort,
readOnly: options.readOnly,
settingSources: options.settingSources,
};
for await (const msg of provider.executeQuery(providerOptions)) {
// Handle error messages
if (msg.type === 'error') {
const errorMessage = msg.error || 'Provider returned an error';
throw new Error(errorMessage);
}
// Extract content from assistant messages
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
options.onText?.(block.text);
} else if (block.type === 'tool_use' && block.name) {
options.onToolUse?.(block.name, block.input);
} else if (block.type === 'thinking' && block.thinking) {
options.onThinking?.(block.thinking);
}
}
}
// Handle result messages
if (msg.type === 'result') {
if (msg.subtype === 'success') {
// Use result text if longer than accumulated text
if (msg.result && msg.result.length > responseText.length) {
responseText = msg.result;
}
// Capture structured output if present
if (msg.structured_output) {
structuredOutput = msg.structured_output;
}
} else if (msg.subtype === 'error_max_turns') {
// Max turns reached - return what we have
break;
} else if (msg.subtype === 'error_max_structured_output_retries') {
throw new Error('Could not produce valid structured output after retries');
}
}
}
return { text: responseText, structured_output: structuredOutput };
}

View File

@@ -5,15 +5,12 @@
* (defaults to Sonnet for balanced speed and quality).
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { logAuthStatus } from './common.js';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
@@ -115,121 +112,30 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
logger.info('Using model:', model);
let responseText = '';
let messageCount = 0;
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt,
model,
cwd: projectPath,
maxTurns: 250,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
thinkingLevel,
readOnly: true, // Feature generation only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
onText: (text) => {
logger.debug(`Feature text block received (${text.length} chars)`);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: text,
projectPath: projectPath,
});
},
});
// Route to appropriate provider based on model type
if (isCursorModel(model)) {
// Use Cursor provider for Cursor models
logger.info('[FeatureGeneration] Using Cursor provider');
const responseText = result.text;
const provider = ProviderFactory.getProviderForModel(model);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(model);
// Add explicit instructions for Cursor to return JSON in response
const cursorPrompt = `${prompt}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. Return the JSON in your response only.
2. Respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
3. Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
for await (const msg of provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
cwd: projectPath,
maxTurns: 250,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
readOnly: true, // Feature generation only reads code, doesn't write
})) {
messageCount++;
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
logger.debug(`Feature text block received (${block.text.length} chars)`);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: block.text,
projectPath: projectPath,
});
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result if it's a final accumulated message
if (msg.result.length > responseText.length) {
responseText = msg.result;
}
}
}
} else {
// Use Claude SDK for Claude models
logger.info('[FeatureGeneration] Using Claude SDK');
const options = createFeatureGenerationOptions({
cwd: projectPath,
abortController,
autoLoadClaudeMd,
model,
thinkingLevel, // Pass thinking level for extended thinking
});
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
logger.info('Calling Claude Agent SDK query() for features...');
logAuthStatus('Right before SDK query() for features');
let stream;
try {
stream = query({ prompt, options });
logger.debug('query() returned stream successfully');
} catch (queryError) {
logger.error('❌ query() threw an exception:');
logger.error('Error:', queryError);
throw queryError;
}
logger.debug('Starting to iterate over feature stream...');
try {
for await (const msg of stream) {
messageCount++;
logger.debug(
`Feature stream message #${messageCount}:`,
JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2)
);
if (msg.type === 'assistant' && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
logger.debug(`Feature text block received (${block.text.length} chars)`);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: block.text,
projectPath: projectPath,
});
}
}
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
logger.debug('Received success result for features');
responseText = (msg as any).result || responseText;
} else if ((msg as { type: string }).type === 'error') {
logger.error('❌ Received error message from feature stream:');
logger.error('Error message:', JSON.stringify(msg, null, 2));
}
}
} catch (streamError) {
logger.error('❌ Error while iterating feature stream:');
logger.error('Stream error:', streamError);
throw streamError;
}
}
logger.info(`Feature stream complete. Total messages: ${messageCount}`);
logger.info(`Feature stream complete.`);
logger.info(`Feature response length: ${responseText.length} chars`);
logger.info('========== FULL RESPONSE TEXT ==========');
logger.info(responseText);

View File

@@ -5,8 +5,6 @@
* (defaults to Opus for high-quality specification generation).
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
import path from 'path';
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import {
@@ -16,12 +14,10 @@ import {
type SpecOutput,
} from '../../lib/app-spec-format.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
import { extractJson } from '../../lib/json-extractor.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { logAuthStatus } from './common.js';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
@@ -109,21 +105,15 @@ ${getStructuredSpecPromptInstruction()}`;
logger.info('Using model:', model);
let responseText = '';
let messageCount = 0;
let structuredOutput: SpecOutput | null = null;
// Route to appropriate provider based on model type
if (isCursorModel(model)) {
// Use Cursor provider for Cursor models
logger.info('[SpecGeneration] Using Cursor provider');
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
const useStructuredOutput = !isCursorModel(model);
const provider = ProviderFactory.getProviderForModel(model);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(model);
// For Cursor, include the JSON schema in the prompt with clear instructions
// to return JSON in the response (not write to a file)
const cursorPrompt = `${prompt}
// Build the final prompt - for Cursor, include JSON schema instructions
let finalPrompt = prompt;
if (!useStructuredOutput) {
finalPrompt = `${prompt}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. DO NOT create any files like "project_specification.json".
@@ -133,153 +123,57 @@ CRITICAL INSTRUCTIONS:
${JSON.stringify(specOutputSchema, null, 2)}
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
for await (const msg of provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
cwd: projectPath,
maxTurns: 250,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
readOnly: true, // Spec generation only reads code, we write the spec ourselves
})) {
messageCount++;
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
logger.info(
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: block.text,
projectPath: projectPath,
});
} else if (block.type === 'tool_use') {
logger.info('Tool use:', block.name);
events.emit('spec-regeneration:event', {
type: 'spec_tool',
tool: block.name,
input: block.input,
});
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result if it's a final accumulated message
if (msg.result.length > responseText.length) {
responseText = msg.result;
}
}
}
// Parse JSON from the response text using shared utility
if (responseText) {
structuredOutput = extractJson<SpecOutput>(responseText, { logger });
}
} else {
// Use Claude SDK for Claude models
logger.info('[SpecGeneration] Using Claude SDK');
const options = createSpecGenerationOptions({
cwd: projectPath,
abortController,
autoLoadClaudeMd,
model,
thinkingLevel, // Pass thinking level for extended thinking
outputFormat: {
type: 'json_schema',
schema: specOutputSchema,
},
});
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
logger.info('Calling Claude Agent SDK query()...');
// Log auth status right before the SDK call
logAuthStatus('Right before SDK query()');
let stream;
try {
stream = query({ prompt, options });
logger.debug('query() returned stream successfully');
} catch (queryError) {
logger.error('❌ query() threw an exception:');
logger.error('Error:', queryError);
throw queryError;
}
logger.info('Starting to iterate over stream...');
try {
for await (const msg of stream) {
messageCount++;
logger.info(
`Stream message #${messageCount}: type=${msg.type}, subtype=${(msg as any).subtype}`
);
if (msg.type === 'assistant') {
const msgAny = msg as any;
if (msgAny.message?.content) {
for (const block of msgAny.message.content) {
if (block.type === 'text') {
responseText += block.text;
logger.info(
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: block.text,
projectPath: projectPath,
});
} else if (block.type === 'tool_use') {
logger.info('Tool use:', block.name);
events.emit('spec-regeneration:event', {
type: 'spec_tool',
tool: block.name,
input: block.input,
});
}
}
}
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
logger.info('Received success result');
// Check for structured output - this is the reliable way to get spec data
const resultMsg = msg as any;
if (resultMsg.structured_output) {
structuredOutput = resultMsg.structured_output as SpecOutput;
logger.info('✅ Received structured output');
logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2));
} else {
logger.warn('⚠️ No structured output in result, will fall back to text parsing');
}
} else if (msg.type === 'result') {
// Handle error result types
const subtype = (msg as any).subtype;
logger.info(`Result message: subtype=${subtype}`);
if (subtype === 'error_max_turns') {
logger.error('❌ Hit max turns limit!');
} else if (subtype === 'error_max_structured_output_retries') {
logger.error('❌ Failed to produce valid structured output after retries');
throw new Error('Could not produce valid spec output');
}
} else if ((msg as { type: string }).type === 'error') {
logger.error('❌ Received error message from stream:');
logger.error('Error message:', JSON.stringify(msg, null, 2));
} else if (msg.type === 'user') {
// Log user messages (tool results)
logger.info(`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`);
}
}
} catch (streamError) {
logger.error('❌ Error while iterating stream:');
logger.error('Stream error:', streamError);
throw streamError;
}
}
logger.info(`Stream iteration complete. Total messages: ${messageCount}`);
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt: finalPrompt,
model,
cwd: projectPath,
maxTurns: 250,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
thinkingLevel,
readOnly: true, // Spec generation only reads code, we write the spec ourselves
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
outputFormat: useStructuredOutput
? {
type: 'json_schema',
schema: specOutputSchema,
}
: undefined,
onText: (text) => {
responseText += text;
logger.info(
`Text block received (${text.length} chars), total now: ${responseText.length} chars`
);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: text,
projectPath: projectPath,
});
},
onToolUse: (tool, input) => {
logger.info('Tool use:', tool);
events.emit('spec-regeneration:event', {
type: 'spec_tool',
tool,
input,
});
},
});
// Get structured output if available
if (result.structured_output) {
structuredOutput = result.structured_output as unknown as SpecOutput;
logger.info('✅ Received structured output');
logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2));
} else if (!useStructuredOutput && responseText) {
// For non-Claude providers, parse JSON from response text
structuredOutput = extractJson<SpecOutput>(responseText, { logger });
}
logger.info(`Stream iteration complete.`);
logger.info(`Response text length: ${responseText.length} chars`);
// Determine XML content to save

View File

@@ -34,6 +34,13 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router {
error: 'Authentication required',
message: "Please run 'claude login' to authenticate",
});
} else if (message.includes('TRUST_PROMPT_PENDING')) {
// Trust prompt appeared but couldn't be auto-approved
res.status(200).json({
error: 'Trust prompt pending',
message:
'Claude CLI needs folder permission. Please run "claude" in your terminal and approve access.',
});
} else if (message.includes('timed out')) {
res.status(200).json({
error: 'Command timed out',

View File

@@ -11,13 +11,11 @@
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { PathNotAllowedError } from '@automaker/platform';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createCustomOptions } from '../../../lib/sdk-options.js';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import * as secureFs from '../../../lib/secure-fs.js';
import * as path from 'path';
import type { SettingsService } from '../../../services/settings-service.js';
@@ -49,31 +47,6 @@ interface DescribeFileErrorResponse {
error: string;
}
/**
* Extract text content from Claude SDK response messages
*/
async function extractTextFromStream(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stream: AsyncIterable<any>
): Promise<string> {
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
const blocks = msg.message.content as Array<{ type: string; text?: string }>;
for (const block of blocks) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}
return responseText;
}
/**
* Create the describe-file request handler
*
@@ -159,16 +132,14 @@ export function createDescribeFileHandler(
// Build prompt with file content passed as structured data
// The file content is included directly, not via tool invocation
const instructionText = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project").
const prompt = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project").
Respond with ONLY the description text, no additional formatting, preamble, or explanation.
File: ${fileName}${truncated ? ' (truncated)' : ''}`;
File: ${fileName}${truncated ? ' (truncated)' : ''}
const promptContent = [
{ type: 'text' as const, text: instructionText },
{ type: 'text' as const, text: `\n\n--- FILE CONTENT ---\n${contentToAnalyze}` },
];
--- FILE CONTENT ---
${contentToAnalyze}`;
// Use the file's directory as the working directory
const cwd = path.dirname(resolvedPath);
@@ -190,67 +161,19 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`);
let description: string;
// Use simpleQuery - provider abstraction handles routing to correct provider
const result = await simpleQuery({
prompt,
model,
cwd,
maxTurns: 1,
allowedTools: [],
thinkingLevel,
readOnly: true, // File description only reads, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
});
// Route to appropriate provider based on model type
if (isCursorModel(model)) {
// Use Cursor provider for Cursor models
logger.info(`Using Cursor provider for model: ${model}`);
const provider = ProviderFactory.getProviderForModel(model);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(model);
// Build a simple text prompt for Cursor (no multi-part content blocks)
const cursorPrompt = `${instructionText}\n\n--- FILE CONTENT ---\n${contentToAnalyze}`;
let responseText = '';
for await (const msg of provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
cwd,
maxTurns: 1,
allowedTools: [],
readOnly: true, // File description only reads, doesn't write
})) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
}
}
description = responseText;
} else {
// Use Claude SDK for Claude models
logger.info(`Using Claude SDK for model: ${model}`);
// Use centralized SDK options with proper cwd validation
// No tools needed since we're passing file content directly
const sdkOptions = createCustomOptions({
cwd,
model,
maxTurns: 1,
allowedTools: [],
autoLoadClaudeMd,
thinkingLevel, // Pass thinking level for extended thinking
});
const promptGenerator = (async function* () {
yield {
type: 'user' as const,
session_id: '',
message: { role: 'user' as const, content: promptContent },
parent_tool_use_id: null,
};
})();
const stream = query({ prompt: promptGenerator, options: sdkOptions });
// Extract the description from the response
description = await extractTextFromStream(stream);
}
const description = result.text;
if (!description || description.trim().length === 0) {
logger.warn('Received empty response from Claude');

View File

@@ -12,12 +12,10 @@
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger, readImageAsBase64 } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createCustomOptions } from '../../../lib/sdk-options.js';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import * as secureFs from '../../../lib/secure-fs.js';
import * as path from 'path';
import type { SettingsService } from '../../../services/settings-service.js';
@@ -178,57 +176,10 @@ function mapDescribeImageError(rawMessage: string | undefined): {
return baseResponse;
}
/**
* Extract text content from Claude SDK response messages and log high-signal stream events.
*/
async function extractTextFromStream(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stream: AsyncIterable<any>,
requestId: string
): Promise<string> {
let responseText = '';
let messageCount = 0;
logger.info(`[${requestId}] [Stream] Begin reading SDK stream...`);
for await (const msg of stream) {
messageCount++;
const msgType = msg?.type;
const msgSubtype = msg?.subtype;
// Keep this concise but informative. Full error object is logged in catch blocks.
logger.info(
`[${requestId}] [Stream] #${messageCount} type=${String(msgType)} subtype=${String(msgSubtype ?? '')}`
);
if (msgType === 'assistant' && msg.message?.content) {
const blocks = msg.message.content as Array<{ type: string; text?: string }>;
logger.info(`[${requestId}] [Stream] assistant blocks=${blocks.length}`);
for (const block of blocks) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
}
if (msgType === 'result' && msgSubtype === 'success') {
if (typeof msg.result === 'string' && msg.result.length > 0) {
responseText = msg.result;
}
}
}
logger.info(
`[${requestId}] [Stream] End of stream. messages=${messageCount} textLength=${responseText.length}`
);
return responseText;
}
/**
* Create the describe-image request handler
*
* Uses Claude SDK query with multi-part content blocks to include the image (base64),
* Uses the provider abstraction with multi-part content blocks to include the image (base64),
* matching the agent runner behavior.
*
* @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
@@ -309,27 +260,6 @@ export function createDescribeImageHandler(
`[${requestId}] image meta filename=${imageData.filename} mime=${imageData.mimeType} base64Len=${base64Length} estBytes=${estimatedBytes}`
);
// Build multi-part prompt with image block (no Read tool required)
const instructionText =
`Describe this image in 1-2 sentences suitable for use as context in an AI coding assistant. ` +
`Focus on what the image shows and its purpose (e.g., "UI mockup showing login form with email/password fields", ` +
`"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` +
`Respond with ONLY the description text, no additional formatting, preamble, or explanation.`;
const promptContent = [
{ type: 'text' as const, text: instructionText },
{
type: 'image' as const,
source: {
type: 'base64' as const,
media_type: imageData.mimeType,
data: imageData.base64,
},
},
];
logger.info(`[${requestId}] Built multi-part prompt blocks=${promptContent.length}`);
const cwd = path.dirname(actualPath);
logger.info(`[${requestId}] Using cwd=${cwd}`);
@@ -348,85 +278,59 @@ export function createDescribeImageHandler(
logger.info(`[${requestId}] Using model: ${model}`);
let description: string;
// Build the instruction text
const instructionText =
`Describe this image in 1-2 sentences suitable for use as context in an AI coding assistant. ` +
`Focus on what the image shows and its purpose (e.g., "UI mockup showing login form with email/password fields", ` +
`"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` +
`Respond with ONLY the description text, no additional formatting, preamble, or explanation.`;
// Build prompt based on provider capability
// Some providers (like Cursor) may not support image content blocks
let prompt: string | Array<{ type: string; text?: string; source?: object }>;
// Route to appropriate provider based on model type
if (isCursorModel(model)) {
// Use Cursor provider for Cursor models
// Note: Cursor may have limited support for image content blocks
logger.info(`[${requestId}] Using Cursor provider for model: ${model}`);
const provider = ProviderFactory.getProviderForModel(model);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(model);
// Build prompt with image reference for Cursor
// Note: Cursor CLI may not support base64 image blocks directly,
// so we include the image path as context
const cursorPrompt = `${instructionText}\n\nImage file: ${actualPath}\nMIME type: ${imageData.mimeType}`;
let responseText = '';
const queryStart = Date.now();
for await (const msg of provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
cwd,
maxTurns: 1,
allowedTools: ['Read'], // Allow Read tool so Cursor can read the image if needed
readOnly: true, // Image description only reads, doesn't write
})) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
}
}
logger.info(`[${requestId}] Cursor query completed in ${Date.now() - queryStart}ms`);
description = responseText;
// Cursor may not support base64 image blocks directly
// Use text prompt with image path reference
logger.info(`[${requestId}] Using text prompt for Cursor model`);
prompt = `${instructionText}\n\nImage file: ${actualPath}\nMIME type: ${imageData.mimeType}`;
} else {
// Use Claude SDK for Claude models (supports image content blocks)
logger.info(`[${requestId}] Using Claude SDK for model: ${model}`);
// Use the same centralized option builder used across the server (validates cwd)
const sdkOptions = createCustomOptions({
cwd,
model,
maxTurns: 1,
allowedTools: [],
autoLoadClaudeMd,
thinkingLevel, // Pass thinking level for extended thinking
});
logger.info(
`[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify(
sdkOptions.allowedTools
)}`
);
const promptGenerator = (async function* () {
yield {
type: 'user' as const,
session_id: '',
message: { role: 'user' as const, content: promptContent },
parent_tool_use_id: null,
};
})();
logger.info(`[${requestId}] Calling query()...`);
const queryStart = Date.now();
const stream = query({ prompt: promptGenerator, options: sdkOptions });
logger.info(`[${requestId}] query() returned stream in ${Date.now() - queryStart}ms`);
// Extract the description from the response
const extractStart = Date.now();
description = await extractTextFromStream(stream, requestId);
logger.info(`[${requestId}] extractMs=${Date.now() - extractStart}`);
// Claude and other vision-capable models support multi-part prompts with images
logger.info(`[${requestId}] Using multi-part prompt with image block`);
prompt = [
{ type: 'text', text: instructionText },
{
type: 'image',
source: {
type: 'base64',
media_type: imageData.mimeType,
data: imageData.base64,
},
},
];
}
logger.info(`[${requestId}] Calling simpleQuery...`);
const queryStart = Date.now();
// Use simpleQuery - provider abstraction handles routing
const result = await simpleQuery({
prompt,
model,
cwd,
maxTurns: 1,
allowedTools: isCursorModel(model) ? ['Read'] : [], // Allow Read for Cursor to read image if needed
thinkingLevel,
readOnly: true, // Image description only reads, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
});
logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`);
const description = result.text;
if (!description || description.trim().length === 0) {
logger.warn(`[${requestId}] Received empty response from Claude`);
logger.warn(`[${requestId}] Received empty response from AI`);
const response: DescribeImageErrorResponse = {
success: false,
error: 'Failed to generate description - empty response',

View File

@@ -1,23 +1,16 @@
/**
* POST /enhance-prompt endpoint - Enhance user input text
*
* Uses Claude AI or Cursor to enhance text based on the specified enhancement mode.
* Supports modes: improve, technical, simplify, acceptance
* Uses the provider abstraction to enhance text based on the specified
* enhancement mode. Works with any configured provider (Claude, Cursor, etc.).
* Supports modes: improve, technical, simplify, acceptance, ux-reviewer
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { resolveModelString } from '@automaker/model-resolver';
import {
CLAUDE_MODEL_MAP,
isCursorModel,
isOpencodeModel,
stripProviderPrefix,
ThinkingLevel,
getThinkingTokenBudget,
} from '@automaker/types';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
import {
@@ -38,7 +31,7 @@ interface EnhanceRequestBody {
enhancementMode: string;
/** Optional model override */
model?: string;
/** Optional thinking level for Claude models (ignored for Cursor models) */
/** Optional thinking level for Claude models */
thinkingLevel?: ThinkingLevel;
}
@@ -58,80 +51,6 @@ interface EnhanceErrorResponse {
error: string;
}
/**
* Extract text content from Claude SDK response messages
*
* @param stream - The async iterable from the query function
* @returns The extracted text content
*/
async function extractTextFromStream(
stream: AsyncIterable<{
type: string;
subtype?: string;
result?: string;
message?: {
content?: Array<{ type: string; text?: string }>;
};
}>
): Promise<string> {
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}
return responseText;
}
/**
* Execute enhancement using a provider (Cursor, OpenCode, etc.)
*
* @param prompt - The enhancement prompt
* @param model - The model to use
* @returns The enhanced text
*/
async function executeWithProvider(prompt: string, model: string): Promise<string> {
const provider = ProviderFactory.getProviderForModel(model);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(model);
let responseText = '';
for await (const msg of provider.executeQuery({
prompt,
model: bareModel,
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
readOnly: true, // Prompt enhancement only generates text, doesn't write files
})) {
if (msg.type === 'error') {
// Throw error with the message from the provider
const errorMessage = msg.error || 'Provider returned an error';
throw new Error(errorMessage);
} else if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result if it's a final accumulated message
if (msg.result.length > responseText.length) {
responseText = msg.result;
}
}
}
return responseText;
}
/**
* Create the enhance request handler
*
@@ -200,7 +119,6 @@ export function createEnhanceHandler(
logger.debug(`Using ${validMode} system prompt (length: ${systemPrompt.length} chars)`);
// Build the user prompt with few-shot examples
// This helps the model understand this is text transformation, not a coding task
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
// Resolve the model - use the passed model, default to sonnet for quality
@@ -208,47 +126,20 @@ export function createEnhanceHandler(
logger.debug(`Using model: ${resolvedModel}`);
let enhancedText: string;
// Use simpleQuery - provider abstraction handles routing to correct provider
// The system prompt is combined with user prompt since some providers
// don't have a separate system prompt concept
const result = await simpleQuery({
prompt: `${systemPrompt}\n\n${userPrompt}`,
model: resolvedModel,
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
maxTurns: 1,
allowedTools: [],
thinkingLevel,
readOnly: true, // Prompt enhancement only generates text, doesn't write files
});
// Route to appropriate provider based on model
if (isCursorModel(resolvedModel)) {
// Use Cursor provider for Cursor models
logger.info(`Using Cursor provider for model: ${resolvedModel}`);
// Cursor doesn't have a separate system prompt concept, so combine them
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
enhancedText = await executeWithProvider(combinedPrompt, resolvedModel);
} else if (isOpencodeModel(resolvedModel)) {
// Use OpenCode provider for OpenCode models (static and dynamic)
logger.info(`Using OpenCode provider for model: ${resolvedModel}`);
// OpenCode CLI handles the system prompt, so combine them
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
enhancedText = await executeWithProvider(combinedPrompt, resolvedModel);
} else {
// Use Claude SDK for Claude models
logger.info(`Using Claude provider for model: ${resolvedModel}`);
// Convert thinkingLevel to maxThinkingTokens for SDK
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
const queryOptions: Parameters<typeof query>[0]['options'] = {
model: resolvedModel,
systemPrompt,
maxTurns: 1,
allowedTools: [],
permissionMode: 'acceptEdits',
};
if (maxThinkingTokens) {
queryOptions.maxThinkingTokens = maxThinkingTokens;
}
const stream = query({
prompt: userPrompt,
options: queryOptions,
});
enhancedText = await extractTextFromStream(stream);
}
const enhancedText = result.text;
if (!enhancedText || enhancedText.trim().length === 0) {
logger.warn('Received empty response from AI');

View File

@@ -1,13 +1,14 @@
/**
* POST /features/generate-title endpoint - Generate a concise title from description
*
* Uses Claude Haiku to generate a short, descriptive title from feature description.
* Uses the provider abstraction to generate a short, descriptive title
* from a feature description. Works with any configured provider (Claude, Cursor, etc.).
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
import { simpleQuery } from '../../../providers/simple-query-service.js';
const logger = createLogger('GenerateTitle');
@@ -34,33 +35,6 @@ Rules:
- No quotes, periods, or extra formatting
- Capture the essence of the feature in a scannable way`;
async function extractTextFromStream(
stream: AsyncIterable<{
type: string;
subtype?: string;
result?: string;
message?: {
content?: Array<{ type: string; text?: string }>;
};
}>
): Promise<string> {
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}
return responseText;
}
export function createGenerateTitleHandler(): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
@@ -89,21 +63,19 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
const stream = query({
prompt: userPrompt,
options: {
model: CLAUDE_MODEL_MAP.haiku,
systemPrompt: SYSTEM_PROMPT,
maxTurns: 1,
allowedTools: [],
permissionMode: 'default',
},
// Use simpleQuery - provider abstraction handles all the streaming/extraction
const result = await simpleQuery({
prompt: `${SYSTEM_PROMPT}\n\n${userPrompt}`,
model: CLAUDE_MODEL_MAP.haiku,
cwd: process.cwd(),
maxTurns: 1,
allowedTools: [],
});
const title = await extractTextFromStream(stream);
const title = result.text;
if (!title || title.trim().length === 0) {
logger.warn('Received empty response from Claude');
logger.warn('Received empty response from AI');
const response: GenerateTitleErrorResponse = {
success: false,
error: 'Failed to generate title - empty response',

View File

@@ -5,6 +5,43 @@
import type { Request, Response } from 'express';
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
const GIT_REMOTE_ORIGIN_COMMAND = 'git remote get-url origin';
const GH_REPO_VIEW_COMMAND = 'gh repo view --json name,owner';
const GITHUB_REPO_URL_PREFIX = 'https://github.com/';
const GITHUB_HTTPS_REMOTE_REGEX = /https:\/\/github\.com\/([^/]+)\/([^/.]+)/;
const GITHUB_SSH_REMOTE_REGEX = /git@github\.com:([^/]+)\/([^/.]+)/;
interface GhRepoViewResponse {
name?: string;
owner?: {
login?: string;
};
}
async function resolveRepoFromGh(projectPath: string): Promise<{
owner: string;
repo: string;
} | null> {
try {
const { stdout } = await execAsync(GH_REPO_VIEW_COMMAND, {
cwd: projectPath,
env: execEnv,
});
const data = JSON.parse(stdout) as GhRepoViewResponse;
const owner = typeof data.owner?.login === 'string' ? data.owner.login : null;
const repo = typeof data.name === 'string' ? data.name : null;
if (!owner || !repo) {
return null;
}
return { owner, repo };
} catch {
return null;
}
}
export interface GitHubRemoteStatus {
hasGitHubRemote: boolean;
remoteUrl: string | null;
@@ -21,19 +58,38 @@ export async function checkGitHubRemote(projectPath: string): Promise<GitHubRemo
};
try {
// Get the remote URL (origin by default)
const { stdout } = await execAsync('git remote get-url origin', {
cwd: projectPath,
env: execEnv,
});
let remoteUrl = '';
try {
// Get the remote URL (origin by default)
const { stdout } = await execAsync(GIT_REMOTE_ORIGIN_COMMAND, {
cwd: projectPath,
env: execEnv,
});
remoteUrl = stdout.trim();
status.remoteUrl = remoteUrl || null;
} catch {
// Ignore missing origin remote
}
const remoteUrl = stdout.trim();
status.remoteUrl = remoteUrl;
const ghRepo = await resolveRepoFromGh(projectPath);
if (ghRepo) {
status.hasGitHubRemote = true;
status.owner = ghRepo.owner;
status.repo = ghRepo.repo;
if (!status.remoteUrl) {
status.remoteUrl = `${GITHUB_REPO_URL_PREFIX}${ghRepo.owner}/${ghRepo.repo}`;
}
return status;
}
// Check if it's a GitHub URL
// Formats: https://github.com/owner/repo.git, git@github.com:owner/repo.git
const httpsMatch = remoteUrl.match(/https:\/\/github\.com\/([^/]+)\/([^/.]+)/);
const sshMatch = remoteUrl.match(/git@github\.com:([^/]+)\/([^/.]+)/);
if (!remoteUrl) {
return status;
}
const httpsMatch = remoteUrl.match(GITHUB_HTTPS_REMOTE_REGEX);
const sshMatch = remoteUrl.match(GITHUB_SSH_REMOTE_REGEX);
const match = httpsMatch || sshMatch;
if (match) {

View File

@@ -25,19 +25,24 @@ interface GraphQLComment {
updatedAt: string;
}
interface GraphQLCommentConnection {
totalCount: number;
pageInfo: {
hasNextPage: boolean;
endCursor: string | null;
};
nodes: GraphQLComment[];
}
interface GraphQLIssueOrPullRequest {
__typename: 'Issue' | 'PullRequest';
comments: GraphQLCommentConnection;
}
interface GraphQLResponse {
data?: {
repository?: {
issue?: {
comments: {
totalCount: number;
pageInfo: {
hasNextPage: boolean;
endCursor: string | null;
};
nodes: GraphQLComment[];
};
};
issueOrPullRequest?: GraphQLIssueOrPullRequest | null;
};
};
errors?: Array<{ message: string }>;
@@ -45,6 +50,7 @@ interface GraphQLResponse {
/** Timeout for GitHub API requests in milliseconds */
const GITHUB_API_TIMEOUT_MS = 30000;
const COMMENTS_PAGE_SIZE = 50;
/**
* Validate cursor format (GraphQL cursors are typically base64 strings)
@@ -54,7 +60,7 @@ function isValidCursor(cursor: string): boolean {
}
/**
* Fetch comments for a specific issue using GitHub GraphQL API
* Fetch comments for a specific issue or pull request using GitHub GraphQL API
*/
async function fetchIssueComments(
projectPath: string,
@@ -70,24 +76,52 @@ async function fetchIssueComments(
// Use GraphQL variables instead of string interpolation for safety
const query = `
query GetIssueComments($owner: String!, $repo: String!, $issueNumber: Int!, $cursor: String) {
query GetIssueComments(
$owner: String!
$repo: String!
$issueNumber: Int!
$cursor: String
$pageSize: Int!
) {
repository(owner: $owner, name: $repo) {
issue(number: $issueNumber) {
comments(first: 50, after: $cursor) {
totalCount
pageInfo {
hasNextPage
endCursor
}
nodes {
id
author {
login
avatarUrl
issueOrPullRequest(number: $issueNumber) {
__typename
... on Issue {
comments(first: $pageSize, after: $cursor) {
totalCount
pageInfo {
hasNextPage
endCursor
}
nodes {
id
author {
login
avatarUrl
}
body
createdAt
updatedAt
}
}
}
... on PullRequest {
comments(first: $pageSize, after: $cursor) {
totalCount
pageInfo {
hasNextPage
endCursor
}
nodes {
id
author {
login
avatarUrl
}
body
createdAt
updatedAt
}
body
createdAt
updatedAt
}
}
}
@@ -99,6 +133,7 @@ async function fetchIssueComments(
repo,
issueNumber,
cursor: cursor || null,
pageSize: COMMENTS_PAGE_SIZE,
};
const requestBody = JSON.stringify({ query, variables });
@@ -140,10 +175,10 @@ async function fetchIssueComments(
throw new Error(response.errors[0].message);
}
const commentsData = response.data?.repository?.issue?.comments;
const commentsData = response.data?.repository?.issueOrPullRequest?.comments;
if (!commentsData) {
throw new Error('Issue not found or no comments data available');
throw new Error('Issue or pull request not found or no comments data available');
}
const comments: GitHubComment[] = commentsData.nodes.map((node) => ({

View File

@@ -9,6 +9,17 @@ import { checkGitHubRemote } from './check-github-remote.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('ListIssues');
const OPEN_ISSUES_LIMIT = 100;
const CLOSED_ISSUES_LIMIT = 50;
const ISSUE_LIST_FIELDS = 'number,title,state,author,createdAt,labels,url,body,assignees';
const ISSUE_STATE_OPEN = 'open';
const ISSUE_STATE_CLOSED = 'closed';
const GH_ISSUE_LIST_COMMAND = 'gh issue list';
const GH_STATE_FLAG = '--state';
const GH_JSON_FLAG = '--json';
const GH_LIMIT_FLAG = '--limit';
const LINKED_PRS_BATCH_SIZE = 20;
const LINKED_PRS_TIMELINE_ITEMS = 10;
export interface GitHubLabel {
name: string;
@@ -69,34 +80,68 @@ async function fetchLinkedPRs(
// Build GraphQL query for batch fetching linked PRs
// We fetch up to 20 issues at a time to avoid query limits
const batchSize = 20;
for (let i = 0; i < issueNumbers.length; i += batchSize) {
const batch = issueNumbers.slice(i, i + batchSize);
for (let i = 0; i < issueNumbers.length; i += LINKED_PRS_BATCH_SIZE) {
const batch = issueNumbers.slice(i, i + LINKED_PRS_BATCH_SIZE);
const issueQueries = batch
.map(
(num, idx) => `
issue${idx}: issue(number: ${num}) {
number
timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) {
nodes {
... on CrossReferencedEvent {
source {
... on PullRequest {
number
title
state
url
issue${idx}: issueOrPullRequest(number: ${num}) {
... on Issue {
number
timelineItems(
first: ${LINKED_PRS_TIMELINE_ITEMS}
itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]
) {
nodes {
... on CrossReferencedEvent {
source {
... on PullRequest {
number
title
state
url
}
}
}
... on ConnectedEvent {
subject {
... on PullRequest {
number
title
state
url
}
}
}
}
... on ConnectedEvent {
subject {
... on PullRequest {
number
title
state
url
}
}
... on PullRequest {
number
timelineItems(
first: ${LINKED_PRS_TIMELINE_ITEMS}
itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]
) {
nodes {
... on CrossReferencedEvent {
source {
... on PullRequest {
number
title
state
url
}
}
}
... on ConnectedEvent {
subject {
... on PullRequest {
number
title
state
url
}
}
}
}
@@ -213,16 +258,35 @@ export function createListIssuesHandler() {
}
// Fetch open and closed issues in parallel (now including assignees)
const repoQualifier =
remoteStatus.owner && remoteStatus.repo ? `${remoteStatus.owner}/${remoteStatus.repo}` : '';
const repoFlag = repoQualifier ? `-R ${repoQualifier}` : '';
const [openResult, closedResult] = await Promise.all([
execAsync(
'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body,assignees --limit 100',
[
GH_ISSUE_LIST_COMMAND,
repoFlag,
`${GH_STATE_FLAG} ${ISSUE_STATE_OPEN}`,
`${GH_JSON_FLAG} ${ISSUE_LIST_FIELDS}`,
`${GH_LIMIT_FLAG} ${OPEN_ISSUES_LIMIT}`,
]
.filter(Boolean)
.join(' '),
{
cwd: projectPath,
env: execEnv,
}
),
execAsync(
'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body,assignees --limit 50',
[
GH_ISSUE_LIST_COMMAND,
repoFlag,
`${GH_STATE_FLAG} ${ISSUE_STATE_CLOSED}`,
`${GH_JSON_FLAG} ${ISSUE_LIST_FIELDS}`,
`${GH_LIMIT_FLAG} ${CLOSED_ISSUES_LIMIT}`,
]
.filter(Boolean)
.join(' '),
{
cwd: projectPath,
env: execEnv,

View File

@@ -6,6 +6,17 @@ import type { Request, Response } from 'express';
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js';
const OPEN_PRS_LIMIT = 100;
const MERGED_PRS_LIMIT = 50;
const PR_LIST_FIELDS =
'number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body';
const PR_STATE_OPEN = 'open';
const PR_STATE_MERGED = 'merged';
const GH_PR_LIST_COMMAND = 'gh pr list';
const GH_STATE_FLAG = '--state';
const GH_JSON_FLAG = '--json';
const GH_LIMIT_FLAG = '--limit';
export interface GitHubLabel {
name: string;
color: string;
@@ -57,16 +68,36 @@ export function createListPRsHandler() {
return;
}
const repoQualifier =
remoteStatus.owner && remoteStatus.repo ? `${remoteStatus.owner}/${remoteStatus.repo}` : '';
const repoFlag = repoQualifier ? `-R ${repoQualifier}` : '';
const [openResult, mergedResult] = await Promise.all([
execAsync(
'gh pr list --state open --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 100',
[
GH_PR_LIST_COMMAND,
repoFlag,
`${GH_STATE_FLAG} ${PR_STATE_OPEN}`,
`${GH_JSON_FLAG} ${PR_LIST_FIELDS}`,
`${GH_LIMIT_FLAG} ${OPEN_PRS_LIMIT}`,
]
.filter(Boolean)
.join(' '),
{
cwd: projectPath,
env: execEnv,
}
),
execAsync(
'gh pr list --state merged --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 50',
[
GH_PR_LIST_COMMAND,
repoFlag,
`${GH_STATE_FLAG} ${PR_STATE_MERGED}`,
`${GH_JSON_FLAG} ${PR_LIST_FIELDS}`,
`${GH_LIMIT_FLAG} ${MERGED_PRS_LIMIT}`,
]
.filter(Boolean)
.join(' '),
{
cwd: projectPath,
env: execEnv,

View File

@@ -1,29 +1,33 @@
/**
* POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK or Cursor (async)
* POST /validate-issue endpoint - Validate a GitHub issue using provider abstraction (async)
*
* Scans the codebase to determine if an issue is valid, invalid, or needs clarification.
* Runs asynchronously and emits events for progress and completion.
* Supports both Claude models and Cursor models.
* Supports Claude, Codex, Cursor, and OpenCode models.
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import type { EventEmitter } from '../../../lib/events.js';
import type {
IssueValidationResult,
IssueValidationEvent,
ModelAlias,
CursorModelId,
ModelId,
GitHubComment,
LinkedPRInfo,
ThinkingLevel,
ReasoningEffort,
} from '@automaker/types';
import {
DEFAULT_PHASE_MODELS,
isClaudeModel,
isCodexModel,
isCursorModel,
isOpencodeModel,
} from '@automaker/types';
import { isCursorModel, DEFAULT_PHASE_MODELS, stripProviderPrefix } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
import { extractJson } from '../../../lib/json-extractor.js';
import { writeValidation } from '../../../lib/validation-storage.js';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import { streamingQuery } from '../../../providers/simple-query-service.js';
import {
issueValidationSchema,
ISSUE_VALIDATION_SYSTEM_PROMPT,
@@ -41,9 +45,6 @@ import {
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
/** Valid Claude model values for validation */
const VALID_CLAUDE_MODELS: readonly ModelAlias[] = ['opus', 'sonnet', 'haiku'] as const;
/**
* Request body for issue validation
*/
@@ -53,10 +54,12 @@ interface ValidateIssueRequestBody {
issueTitle: string;
issueBody: string;
issueLabels?: string[];
/** Model to use for validation (opus, sonnet, haiku, or cursor model IDs) */
model?: ModelAlias | CursorModelId;
/** Thinking level for Claude models (ignored for Cursor models) */
/** Model to use for validation (Claude alias or provider model ID) */
model?: ModelId;
/** Thinking level for Claude models (ignored for non-Claude models) */
thinkingLevel?: ThinkingLevel;
/** Reasoning effort for Codex models (ignored for non-Codex models) */
reasoningEffort?: ReasoningEffort;
/** Comments to include in validation analysis */
comments?: GitHubComment[];
/** Linked pull requests for this issue */
@@ -68,7 +71,7 @@ interface ValidateIssueRequestBody {
*
* Emits events for start, progress, complete, and error.
* Stores result on completion.
* Supports both Claude models (with structured output) and Cursor models (with JSON parsing).
* Supports Claude/Codex models (structured output) and Cursor/OpenCode models (JSON parsing).
*/
async function runValidation(
projectPath: string,
@@ -76,13 +79,14 @@ async function runValidation(
issueTitle: string,
issueBody: string,
issueLabels: string[] | undefined,
model: ModelAlias | CursorModelId,
model: ModelId,
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService,
comments?: ValidationComment[],
linkedPRs?: ValidationLinkedPR[],
thinkingLevel?: ThinkingLevel
thinkingLevel?: ThinkingLevel,
reasoningEffort?: ReasoningEffort
): Promise<void> {
// Emit start event
const startEvent: IssueValidationEvent = {
@@ -102,7 +106,7 @@ async function runValidation(
try {
// Build the prompt (include comments and linked PRs if provided)
const prompt = buildValidationPrompt(
const basePrompt = buildValidationPrompt(
issueNumber,
issueTitle,
issueBody,
@@ -111,20 +115,15 @@ async function runValidation(
linkedPRs
);
let validationResult: IssueValidationResult | null = null;
let responseText = '';
// Route to appropriate provider based on model
if (isCursorModel(model)) {
// Use Cursor provider for Cursor models
logger.info(`Using Cursor provider for validation with model: ${model}`);
// Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't)
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
const provider = ProviderFactory.getProviderForModel(model);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(model);
// For Cursor, include the system prompt and schema in the user prompt
const cursorPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT}
// Build the final prompt - for Cursor, include system prompt and JSON schema instructions
let finalPrompt = basePrompt;
if (!useStructuredOutput) {
finalPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. Return the JSON in your response only.
@@ -135,121 +134,78 @@ ${JSON.stringify(issueValidationSchema, null, 2)}
Your entire response should be valid JSON starting with { and ending with }. No text before or after.
${prompt}`;
${basePrompt}`;
}
for await (const msg of provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
cwd: projectPath,
readOnly: true, // Issue validation only reads code, doesn't write
})) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[ValidateIssue]'
);
// Emit progress event
const progressEvent: IssueValidationEvent = {
type: 'issue_validation_progress',
issueNumber,
content: block.text,
projectPath,
};
events.emit('issue-validation:event', progressEvent);
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result if it's a final accumulated message
if (msg.result.length > responseText.length) {
responseText = msg.result;
}
}
}
// Parse JSON from the response text using shared utility
if (responseText) {
validationResult = extractJson<IssueValidationResult>(responseText, { logger });
}
} else {
// Use Claude SDK for Claude models
logger.info(`Using Claude provider for validation with model: ${model}`);
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[ValidateIssue]'
);
// Use thinkingLevel from request if provided, otherwise fall back to settings
let effectiveThinkingLevel: ThinkingLevel | undefined = thinkingLevel;
// Use request overrides if provided, otherwise fall back to settings
let effectiveThinkingLevel: ThinkingLevel | undefined = thinkingLevel;
let effectiveReasoningEffort: ReasoningEffort | undefined = reasoningEffort;
if (!effectiveThinkingLevel || !effectiveReasoningEffort) {
const settings = await settingsService?.getGlobalSettings();
const phaseModelEntry =
settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel;
const resolved = resolvePhaseModel(phaseModelEntry);
if (!effectiveThinkingLevel) {
const settings = await settingsService?.getGlobalSettings();
const phaseModelEntry =
settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel;
const resolved = resolvePhaseModel(phaseModelEntry);
effectiveThinkingLevel = resolved.thinkingLevel;
}
// Create SDK options with structured output and abort controller
const options = createSuggestionsOptions({
cwd: projectPath,
model: model as ModelAlias,
systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT,
abortController,
autoLoadClaudeMd,
thinkingLevel: effectiveThinkingLevel,
outputFormat: {
type: 'json_schema',
schema: issueValidationSchema as Record<string, unknown>,
},
});
// Execute the query
const stream = query({ prompt, options });
for await (const msg of stream) {
// Collect assistant text for debugging and emit progress
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
// Emit progress event
const progressEvent: IssueValidationEvent = {
type: 'issue_validation_progress',
issueNumber,
content: block.text,
projectPath,
};
events.emit('issue-validation:event', progressEvent);
}
}
}
// Extract structured output on success
if (msg.type === 'result' && msg.subtype === 'success') {
const resultMsg = msg as { structured_output?: IssueValidationResult };
if (resultMsg.structured_output) {
validationResult = resultMsg.structured_output;
logger.debug('Received structured output:', validationResult);
}
}
// Handle errors
if (msg.type === 'result') {
const resultMsg = msg as { subtype?: string };
if (resultMsg.subtype === 'error_max_structured_output_retries') {
logger.error('Failed to produce valid structured output after retries');
throw new Error('Could not produce valid validation output');
}
}
if (!effectiveReasoningEffort && typeof phaseModelEntry !== 'string') {
effectiveReasoningEffort = phaseModelEntry.reasoningEffort;
}
}
logger.info(`Using model: ${model}`);
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt: finalPrompt,
model: model as string,
cwd: projectPath,
systemPrompt: useStructuredOutput ? ISSUE_VALIDATION_SYSTEM_PROMPT : undefined,
abortController,
thinkingLevel: effectiveThinkingLevel,
reasoningEffort: effectiveReasoningEffort,
readOnly: true, // Issue validation only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
outputFormat: useStructuredOutput
? {
type: 'json_schema',
schema: issueValidationSchema as Record<string, unknown>,
}
: undefined,
onText: (text) => {
responseText += text;
// Emit progress event
const progressEvent: IssueValidationEvent = {
type: 'issue_validation_progress',
issueNumber,
content: text,
projectPath,
};
events.emit('issue-validation:event', progressEvent);
},
});
// Clear timeout
clearTimeout(timeoutId);
// Get validation result from structured output or parse from text
let validationResult: IssueValidationResult | null = null;
if (result.structured_output) {
validationResult = result.structured_output as unknown as IssueValidationResult;
logger.debug('Received structured output:', validationResult);
} else if (responseText) {
// Parse JSON from response text
validationResult = extractJson<IssueValidationResult>(responseText, { logger });
}
// Require validation result
if (!validationResult) {
logger.error('No validation result received from AI provider');
@@ -299,7 +255,7 @@ ${prompt}`;
/**
* Creates the handler for validating GitHub issues against the codebase.
*
* Uses Claude SDK with:
* Uses the provider abstraction with:
* - Read-only tools (Read, Glob, Grep) for codebase analysis
* - JSON schema structured output for reliable parsing
* - System prompt guiding the validation process
@@ -319,6 +275,7 @@ export function createValidateIssueHandler(
issueLabels,
model = 'opus',
thinkingLevel,
reasoningEffort,
comments: rawComments,
linkedPRs: rawLinkedPRs,
} = req.body as ValidateIssueRequestBody;
@@ -366,14 +323,17 @@ export function createValidateIssueHandler(
return;
}
// Validate model parameter at runtime - accept Claude models or Cursor models
const isValidClaudeModel = VALID_CLAUDE_MODELS.includes(model as ModelAlias);
const isValidCursorModel = isCursorModel(model);
// Validate model parameter at runtime - accept any supported provider model
const isValidModel =
isClaudeModel(model) ||
isCursorModel(model) ||
isCodexModel(model) ||
isOpencodeModel(model);
if (!isValidClaudeModel && !isValidCursorModel) {
if (!isValidModel) {
res.status(400).json({
success: false,
error: `Invalid model. Must be one of: ${VALID_CLAUDE_MODELS.join(', ')}, or a Cursor model ID`,
error: 'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias).',
});
return;
}
@@ -404,7 +364,8 @@ export function createValidateIssueHandler(
settingsService,
validationComments,
validationLinkedPRs,
thinkingLevel
thinkingLevel,
reasoningEffort
)
.catch(() => {
// Error is already handled inside runValidation (event emitted)

View File

@@ -5,7 +5,7 @@
* Each provider shows: `{ configured: boolean, masked: string }`
* Masked shows first 4 and last 4 characters for verification.
*
* Response: `{ "success": true, "credentials": { anthropic } }`
* Response: `{ "success": true, "credentials": { anthropic, google, openai } }`
*/
import type { Request, Response } from 'express';

View File

@@ -1,7 +1,7 @@
/**
* PUT /api/settings/credentials - Update API credentials
*
* Updates API keys for Anthropic. Partial updates supported.
* Updates API keys for supported providers. Partial updates supported.
* Returns masked credentials for verification without exposing full keys.
*
* Request body: `Partial<Credentials>` (usually just apiKeys)

View File

@@ -11,6 +11,7 @@ export function createApiKeysHandler() {
res.json({
success: true,
hasAnthropicKey: !!getApiKey('anthropic') || !!process.env.ANTHROPIC_API_KEY,
hasGoogleKey: !!getApiKey('google'),
hasOpenaiKey: !!getApiKey('openai') || !!process.env.OPENAI_API_KEY,
});
} catch (error) {

View File

@@ -21,22 +21,25 @@ export function createStoreApiKeyHandler() {
return;
}
setApiKey(provider, apiKey);
// Also set as environment variable and persist to .env
if (provider === 'anthropic' || provider === 'anthropic_oauth_token') {
// Both API key and OAuth token use ANTHROPIC_API_KEY
process.env.ANTHROPIC_API_KEY = apiKey;
await persistApiKeyToEnv('ANTHROPIC_API_KEY', apiKey);
logger.info('[Setup] Stored API key as ANTHROPIC_API_KEY');
} else {
const providerEnvMap: Record<string, string> = {
anthropic: 'ANTHROPIC_API_KEY',
anthropic_oauth_token: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
};
const envKey = providerEnvMap[provider];
if (!envKey) {
res.status(400).json({
success: false,
error: `Unsupported provider: ${provider}. Only anthropic is supported.`,
error: `Unsupported provider: ${provider}. Only anthropic and openai are supported.`,
});
return;
}
setApiKey(provider, apiKey);
process.env[envKey] = apiKey;
await persistApiKeyToEnv(envKey, apiKey);
logger.info(`[Setup] Stored API key as ${envKey}`);
res.json({ success: true });
} catch (error) {
logError(error, 'Store API key failed');

View File

@@ -5,19 +5,12 @@
* (AI Suggestions in the UI). Supports both Claude and Cursor models.
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import {
DEFAULT_PHASE_MODELS,
isCursorModel,
stripProviderPrefix,
type ThinkingLevel,
} from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createSuggestionsOptions } from '../../lib/sdk-options.js';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { FeatureLoader } from '../../services/feature-loader.js';
import { getAppSpecPath } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js';
@@ -204,19 +197,14 @@ The response will be automatically formatted as structured JSON.`;
logger.info('[Suggestions] Using model:', model);
let responseText = '';
let structuredOutput: { suggestions: Array<Record<string, unknown>> } | null = null;
// Route to appropriate provider based on model type
if (isCursorModel(model)) {
// Use Cursor provider for Cursor models
logger.info('[Suggestions] Using Cursor provider');
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
const useStructuredOutput = !isCursorModel(model);
const provider = ProviderFactory.getProviderForModel(model);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(model);
// For Cursor, include the JSON schema in the prompt with clear instructions
const cursorPrompt = `${prompt}
// Build the final prompt - for Cursor, include JSON schema instructions
let finalPrompt = prompt;
if (!useStructuredOutput) {
finalPrompt = `${prompt}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. Return the JSON in your response only.
@@ -226,104 +214,60 @@ CRITICAL INSTRUCTIONS:
${JSON.stringify(suggestionsSchema, null, 2)}
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
for await (const msg of provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
cwd: projectPath,
maxTurns: 250,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
readOnly: true, // Suggestions only reads code, doesn't write
})) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
events.emit('suggestions:event', {
type: 'suggestions_progress',
content: block.text,
});
} else if (block.type === 'tool_use') {
events.emit('suggestions:event', {
type: 'suggestions_tool',
tool: block.name,
input: block.input,
});
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result if it's a final accumulated message (from Cursor provider)
logger.info('[Suggestions] Received result from Cursor, length:', msg.result.length);
logger.info('[Suggestions] Previous responseText length:', responseText.length);
if (msg.result.length > responseText.length) {
logger.info('[Suggestions] Using Cursor result (longer than accumulated text)');
responseText = msg.result;
} else {
logger.info('[Suggestions] Keeping accumulated text (longer than Cursor result)');
}
}
}
} else {
// Use Claude SDK for Claude models
logger.info('[Suggestions] Using Claude SDK');
const options = createSuggestionsOptions({
cwd: projectPath,
abortController,
autoLoadClaudeMd,
model, // Pass the model from settings
thinkingLevel, // Pass thinking level for extended thinking
outputFormat: {
type: 'json_schema',
schema: suggestionsSchema,
},
});
const stream = query({ prompt, options });
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
events.emit('suggestions:event', {
type: 'suggestions_progress',
content: block.text,
});
} else if (block.type === 'tool_use') {
events.emit('suggestions:event', {
type: 'suggestions_tool',
tool: block.name,
input: block.input,
});
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
// Check for structured output
const resultMsg = msg as any;
if (resultMsg.structured_output) {
structuredOutput = resultMsg.structured_output as {
suggestions: Array<Record<string, unknown>>;
};
logger.debug('Received structured output:', structuredOutput);
}
} else if (msg.type === 'result') {
const resultMsg = msg as any;
if (resultMsg.subtype === 'error_max_structured_output_retries') {
logger.error('Failed to produce valid structured output after retries');
throw new Error('Could not produce valid suggestions output');
} else if (resultMsg.subtype === 'error_max_turns') {
logger.error('Hit max turns limit before completing suggestions generation');
logger.warn(`Response text length: ${responseText.length} chars`);
// Still try to parse what we have
}
}
}
}
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt: finalPrompt,
model,
cwd: projectPath,
maxTurns: 250,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
thinkingLevel,
readOnly: true, // Suggestions only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
outputFormat: useStructuredOutput
? {
type: 'json_schema',
schema: suggestionsSchema,
}
: undefined,
onText: (text) => {
responseText += text;
events.emit('suggestions:event', {
type: 'suggestions_progress',
content: text,
});
},
onToolUse: (tool, input) => {
events.emit('suggestions:event', {
type: 'suggestions_tool',
tool,
input,
});
},
});
// Use structured output if available, otherwise fall back to parsing text
try {
let structuredOutput: { suggestions: Array<Record<string, unknown>> } | null = null;
if (result.structured_output) {
structuredOutput = result.structured_output as {
suggestions: Array<Record<string, unknown>>;
};
logger.debug('Received structured output:', structuredOutput);
} else if (responseText) {
// Fallback: try to parse from text using shared extraction utility
logger.warn('No structured output received, attempting to parse from text');
structuredOutput = extractJsonWithArray<{ suggestions: Array<Record<string, unknown>> }>(
responseText,
'suggestions',
{ logger }
);
}
if (structuredOutput && structuredOutput.suggestions) {
// Use structured output directly
events.emit('suggestions:event', {
@@ -334,24 +278,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
})),
});
} else {
// Fallback: try to parse from text using shared extraction utility
logger.warn('No structured output received, attempting to parse from text');
const parsed = extractJsonWithArray<{ suggestions: Array<Record<string, unknown>> }>(
responseText,
'suggestions',
{ logger }
);
if (parsed && parsed.suggestions) {
events.emit('suggestions:event', {
type: 'suggestions_complete',
suggestions: parsed.suggestions.map((s: Record<string, unknown>, i: number) => ({
...s,
id: s.id || `suggestion-${Date.now()}-${i}`,
})),
});
} else {
throw new Error('No valid JSON found in response');
}
throw new Error('No valid JSON found in response');
}
} catch (error) {
// Log the parsing error for debugging

View File

@@ -17,6 +17,7 @@ import { createDeleteHandler } from './routes/delete.js';
import { createCreatePRHandler } from './routes/create-pr.js';
import { createPRInfoHandler } from './routes/pr-info.js';
import { createCommitHandler } from './routes/commit.js';
import { createGenerateCommitMessageHandler } from './routes/generate-commit-message.js';
import { createPushHandler } from './routes/push.js';
import { createPullHandler } from './routes/pull.js';
import { createCheckoutBranchHandler } from './routes/checkout-branch.js';
@@ -33,14 +34,19 @@ import { createMigrateHandler } from './routes/migrate.js';
import { createStartDevHandler } from './routes/start-dev.js';
import { createStopDevHandler } from './routes/stop-dev.js';
import { createListDevServersHandler } from './routes/list-dev-servers.js';
import { createGetDevServerLogsHandler } from './routes/dev-server-logs.js';
import {
createGetInitScriptHandler,
createPutInitScriptHandler,
createDeleteInitScriptHandler,
createRunInitScriptHandler,
} from './routes/init-script.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes(events: EventEmitter): Router {
export function createWorktreeRoutes(
events: EventEmitter,
settingsService?: SettingsService
): Router {
const router = Router();
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
@@ -64,6 +70,12 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
requireGitRepoOnly,
createCommitHandler()
);
router.post(
'/generate-commit-message',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createGenerateCommitMessageHandler(settingsService)
);
router.post(
'/push',
validatePathParams('worktreePath'),
@@ -97,6 +109,11 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
);
router.post('/stop-dev', createStopDevHandler());
router.post('/list-dev-servers', createListDevServersHandler());
router.get(
'/dev-server-logs',
validatePathParams('worktreePath'),
createGetDevServerLogsHandler()
);
// Init script routes
router.get('/init-script', createGetInitScriptHandler());

View File

@@ -70,9 +70,8 @@ export function createCreatePRHandler() {
logger.debug(`Changed files:\n${status}`);
}
// If there are changes, commit them
// If there are changes, commit them before creating the PR
let commitHash: string | null = null;
let commitError: string | null = null;
if (hasChanges) {
const message = commitMessage || `Changes from ${branchName}`;
logger.debug(`Committing changes with message: ${message}`);
@@ -98,14 +97,13 @@ export function createCreatePRHandler() {
logger.info(`Commit successful: ${commitHash}`);
} catch (commitErr: unknown) {
const err = commitErr as { stderr?: string; message?: string };
commitError = err.stderr || err.message || 'Commit failed';
const commitError = err.stderr || err.message || 'Commit failed';
logger.error(`Commit failed: ${commitError}`);
// Return error immediately - don't proceed with push/PR if commit fails
res.status(500).json({
success: false,
error: `Failed to commit changes: ${commitError}`,
commitError,
});
return;
}
@@ -381,9 +379,8 @@ export function createCreatePRHandler() {
success: true,
result: {
branch: branchName,
committed: hasChanges && !commitError,
committed: hasChanges,
commitHash,
commitError: commitError || undefined,
pushed: true,
prUrl,
prNumber,

View File

@@ -0,0 +1,52 @@
/**
* GET /dev-server-logs endpoint - Get buffered logs for a worktree's dev server
*
* Returns the scrollback buffer containing historical log output for a running
* dev server. Used by clients to populate the log panel on initial connection
* before subscribing to real-time updates via WebSocket.
*/
import type { Request, Response } from 'express';
import { getDevServerService } from '../../../services/dev-server-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createGetDevServerLogsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.query as {
worktreePath?: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath query parameter is required',
});
return;
}
const devServerService = getDevServerService();
const result = devServerService.getServerLogs(worktreePath);
if (result.success && result.result) {
res.json({
success: true,
result: {
worktreePath: result.result.worktreePath,
port: result.result.port,
logs: result.result.logs,
startedAt: result.result.startedAt,
},
});
} else {
res.status(404).json({
success: false,
error: result.error || 'Failed to get dev server logs',
});
}
} catch (error) {
logError(error, 'Get dev server logs failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,275 @@
/**
* POST /worktree/generate-commit-message endpoint - Generate an AI commit message from git diff
*
* Uses the configured model (via phaseModels.commitMessageModel) to generate a concise,
* conventional commit message from git changes. Defaults to Claude Haiku for speed.
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { existsSync } from 'fs';
import { join } from 'path';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { mergeCommitMessagePrompts } from '@automaker/prompts';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('GenerateCommitMessage');
const execAsync = promisify(exec);
/** Timeout for AI provider calls in milliseconds (30 seconds) */
const AI_TIMEOUT_MS = 30_000;
/**
* Wraps an async generator with a timeout.
* If the generator takes longer than the timeout, it throws an error.
*/
async function* withTimeout<T>(
generator: AsyncIterable<T>,
timeoutMs: number
): AsyncGenerator<T, void, unknown> {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), timeoutMs);
});
const iterator = generator[Symbol.asyncIterator]();
let done = false;
while (!done) {
const result = await Promise.race([iterator.next(), timeoutPromise]);
if (result.done) {
done = true;
} else {
yield result.value;
}
}
}
/**
* Get the effective system prompt for commit message generation.
* Uses custom prompt from settings if enabled, otherwise falls back to default.
*/
async function getSystemPrompt(settingsService?: SettingsService): Promise<string> {
const settings = await settingsService?.getGlobalSettings();
const prompts = mergeCommitMessagePrompts(settings?.promptCustomization?.commitMessage);
return prompts.systemPrompt;
}
interface GenerateCommitMessageRequestBody {
worktreePath: string;
}
interface GenerateCommitMessageSuccessResponse {
success: true;
message: string;
}
interface GenerateCommitMessageErrorResponse {
success: false;
error: string;
}
async function extractTextFromStream(
stream: AsyncIterable<{
type: string;
subtype?: string;
result?: string;
message?: {
content?: Array<{ type: string; text?: string }>;
};
}>
): Promise<string> {
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}
return responseText;
}
export function createGenerateCommitMessageHandler(
settingsService?: SettingsService
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as GenerateCommitMessageRequestBody;
if (!worktreePath || typeof worktreePath !== 'string') {
const response: GenerateCommitMessageErrorResponse = {
success: false,
error: 'worktreePath is required and must be a string',
};
res.status(400).json(response);
return;
}
// Validate that the directory exists
if (!existsSync(worktreePath)) {
const response: GenerateCommitMessageErrorResponse = {
success: false,
error: 'worktreePath does not exist',
};
res.status(400).json(response);
return;
}
// Validate that it's a git repository (check for .git folder or file for worktrees)
const gitPath = join(worktreePath, '.git');
if (!existsSync(gitPath)) {
const response: GenerateCommitMessageErrorResponse = {
success: false,
error: 'worktreePath is not a git repository',
};
res.status(400).json(response);
return;
}
logger.info(`Generating commit message for worktree: ${worktreePath}`);
// Get git diff of staged and unstaged changes
let diff = '';
try {
// First try to get staged changes
const { stdout: stagedDiff } = await execAsync('git diff --cached', {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
});
// If no staged changes, get unstaged changes
if (!stagedDiff.trim()) {
const { stdout: unstagedDiff } = await execAsync('git diff', {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
});
diff = unstagedDiff;
} else {
diff = stagedDiff;
}
} catch (error) {
logger.error('Failed to get git diff:', error);
const response: GenerateCommitMessageErrorResponse = {
success: false,
error: 'Failed to get git changes',
};
res.status(500).json(response);
return;
}
if (!diff.trim()) {
const response: GenerateCommitMessageErrorResponse = {
success: false,
error: 'No changes to commit',
};
res.status(400).json(response);
return;
}
// Truncate diff if too long (keep first 10000 characters to avoid token limits)
const truncatedDiff =
diff.length > 10000 ? diff.substring(0, 10000) + '\n\n[... diff truncated ...]' : diff;
const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``;
// Get model from phase settings
const settings = await settingsService?.getGlobalSettings();
const phaseModelEntry =
settings?.phaseModels?.commitMessageModel || DEFAULT_PHASE_MODELS.commitMessageModel;
const { model } = resolvePhaseModel(phaseModelEntry);
logger.info(`Using model for commit message: ${model}`);
// Get the effective system prompt (custom or default)
const systemPrompt = await getSystemPrompt(settingsService);
let message: string;
// Route to appropriate provider based on model type
if (isCursorModel(model)) {
// Use Cursor provider for Cursor models
logger.info(`Using Cursor provider for model: ${model}`);
const provider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`;
let responseText = '';
const cursorStream = provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
cwd: worktreePath,
maxTurns: 1,
allowedTools: [],
readOnly: true,
});
// Wrap with timeout to prevent indefinite hangs
for await (const msg of withTimeout(cursorStream, AI_TIMEOUT_MS)) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
}
}
message = responseText.trim();
} else {
// Use Claude SDK for Claude models
const stream = query({
prompt: userPrompt,
options: {
model,
systemPrompt,
maxTurns: 1,
allowedTools: [],
permissionMode: 'default',
},
});
// Wrap with timeout to prevent indefinite hangs
message = await extractTextFromStream(withTimeout(stream, AI_TIMEOUT_MS));
}
if (!message || message.trim().length === 0) {
logger.warn('Received empty response from model');
const response: GenerateCommitMessageErrorResponse = {
success: false,
error: 'Failed to generate commit message - empty response',
};
res.status(500).json(response);
return;
}
logger.info(`Generated commit message: ${message.trim().substring(0, 100)}...`);
const response: GenerateCommitMessageSuccessResponse = {
success: true,
message: message.trim(),
};
res.json(response);
} catch (error) {
logError(error, 'Generate commit message failed');
const response: GenerateCommitMessageErrorResponse = {
success: false,
error: getErrorMessage(error),
};
res.status(500).json(response);
}
};
}

View File

@@ -1,5 +1,5 @@
/**
* POST /list-branches endpoint - List all local branches
* POST /list-branches endpoint - List all local branches and optionally remote branches
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
@@ -21,8 +21,9 @@ interface BranchInfo {
export function createListBranchesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
const { worktreePath, includeRemote = false } = req.body as {
worktreePath: string;
includeRemote?: boolean;
};
if (!worktreePath) {
@@ -60,6 +61,55 @@ export function createListBranchesHandler() {
};
});
// Fetch remote branches if requested
if (includeRemote) {
try {
// Fetch latest remote refs (silently, don't fail if offline)
try {
await execAsync('git fetch --all --quiet', {
cwd: worktreePath,
timeout: 10000, // 10 second timeout
});
} catch {
// Ignore fetch errors - we'll use cached remote refs
}
// List remote branches
const { stdout: remoteBranchesOutput } = await execAsync(
'git branch -r --format="%(refname:short)"',
{ cwd: worktreePath }
);
const localBranchNames = new Set(branches.map((b) => b.name));
remoteBranchesOutput
.trim()
.split('\n')
.filter((b) => b.trim())
.forEach((name) => {
// Remove any surrounding quotes
const cleanName = name.trim().replace(/^['"]|['"]$/g, '');
// Skip HEAD pointers like "origin/HEAD"
if (cleanName.includes('/HEAD')) return;
// Only add remote branches if a branch with the exact same name isn't already
// in the list. This avoids duplicates if a local branch is named like a remote one.
// Note: We intentionally include remote branches even when a local branch with the
// same base name exists (e.g., show "origin/main" even if local "main" exists),
// since users need to select remote branches as PR base targets.
if (!localBranchNames.has(cleanName)) {
branches.push({
name: cleanName, // Keep full name like "origin/main"
isCurrent: false,
isRemote: true,
});
}
});
} catch {
// Ignore errors fetching remote branches - return local branches only
}
}
// Get ahead/behind count for current branch
let aheadCount = 0;
let behindCount = 0;

View File

@@ -13,7 +13,7 @@ import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, normalizePath } from '../common.js';
import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js';
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';
@@ -121,6 +121,52 @@ async function scanWorktreesDirectory(
return discovered;
}
/**
* Fetch open PRs from GitHub and create a map of branch name to PR info.
* This allows detecting PRs that were created outside the app.
*/
async function fetchGitHubPRs(projectPath: string): Promise<Map<string, WorktreePRInfo>> {
const prMap = new Map<string, WorktreePRInfo>();
try {
// Check if gh CLI is available
const ghAvailable = await isGhCliAvailable();
if (!ghAvailable) {
return prMap;
}
// Fetch open PRs from GitHub
const { stdout } = await execAsync(
'gh pr list --state open --json number,title,url,state,headRefName,createdAt --limit 1000',
{ cwd: projectPath, env: execEnv, timeout: 15000 }
);
const prs = JSON.parse(stdout || '[]') as Array<{
number: number;
title: string;
url: string;
state: string;
headRefName: string;
createdAt: string;
}>;
for (const pr of prs) {
prMap.set(pr.headRefName, {
number: pr.number,
url: pr.url,
title: pr.title,
state: pr.state,
createdAt: pr.createdAt,
});
}
} catch (error) {
// Silently fail - PR detection is optional
logger.warn(`Failed to fetch GitHub PRs: ${getErrorMessage(error)}`);
}
return prMap;
}
export function createListHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
@@ -241,11 +287,23 @@ export function createListHandler() {
}
}
// Add PR info from metadata for each worktree
// Add PR info from metadata or GitHub for each worktree
// Only fetch GitHub PRs if includeDetails is requested (performance optimization)
const githubPRs = includeDetails
? await fetchGitHubPRs(projectPath)
: new Map<string, WorktreePRInfo>();
for (const worktree of worktrees) {
const metadata = allMetadata.get(worktree.branch);
if (metadata?.pr) {
// Use stored metadata (more complete info)
worktree.pr = metadata.pr;
} else if (includeDetails) {
// Fall back to GitHub PR detection only when includeDetails is requested
const githubPR = githubPRs.get(worktree.branch);
if (githubPR) {
worktree.pr = githubPR;
}
}
}

View File

@@ -8,7 +8,6 @@
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
@@ -16,28 +15,31 @@ const execAsync = promisify(exec);
export function createMergeHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, options } = req.body as {
const { projectPath, branchName, worktreePath, options } = req.body as {
projectPath: string;
featureId: string;
branchName: string;
worktreePath: string;
options?: { squash?: boolean; message?: string };
};
if (!projectPath || !featureId) {
if (!projectPath || !branchName || !worktreePath) {
res.status(400).json({
success: false,
error: 'projectPath and featureId required',
error: 'projectPath, branchName, and worktreePath are required',
});
return;
}
const branchName = `feature/${featureId}`;
// Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, '.worktrees', featureId);
// Get current branch
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: projectPath,
});
// Validate branch exists
try {
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
} catch {
res.status(400).json({
success: false,
error: `Branch "${branchName}" does not exist`,
});
return;
}
// Merge the feature branch
const mergeCmd = options?.squash

View File

@@ -10,15 +10,18 @@
*/
import { ProviderFactory } from '../providers/provider-factory.js';
import { simpleQuery } from '../providers/simple-query-service.js';
import type {
ExecuteOptions,
Feature,
ModelProvider,
PipelineStep,
FeatureStatusWithPipeline,
PipelineConfig,
ThinkingLevel,
PlanningMode,
} from '@automaker/types';
import { DEFAULT_PHASE_MODELS, stripProviderPrefix } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isClaudeModel, stripProviderPrefix } from '@automaker/types';
import {
buildPromptWithImages,
classifyError,
@@ -83,6 +86,26 @@ interface PlanSpec {
tasks?: ParsedTask[];
}
/**
* Information about pipeline status when resuming a feature.
* Used to determine how to handle features stuck in pipeline execution.
*
* @property {boolean} isPipeline - Whether the feature is in a pipeline step
* @property {string | null} stepId - ID of the current pipeline step (e.g., 'step_123')
* @property {number} stepIndex - Index of the step in the sorted pipeline steps (-1 if not found)
* @property {number} totalSteps - Total number of steps in the pipeline
* @property {PipelineStep | null} step - The pipeline step configuration, or null if step not found
* @property {PipelineConfig | null} config - The full pipeline configuration, or null if no pipeline
*/
interface PipelineStatusInfo {
isPipeline: boolean;
stepId: string | null;
stepIndex: number;
totalSteps: number;
step: PipelineStep | null;
config: PipelineConfig | null;
}
/**
* Parse tasks from generated spec content
* Looks for the ```tasks code block and extracts task lines
@@ -917,6 +940,25 @@ Complete the pipeline step instructions above. Review the previous work and appl
throw new Error('already running');
}
// Load feature to check status
const feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
// Check if feature is stuck in a pipeline step
const pipelineInfo = await this.detectPipelineStatus(
projectPath,
featureId,
(feature.status || '') as FeatureStatusWithPipeline
);
if (pipelineInfo.isPipeline) {
// Feature stuck in pipeline - use pipeline resume
return this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo);
}
// Normal resume flow for non-pipeline features
// Check if context exists in .automaker directory
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, 'agent-output.md');
@@ -936,11 +978,252 @@ Complete the pipeline step instructions above. Review the previous work and appl
}
// No context, start fresh - executeFeature will handle adding to runningFeatures
// Remove the temporary entry we added
this.runningFeatures.delete(featureId);
return this.executeFeature(projectPath, featureId, useWorktrees, false);
}
/**
* Resume a feature that crashed during pipeline execution.
* Handles multiple edge cases to ensure robust recovery:
* - No context file: Restart entire pipeline from beginning
* - Step deleted from config: Complete feature without remaining pipeline steps
* - Valid step exists: Resume from the crashed step and continue
*
* @param {string} projectPath - Absolute path to the project directory
* @param {Feature} feature - The feature object (already loaded to avoid redundant reads)
* @param {boolean} useWorktrees - Whether to use git worktrees for isolation
* @param {PipelineStatusInfo} pipelineInfo - Information about the pipeline status from detectPipelineStatus()
* @returns {Promise<void>} Resolves when resume operation completes or throws on error
* @throws {Error} If pipeline config is null but stepIndex is valid (should never happen)
* @private
*/
private async resumePipelineFeature(
projectPath: string,
feature: Feature,
useWorktrees: boolean,
pipelineInfo: PipelineStatusInfo
): Promise<void> {
const featureId = feature.id;
console.log(
`[AutoMode] Resuming feature ${featureId} from pipeline step ${pipelineInfo.stepId}`
);
// Check for context file
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, 'agent-output.md');
let hasContext = false;
try {
await secureFs.access(contextPath);
hasContext = true;
} catch {
// No context
}
// Edge Case 1: No context file - restart entire pipeline from beginning
if (!hasContext) {
console.warn(
`[AutoMode] No context found for pipeline feature ${featureId}, restarting from beginning`
);
// Reset status to in_progress and start fresh
await this.updateFeatureStatus(projectPath, featureId, 'in_progress');
return this.executeFeature(projectPath, featureId, useWorktrees, false);
}
// Edge Case 2: Step no longer exists in pipeline config
if (pipelineInfo.stepIndex === -1) {
console.warn(
`[AutoMode] Step ${pipelineInfo.stepId} no longer exists in pipeline, completing feature without pipeline`
);
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
passes: true,
message:
'Pipeline step no longer exists - feature completed without remaining pipeline steps',
projectPath,
});
return;
}
// Normal case: Valid pipeline step exists, has context
// Resume from the stuck step (re-execute the step that crashed)
if (!pipelineInfo.config) {
throw new Error('Pipeline config is null but stepIndex is valid - this should not happen');
}
return this.resumeFromPipelineStep(
projectPath,
feature,
useWorktrees,
pipelineInfo.stepIndex,
pipelineInfo.config
);
}
/**
* Resume pipeline execution from a specific step index.
* Re-executes the step that crashed (to handle partial completion),
* then continues executing all remaining pipeline steps in order.
*
* This method handles the complete pipeline resume workflow:
* - Validates feature and step index
* - Locates or creates git worktree if needed
* - Executes remaining steps starting from the crashed step
* - Updates feature status to verified/waiting_approval when complete
* - Emits progress events throughout execution
*
* @param {string} projectPath - Absolute path to the project directory
* @param {Feature} feature - The feature object (already loaded to avoid redundant reads)
* @param {boolean} useWorktrees - Whether to use git worktrees for isolation
* @param {number} startFromStepIndex - Zero-based index of the step to resume from
* @param {PipelineConfig} pipelineConfig - Pipeline config passed from detectPipelineStatus to avoid re-reading
* @returns {Promise<void>} Resolves when pipeline execution completes successfully
* @throws {Error} If feature not found, step index invalid, or pipeline execution fails
* @private
*/
private async resumeFromPipelineStep(
projectPath: string,
feature: Feature,
useWorktrees: boolean,
startFromStepIndex: number,
pipelineConfig: PipelineConfig
): Promise<void> {
const featureId = feature.id;
const sortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order);
// Validate step index
if (startFromStepIndex < 0 || startFromStepIndex >= sortedSteps.length) {
throw new Error(`Invalid step index: ${startFromStepIndex}`);
}
// Get steps to execute (from startFromStepIndex onwards)
const stepsToExecute = sortedSteps.slice(startFromStepIndex);
console.log(
`[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}`
);
// Add to running features immediately
const abortController = new AbortController();
this.runningFeatures.set(featureId, {
featureId,
projectPath,
worktreePath: null, // Will be set below
branchName: feature.branchName ?? null,
abortController,
isAutoMode: false,
startTime: Date.now(),
});
try {
// Validate project path
validateWorkingDirectory(projectPath);
// Derive workDir from feature.branchName
let worktreePath: string | null = null;
const branchName = feature.branchName;
if (useWorktrees && branchName) {
worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName);
if (worktreePath) {
console.log(`[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}`);
} else {
console.warn(
`[AutoMode] Worktree for branch "${branchName}" not found, using project path`
);
}
}
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
validateWorkingDirectory(workDir);
// Update running feature with worktree info
const runningFeature = this.runningFeatures.get(featureId);
if (runningFeature) {
runningFeature.worktreePath = worktreePath;
runningFeature.branchName = branchName ?? null;
}
// Emit resume event
this.emitAutoModeEvent('auto_mode_feature_start', {
featureId,
projectPath,
feature: {
id: featureId,
title: feature.title || 'Resuming Pipeline',
description: feature.description,
},
});
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
content: `Resuming from pipeline step ${startFromStepIndex + 1}/${sortedSteps.length}`,
projectPath,
});
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
this.settingsService,
'[AutoMode]'
);
// Execute remaining pipeline steps (starting from crashed step)
await this.executePipelineSteps(
projectPath,
featureId,
feature,
stepsToExecute,
workDir,
abortController,
autoLoadClaudeMd
);
// Determine final status
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
console.log('[AutoMode] Pipeline resume completed successfully');
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
passes: true,
message: 'Pipeline resumed and completed successfully',
projectPath,
});
} catch (error) {
const errorInfo = classifyError(error);
if (errorInfo.isAbort) {
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
passes: false,
message: 'Pipeline resume stopped by user',
projectPath,
});
} else {
console.error(`[AutoMode] Pipeline resume failed for feature ${featureId}:`, error);
await this.updateFeatureStatus(projectPath, featureId, 'backlog');
this.emitAutoModeEvent('auto_mode_error', {
featureId,
error: errorInfo.message,
errorType: errorInfo.type,
projectPath,
});
}
} finally {
this.runningFeatures.delete(featureId);
}
}
/**
* Follow up on a feature with additional instructions
*/
@@ -2885,6 +3168,111 @@ Review the previous work and continue the implementation. If the feature appears
});
}
/**
* Detect if a feature is stuck in a pipeline step and extract step information.
* Parses the feature status to determine if it's a pipeline status (e.g., 'pipeline_step_xyz'),
* loads the pipeline configuration, and validates that the step still exists.
*
* This method handles several scenarios:
* - Non-pipeline status: Returns default PipelineStatusInfo with isPipeline=false
* - Invalid pipeline status format: Returns isPipeline=true but null step info
* - Step deleted from config: Returns stepIndex=-1 to signal missing step
* - Valid pipeline step: Returns full step information and config
*
* @param {string} projectPath - Absolute path to the project directory
* @param {string} featureId - Unique identifier of the feature
* @param {FeatureStatusWithPipeline} currentStatus - Current feature status (may include pipeline step info)
* @returns {Promise<PipelineStatusInfo>} Information about the pipeline status and step
* @private
*/
private async detectPipelineStatus(
projectPath: string,
featureId: string,
currentStatus: FeatureStatusWithPipeline
): Promise<PipelineStatusInfo> {
// Check if status is pipeline format using PipelineService
const isPipeline = pipelineService.isPipelineStatus(currentStatus);
if (!isPipeline) {
return {
isPipeline: false,
stepId: null,
stepIndex: -1,
totalSteps: 0,
step: null,
config: null,
};
}
// Extract step ID using PipelineService
const stepId = pipelineService.getStepIdFromStatus(currentStatus);
if (!stepId) {
console.warn(
`[AutoMode] Feature ${featureId} has invalid pipeline status format: ${currentStatus}`
);
return {
isPipeline: true,
stepId: null,
stepIndex: -1,
totalSteps: 0,
step: null,
config: null,
};
}
// Load pipeline config
const config = await pipelineService.getPipelineConfig(projectPath);
if (!config || config.steps.length === 0) {
// Pipeline config doesn't exist or empty - feature stuck with invalid pipeline status
console.warn(
`[AutoMode] Feature ${featureId} has pipeline status but no pipeline config exists`
);
return {
isPipeline: true,
stepId,
stepIndex: -1,
totalSteps: 0,
step: null,
config: null,
};
}
// Find the step directly from config (already loaded, avoid redundant file read)
const sortedSteps = [...config.steps].sort((a, b) => a.order - b.order);
const stepIndex = sortedSteps.findIndex((s) => s.id === stepId);
const step = stepIndex === -1 ? null : sortedSteps[stepIndex];
if (!step) {
// Step not found in current config - step was deleted/changed
console.warn(
`[AutoMode] Feature ${featureId} stuck in step ${stepId} which no longer exists in pipeline config`
);
return {
isPipeline: true,
stepId,
stepIndex: -1,
totalSteps: sortedSteps.length,
step: null,
config,
};
}
console.log(
`[AutoMode] Detected pipeline status for feature ${featureId}: step ${stepIndex + 1}/${sortedSteps.length} (${step.name})`
);
return {
isPipeline: true,
stepId,
stepIndex,
totalSteps: sortedSteps.length,
step,
config,
};
}
/**
* Build a focused prompt for executing a single task.
* Each task gets minimal context to keep the agent focused.
@@ -3193,41 +3581,43 @@ IMPORTANT: Only include NON-OBVIOUS learnings with real reasoning. Skip trivial
If nothing notable: {"learnings": []}`;
try {
// Import query dynamically to avoid circular dependencies
const { query } = await import('@anthropic-ai/claude-agent-sdk');
// Get model from phase settings
const settings = await this.settingsService?.getGlobalSettings();
const phaseModelEntry =
settings?.phaseModels?.memoryExtractionModel || DEFAULT_PHASE_MODELS.memoryExtractionModel;
const { model } = resolvePhaseModel(phaseModelEntry);
const hasClaudeKey = Boolean(process.env.ANTHROPIC_API_KEY);
let resolvedModel = model;
const stream = query({
prompt: userPrompt,
options: {
model,
maxTurns: 1,
allowedTools: [],
permissionMode: 'acceptEdits',
systemPrompt:
'You are a JSON extraction assistant. You MUST respond with ONLY valid JSON, no explanations, no markdown, no other text. Extract learnings from the provided implementation context and return them as JSON.',
},
});
// Extract text from stream
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
if (isClaudeModel(model) && !hasClaudeKey) {
const fallbackModel = feature.model
? resolveModelString(feature.model, DEFAULT_MODELS.claude)
: null;
if (fallbackModel && !isClaudeModel(fallbackModel)) {
console.log(
`[AutoMode] Claude not configured for memory extraction; using feature model "${fallbackModel}".`
);
resolvedModel = fallbackModel;
} else {
console.log(
'[AutoMode] Claude not configured for memory extraction; skipping learning extraction.'
);
return;
}
}
const result = await simpleQuery({
prompt: userPrompt,
model: resolvedModel,
cwd: projectPath,
maxTurns: 1,
allowedTools: [],
systemPrompt:
'You are a JSON extraction assistant. You MUST respond with ONLY valid JSON, no explanations, no markdown, no other text. Extract learnings from the provided implementation context and return them as JSON.',
});
const responseText = result.text;
console.log(`[AutoMode] Learning extraction response: ${responseText.length} chars`);
console.log(`[AutoMode] Response preview: ${responseText.substring(0, 300)}`);

View File

@@ -49,13 +49,11 @@ export class ClaudeUsageService {
/**
* Execute the claude /usage command and return the output
* Uses platform-specific PTY implementation
* Uses node-pty on all platforms for consistency
*/
private executeClaudeUsageCommand(): Promise<string> {
if (this.isWindows || this.isLinux) {
return this.executeClaudeUsageCommandPty();
}
return this.executeClaudeUsageCommandMac();
// Use node-pty on all platforms - it's more reliable than expect on macOS
return this.executeClaudeUsageCommandPty();
}
/**
@@ -67,24 +65,36 @@ export class ClaudeUsageService {
let stderr = '';
let settled = false;
// Use a simple working directory (home or tmp)
const workingDirectory = process.env.HOME || '/tmp';
// Use current working directory - likely already trusted by Claude CLI
const workingDirectory = process.cwd();
// Use 'expect' with an inline script to run claude /usage with a PTY
// Wait for "Current session" header, then wait for full output before exiting
// Running from cwd which should already be trusted
const expectScript = `
set timeout 20
set timeout 30
spawn claude /usage
# Wait for usage data or handle trust prompt if needed
expect {
"Current session" {
sleep 2
send "\\x1b"
-re "Ready to code|permission to work|Do you want to work" {
# Trust prompt appeared - send Enter to approve
sleep 1
send "\\r"
exp_continue
}
"Esc to cancel" {
"Current session" {
# Usage data appeared - wait for full output, then exit
sleep 3
send "\\x1b"
}
timeout {}
"% left" {
# Usage percentage appeared
sleep 3
send "\\x1b"
}
timeout {
send "\\x1b"
}
eof {}
}
expect eof
@@ -158,14 +168,18 @@ export class ClaudeUsageService {
let output = '';
let settled = false;
let hasSeenUsageData = false;
let hasSeenTrustPrompt = false;
const workingDirectory = this.isWindows
? process.env.USERPROFILE || os.homedir() || 'C:\\'
: process.env.HOME || os.homedir() || '/tmp';
// Use current working directory (project dir) - most likely already trusted by Claude CLI
const workingDirectory = process.cwd();
// Use platform-appropriate shell and command
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
// Use --add-dir to whitelist the current directory and bypass the trust prompt
// We don't pass /usage here, we'll type it into the REPL
const args = this.isWindows
? ['/c', 'claude', '--add-dir', workingDirectory]
: ['-c', `claude --add-dir "${workingDirectory}"`];
let ptyProcess: any = null;
@@ -181,8 +195,6 @@ export class ClaudeUsageService {
} as Record<string, string>,
});
} catch (spawnError) {
// pty.spawn() can throw synchronously if the native module fails to load
// or if PTY is not available in the current environment (e.g., containers without /dev/pts)
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
@@ -204,17 +216,60 @@ export class ClaudeUsageService {
// Don't fail if we have data - return it instead
if (output.includes('Current session')) {
resolve(output);
} else if (hasSeenTrustPrompt) {
// Trust prompt was shown but we couldn't auto-approve it
reject(
new Error(
'TRUST_PROMPT_PENDING: Claude CLI is waiting for folder permission. Please run "claude" in your terminal and approve access to continue.'
)
);
} else {
reject(new Error('Command timed out'));
reject(
new Error(
'The Claude CLI took too long to respond. This can happen if the CLI is waiting for a trust prompt or is otherwise busy.'
)
);
}
}
}, this.timeout);
}, 45000); // 45 second timeout
let hasSentCommand = false;
let hasApprovedTrust = false;
ptyProcess.onData((data: string) => {
output += data;
// Check if we've seen the usage data (look for "Current session")
if (!hasSeenUsageData && output.includes('Current session')) {
// Strip ANSI codes for easier matching
// eslint-disable-next-line no-control-regex
const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
// Check for specific authentication/permission errors
if (
cleanOutput.includes('OAuth token does not meet scope requirement') ||
cleanOutput.includes('permission_error') ||
cleanOutput.includes('token_expired') ||
cleanOutput.includes('authentication_error')
) {
if (!settled) {
settled = true;
if (ptyProcess && !ptyProcess.killed) {
ptyProcess.kill();
}
reject(
new Error(
"Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions."
)
);
}
return;
}
// Check if we've seen the usage data (look for "Current session" or the TUI Usage header)
if (
!hasSeenUsageData &&
(cleanOutput.includes('Current session') ||
(cleanOutput.includes('Usage') && cleanOutput.includes('% left')))
) {
hasSeenUsageData = true;
// Wait for full output, then send escape to exit
setTimeout(() => {
@@ -228,16 +283,62 @@ export class ClaudeUsageService {
}
}, 2000);
}
}, 2000);
}, 3000);
}
// Handle Trust Dialog - multiple variants:
// - "Do you want to work in this folder?"
// - "Ready to code here?" / "I'll need permission to work with your files"
// Since we are running in cwd (project dir), it is safe to approve.
if (
!hasApprovedTrust &&
(cleanOutput.includes('Do you want to work in this folder?') ||
cleanOutput.includes('Ready to code here') ||
cleanOutput.includes('permission to work with your files'))
) {
hasApprovedTrust = true;
hasSeenTrustPrompt = true;
// Wait a tiny bit to ensure prompt is ready, then send Enter
setTimeout(() => {
if (!settled && ptyProcess && !ptyProcess.killed) {
ptyProcess.write('\r');
}
}, 1000);
}
// Detect REPL prompt and send /usage command
if (
!hasSentCommand &&
(cleanOutput.includes('') || cleanOutput.includes('? for shortcuts'))
) {
hasSentCommand = true;
// Wait for REPL to fully settle
setTimeout(() => {
if (!settled && ptyProcess && !ptyProcess.killed) {
// Send command with carriage return
ptyProcess.write('/usage\r');
// Send another enter after 1 second to confirm selection if autocomplete menu appeared
setTimeout(() => {
if (!settled && ptyProcess && !ptyProcess.killed) {
ptyProcess.write('\r');
}
}, 1200);
}
}, 1500);
}
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
if (
!hasSeenUsageData &&
cleanOutput.includes('Esc to cancel') &&
!cleanOutput.includes('Do you want to work in this folder?')
) {
setTimeout(() => {
if (!settled && ptyProcess && !ptyProcess.killed) {
ptyProcess.write('\x1b'); // Send escape key
}
}, 3000);
}, 5000);
}
});
@@ -246,8 +347,11 @@ export class ClaudeUsageService {
if (settled) return;
settled = true;
// Check for authentication errors in output
if (output.includes('token_expired') || output.includes('authentication_error')) {
if (
output.includes('token_expired') ||
output.includes('authentication_error') ||
output.includes('permission_error')
) {
reject(new Error("Authentication required - please run 'claude login'"));
return;
}

View File

@@ -12,24 +12,123 @@ import * as secureFs from '../lib/secure-fs.js';
import path from 'path';
import net from 'net';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js';
const logger = createLogger('DevServerService');
// Maximum scrollback buffer size (characters) - matches TerminalService pattern
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server
// Throttle output to prevent overwhelming WebSocket under heavy load
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
export interface DevServerInfo {
worktreePath: string;
port: number;
url: string;
process: ChildProcess | null;
startedAt: Date;
// Scrollback buffer for log history (replay on reconnect)
scrollbackBuffer: string;
// Pending output to be flushed to subscribers
outputBuffer: string;
// Throttle timer for batching output
flushTimeout: NodeJS.Timeout | null;
// Flag to indicate server is stopping (prevents output after stop)
stopping: boolean;
}
// Port allocation starts at 3001 to avoid conflicts with common dev ports
const BASE_PORT = 3001;
const MAX_PORT = 3099; // Safety limit
// Common livereload ports that may need cleanup when stopping dev servers
const LIVERELOAD_PORTS = [35729, 35730, 35731] as const;
class DevServerService {
private runningServers: Map<string, DevServerInfo> = new Map();
private allocatedPorts: Set<number> = new Set();
private emitter: EventEmitter | null = null;
/**
* Set the event emitter for streaming log events
* Called during service initialization with the global event emitter
*/
setEventEmitter(emitter: EventEmitter): void {
this.emitter = emitter;
}
/**
* Append data to scrollback buffer with size limit enforcement
* Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE
*/
private appendToScrollback(server: DevServerInfo, data: string): void {
server.scrollbackBuffer += data;
if (server.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) {
server.scrollbackBuffer = server.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
}
}
/**
* Flush buffered output to WebSocket subscribers
* Sends batched output to prevent overwhelming clients under heavy load
*/
private flushOutput(server: DevServerInfo): void {
// Skip flush if server is stopping or buffer is empty
if (server.stopping || server.outputBuffer.length === 0) {
server.flushTimeout = null;
return;
}
let dataToSend = server.outputBuffer;
if (dataToSend.length > OUTPUT_BATCH_SIZE) {
// Send in batches if buffer is large
dataToSend = server.outputBuffer.slice(0, OUTPUT_BATCH_SIZE);
server.outputBuffer = server.outputBuffer.slice(OUTPUT_BATCH_SIZE);
// Schedule another flush for remaining data
server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS);
} else {
server.outputBuffer = '';
server.flushTimeout = null;
}
// Emit output event for WebSocket streaming
if (this.emitter) {
this.emitter.emit('dev-server:output', {
worktreePath: server.worktreePath,
content: dataToSend,
timestamp: new Date().toISOString(),
});
}
}
/**
* Handle incoming stdout/stderr data from dev server process
* Buffers data for scrollback replay and schedules throttled emission
*/
private handleProcessOutput(server: DevServerInfo, data: Buffer): void {
// Skip output if server is stopping
if (server.stopping) {
return;
}
const content = data.toString();
// Append to scrollback buffer for replay on reconnect
this.appendToScrollback(server, content);
// Buffer output for throttled live delivery
server.outputBuffer += content;
// Schedule flush if not already scheduled
if (!server.flushTimeout) {
server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS);
}
// Also log for debugging (existing behavior)
logger.debug(`[Port${server.port}] ${content.trim()}`);
}
/**
* Check if a port is available (not in use by system or by us)
@@ -244,10 +343,9 @@ class DevServerService {
// Reserve the port (port was already force-killed in findAvailablePort)
this.allocatedPorts.add(port);
// Also kill common related ports (livereload uses 35729 by default)
// Also kill common related ports (livereload, etc.)
// Some dev servers use fixed ports for HMR/livereload regardless of main port
const commonRelatedPorts = [35729, 35730, 35731];
for (const relatedPort of commonRelatedPorts) {
for (const relatedPort of LIVERELOAD_PORTS) {
this.killProcessOnPort(relatedPort);
}
@@ -259,9 +357,14 @@ class DevServerService {
logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
// Spawn the dev process with PORT environment variable
// FORCE_COLOR enables colored output even when not running in a TTY
const env = {
...process.env,
PORT: String(port),
FORCE_COLOR: '1',
// Some tools use these additional env vars for color detection
COLORTERM: 'truecolor',
TERM: 'xterm-256color',
};
const devProcess = spawn(devCommand.cmd, devCommand.args, {
@@ -274,32 +377,66 @@ class DevServerService {
// Track if process failed early using object to work around TypeScript narrowing
const status = { error: null as string | null, exited: false };
// Log output for debugging
// Create server info early so we can reference it in handlers
// We'll add it to runningServers after verifying the process started successfully
const serverInfo: DevServerInfo = {
worktreePath,
port,
url: `http://localhost:${port}`,
process: devProcess,
startedAt: new Date(),
scrollbackBuffer: '',
outputBuffer: '',
flushTimeout: null,
stopping: false,
};
// Capture stdout with buffer management and event emission
if (devProcess.stdout) {
devProcess.stdout.on('data', (data: Buffer) => {
logger.debug(`[Port${port}] ${data.toString().trim()}`);
this.handleProcessOutput(serverInfo, data);
});
}
// Capture stderr with buffer management and event emission
if (devProcess.stderr) {
devProcess.stderr.on('data', (data: Buffer) => {
const msg = data.toString().trim();
logger.debug(`[Port${port}] ${msg}`);
this.handleProcessOutput(serverInfo, data);
});
}
// Helper to clean up resources and emit stop event
const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => {
if (serverInfo.flushTimeout) {
clearTimeout(serverInfo.flushTimeout);
serverInfo.flushTimeout = null;
}
// Emit stopped event (only if not already stopping - prevents duplicate events)
if (this.emitter && !serverInfo.stopping) {
this.emitter.emit('dev-server:stopped', {
worktreePath,
port,
exitCode,
error: errorMessage,
timestamp: new Date().toISOString(),
});
}
this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath);
};
devProcess.on('error', (error) => {
logger.error(`Process error:`, error);
status.error = error.message;
this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath);
cleanupAndEmitStop(null, error.message);
});
devProcess.on('exit', (code) => {
logger.info(`Process for ${worktreePath} exited with code ${code}`);
status.exited = true;
this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath);
cleanupAndEmitStop(code);
});
// Wait a moment to see if the process fails immediately
@@ -319,16 +456,19 @@ class DevServerService {
};
}
const serverInfo: DevServerInfo = {
worktreePath,
port,
url: `http://localhost:${port}`,
process: devProcess,
startedAt: new Date(),
};
// Server started successfully - add to running servers map
this.runningServers.set(worktreePath, serverInfo);
// Emit started event for WebSocket subscribers
if (this.emitter) {
this.emitter.emit('dev-server:started', {
worktreePath,
port,
url: serverInfo.url,
timestamp: new Date().toISOString(),
});
}
return {
success: true,
result: {
@@ -365,6 +505,28 @@ class DevServerService {
logger.info(`Stopping dev server for ${worktreePath}`);
// Mark as stopping to prevent further output events
server.stopping = true;
// Clean up flush timeout to prevent memory leaks
if (server.flushTimeout) {
clearTimeout(server.flushTimeout);
server.flushTimeout = null;
}
// Clear any pending output buffer
server.outputBuffer = '';
// Emit stopped event immediately so UI updates right away
if (this.emitter) {
this.emitter.emit('dev-server:stopped', {
worktreePath,
port: server.port,
exitCode: null, // Will be populated by exit handler if process exits normally
timestamp: new Date().toISOString(),
});
}
// Kill the process
if (server.process && !server.process.killed) {
server.process.kill('SIGTERM');
@@ -422,6 +584,41 @@ class DevServerService {
return this.runningServers.get(worktreePath);
}
/**
* Get buffered logs for a worktree's dev server
* Returns the scrollback buffer containing historical log output
* Used by the API to serve logs to clients on initial connection
*/
getServerLogs(worktreePath: string): {
success: boolean;
result?: {
worktreePath: string;
port: number;
logs: string;
startedAt: string;
};
error?: string;
} {
const server = this.runningServers.get(worktreePath);
if (!server) {
return {
success: false,
error: `No dev server running for worktree: ${worktreePath}`,
};
}
return {
success: true,
result: {
worktreePath: server.worktreePath,
port: server.port,
logs: server.scrollbackBuffer,
startedAt: server.startedAt.toISOString(),
},
};
}
/**
* Get all allocated ports
*/

View File

@@ -431,6 +431,8 @@ export class SettingsService {
*/
async getMaskedCredentials(): Promise<{
anthropic: { configured: boolean; masked: string };
google: { configured: boolean; masked: string };
openai: { configured: boolean; masked: string };
}> {
const credentials = await this.getCredentials();
@@ -444,6 +446,14 @@ export class SettingsService {
configured: !!credentials.apiKeys.anthropic,
masked: maskKey(credentials.apiKeys.anthropic),
},
google: {
configured: !!credentials.apiKeys.google,
masked: maskKey(credentials.apiKeys.google),
},
openai: {
configured: !!credentials.apiKeys.openai,
masked: maskKey(credentials.apiKeys.openai),
},
};
}

View File

@@ -12,6 +12,8 @@ describe('claude-provider.ts', () => {
vi.clearAllMocks();
provider = new ClaudeProvider();
delete process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_BASE_URL;
delete process.env.ANTHROPIC_AUTH_TOKEN;
});
describe('getName', () => {
@@ -267,6 +269,93 @@ describe('claude-provider.ts', () => {
});
});
describe('environment variable passthrough', () => {
afterEach(() => {
delete process.env.ANTHROPIC_BASE_URL;
delete process.env.ANTHROPIC_AUTH_TOKEN;
});
it('should pass ANTHROPIC_BASE_URL to SDK env', async () => {
process.env.ANTHROPIC_BASE_URL = 'https://custom.example.com/v1';
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: 'text', text: 'test' };
})()
);
const generator = provider.executeQuery({
prompt: 'Test',
cwd: '/test',
});
await collectAsyncGenerator(generator);
expect(sdk.query).toHaveBeenCalledWith({
prompt: 'Test',
options: expect.objectContaining({
env: expect.objectContaining({
ANTHROPIC_BASE_URL: 'https://custom.example.com/v1',
}),
}),
});
});
it('should pass ANTHROPIC_AUTH_TOKEN to SDK env', async () => {
process.env.ANTHROPIC_AUTH_TOKEN = 'custom-auth-token';
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: 'text', text: 'test' };
})()
);
const generator = provider.executeQuery({
prompt: 'Test',
cwd: '/test',
});
await collectAsyncGenerator(generator);
expect(sdk.query).toHaveBeenCalledWith({
prompt: 'Test',
options: expect.objectContaining({
env: expect.objectContaining({
ANTHROPIC_AUTH_TOKEN: 'custom-auth-token',
}),
}),
});
});
it('should pass both custom endpoint vars together', async () => {
process.env.ANTHROPIC_BASE_URL = 'https://gateway.example.com';
process.env.ANTHROPIC_AUTH_TOKEN = 'gateway-token';
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: 'text', text: 'test' };
})()
);
const generator = provider.executeQuery({
prompt: 'Test',
cwd: '/test',
});
await collectAsyncGenerator(generator);
expect(sdk.query).toHaveBeenCalledWith({
prompt: 'Test',
options: expect.objectContaining({
env: expect.objectContaining({
ANTHROPIC_BASE_URL: 'https://gateway.example.com',
ANTHROPIC_AUTH_TOKEN: 'gateway-token',
}),
}),
});
});
});
describe('getAvailableModels', () => {
it('should return 4 Claude models', () => {
const models = provider.getAvailableModels();

View File

@@ -257,7 +257,7 @@ describe('codex-provider.ts', () => {
expect(results[1].result).toBe('Hello from SDK');
});
it('uses the CLI when tools are requested even if an API key is present', async () => {
it('uses the SDK when API key is present, even for tool requests (to avoid OAuth issues)', async () => {
process.env[OPENAI_API_KEY_ENV] = 'sk-test';
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
@@ -270,8 +270,8 @@ describe('codex-provider.ts', () => {
})
);
expect(codexRunMock).not.toHaveBeenCalled();
expect(spawnJSONLProcess).toHaveBeenCalled();
expect(codexRunMock).toHaveBeenCalled();
expect(spawnJSONLProcess).not.toHaveBeenCalled();
});
it('falls back to CLI when no tools are requested and no API key is available', async () => {

View File

@@ -551,7 +551,7 @@ Resets in 2h
expect(result.sessionPercentage).toBe(35);
expect(pty.spawn).toHaveBeenCalledWith(
'cmd.exe',
['/c', 'claude', '/usage'],
['/c', 'claude', '--add-dir', 'C:\\Users\\testuser'],
expect.any(Object)
);
});
@@ -582,8 +582,8 @@ Resets in 2h
// Simulate seeing usage data
dataCallback!(mockOutput);
// Advance time to trigger escape key sending
vi.advanceTimersByTime(2100);
// Advance time to trigger escape key sending (impl uses 3000ms delay)
vi.advanceTimersByTime(3100);
expect(mockPty.write).toHaveBeenCalledWith('\x1b');
@@ -614,9 +614,10 @@ Resets in 2h
const promise = windowsService.fetchUsageData();
dataCallback!('authentication_error');
exitCallback!({ exitCode: 1 });
await expect(promise).rejects.toThrow('Authentication required');
await expect(promise).rejects.toThrow(
"Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions."
);
});
it('should handle timeout with no data on Windows', async () => {
@@ -628,14 +629,18 @@ Resets in 2h
onExit: vi.fn(),
write: vi.fn(),
kill: vi.fn(),
killed: false,
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData();
vi.advanceTimersByTime(31000);
// Advance time past timeout (45 seconds)
vi.advanceTimersByTime(46000);
await expect(promise).rejects.toThrow('Command timed out');
await expect(promise).rejects.toThrow(
'The Claude CLI took too long to respond. This can happen if the CLI is waiting for a trust prompt or is otherwise busy.'
);
expect(mockPty.kill).toHaveBeenCalled();
vi.useRealTimers();
@@ -654,6 +659,7 @@ Resets in 2h
onExit: vi.fn(),
write: vi.fn(),
kill: vi.fn(),
killed: false,
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
@@ -662,8 +668,8 @@ Resets in 2h
// Simulate receiving usage data
dataCallback!('Current session\n65% left\nResets in 2h');
// Advance time past timeout (30 seconds)
vi.advanceTimersByTime(31000);
// Advance time past timeout (45 seconds)
vi.advanceTimersByTime(46000);
// Should resolve with data instead of rejecting
const result = await promise;
@@ -686,6 +692,7 @@ Resets in 2h
onExit: vi.fn(),
write: vi.fn(),
kill: vi.fn(),
killed: false,
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
@@ -694,8 +701,8 @@ Resets in 2h
// Simulate seeing usage data
dataCallback!('Current session\n65% left');
// Advance 2s to trigger ESC
vi.advanceTimersByTime(2100);
// Advance 3s to trigger ESC (impl uses 3000ms delay)
vi.advanceTimersByTime(3100);
expect(mockPty.write).toHaveBeenCalledWith('\x1b');
// Advance another 2s to trigger SIGTERM fallback

View File

@@ -70,6 +70,8 @@ const eslintConfig = defineConfig([
AbortSignal: 'readonly',
Audio: 'readonly',
ScrollBehavior: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
// Timers
setTimeout: 'readonly',
setInterval: 'readonly',

View File

@@ -56,6 +56,7 @@
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-radio-group": "1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-slider": "1.3.6",
"@radix-ui/react-slot": "1.2.4",

View File

@@ -5,6 +5,7 @@ import { router } from './utils/router';
import { SplashScreen } from './components/splash-screen';
import { useSettingsSync } from './hooks/use-settings-sync';
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
import { useProviderAuthInit } from './hooks/use-provider-auth-init';
import './styles/global.css';
import './styles/theme-imports';
@@ -24,8 +25,11 @@ export default function App() {
useEffect(() => {
if (import.meta.env.DEV) {
const clearPerfEntries = () => {
performance.clearMarks();
performance.clearMeasures();
// Check if window.performance is available before calling its methods
if (window.performance) {
window.performance.clearMarks();
window.performance.clearMeasures();
}
};
const interval = setInterval(clearPerfEntries, 5000);
return () => clearInterval(interval);
@@ -45,6 +49,9 @@ export default function App() {
// Initialize Cursor CLI status at startup
useCursorStatusInit();
// Initialize Provider auth status at startup (for Claude/Codex usage display)
useProviderAuthInit();
const handleSplashComplete = useCallback(() => {
sessionStorage.setItem('automaker-splash-shown', 'true');
setShowSplash(false);

View File

@@ -11,6 +11,7 @@ import { useSetupStore } from '@/store/setup-store';
const ERROR_CODES = {
API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE',
AUTH_ERROR: 'AUTH_ERROR',
TRUST_PROMPT: 'TRUST_PROMPT',
UNKNOWN: 'UNKNOWN',
} as const;
@@ -55,8 +56,12 @@ export function ClaudeUsagePopover() {
}
const data = await api.claude.getUsage();
if ('error' in data) {
// Detect trust prompt error
const isTrustPrompt =
data.error === 'Trust prompt pending' ||
(data.message && data.message.includes('folder permission'));
setError({
code: ERROR_CODES.AUTH_ERROR,
code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR,
message: data.message || data.error,
});
return;
@@ -257,6 +262,11 @@ export function ClaudeUsagePopover() {
<p className="text-xs text-muted-foreground">
{error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
'Ensure the Electron bridge is running or restart the app'
) : error.code === ERROR_CODES.TRUST_PROMPT ? (
<>
Run <code className="font-mono bg-muted px-1 rounded">claude</code> in your
terminal and approve access to continue
</>
) : (
<>
Make sure Claude CLI is installed and authenticated via{' '}

View File

@@ -0,0 +1,187 @@
import { useState, useRef } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Upload, X, ImageIcon } from 'lucide-react';
import { useAppStore } from '@/store/app-store';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Project } from '@/lib/electron';
import { IconPicker } from './icon-picker';
interface EditProjectDialogProps {
project: Project;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDialogProps) {
const { setProjectName, setProjectIcon, setProjectCustomIcon } = useAppStore();
const [name, setName] = useState(project.name);
const [icon, setIcon] = useState<string | null>((project as any).icon || null);
const [customIconPath, setCustomIconPath] = useState<string | null>(
(project as any).customIconPath || null
);
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSave = () => {
if (name.trim() !== project.name) {
setProjectName(project.id, name.trim());
}
if (icon !== (project as any).icon) {
setProjectIcon(project.id, icon);
}
if (customIconPath !== (project as any).customIconPath) {
setProjectCustomIcon(project.id, customIconPath);
}
onOpenChange(false);
};
const handleCustomIconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
return;
}
// Validate file size (max 2MB for icons)
if (file.size > 2 * 1024 * 1024) {
return;
}
setIsUploadingIcon(true);
try {
// Convert to base64
const reader = new FileReader();
reader.onload = async () => {
const base64Data = reader.result as string;
const result = await getHttpApiClient().saveImageToTemp(
base64Data,
`project-icon-${file.name}`,
file.type,
project.path
);
if (result.success && result.path) {
setCustomIconPath(result.path);
// Clear the Lucide icon when custom icon is set
setIcon(null);
}
setIsUploadingIcon(false);
};
reader.readAsDataURL(file);
} catch {
setIsUploadingIcon(false);
}
};
const handleRemoveCustomIcon = () => {
setCustomIconPath(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit Project</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
{/* Project Name */}
<div className="space-y-2">
<Label htmlFor="project-name">Project Name</Label>
<Input
id="project-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter project name"
/>
</div>
{/* Icon Picker */}
<div className="space-y-2">
<Label>Project Icon</Label>
<p className="text-xs text-muted-foreground mb-2">
Choose a preset icon or upload a custom image
</p>
{/* Custom Icon Upload */}
<div className="mb-4">
<div className="flex items-center gap-3">
{customIconPath ? (
<div className="relative">
<img
src={getAuthenticatedImageUrl(customIconPath, project.path)}
alt="Custom project icon"
className="w-12 h-12 rounded-lg object-cover border border-border"
/>
<button
type="button"
onClick={handleRemoveCustomIcon}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90"
>
<X className="w-3 h-3" />
</button>
</div>
) : (
<div className="w-12 h-12 rounded-lg border border-dashed border-border flex items-center justify-center bg-accent/30">
<ImageIcon className="w-5 h-5 text-muted-foreground" />
</div>
)}
<div className="flex-1">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleCustomIconUpload}
className="hidden"
id="custom-icon-upload-dialog"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingIcon}
className="gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
</Button>
<p className="text-xs text-muted-foreground mt-1">
PNG, JPG, GIF or WebP. Max 2MB.
</p>
</div>
</div>
</div>
{/* Preset Icon Picker - only show if no custom icon */}
{!customIconPath && <IconPicker selectedIcon={icon} onSelectIcon={setIcon} />}
</div>
</div>
<DialogFooter className="flex-shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!name.trim()}>
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,520 @@
import { useState } from 'react';
import { X, Search } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
interface IconPickerProps {
selectedIcon: string | null;
onSelectIcon: (icon: string | null) => void;
}
// Comprehensive list of project-related icons from Lucide
// Organized by category for easier browsing
const POPULAR_ICONS = [
// Folders & Files
'Folder',
'FolderOpen',
'FolderCode',
'FolderGit',
'FolderKanban',
'FolderTree',
'FolderInput',
'FolderOutput',
'FolderPlus',
'File',
'FileCode',
'FileText',
'FileJson',
'FileImage',
'FileVideo',
'FileAudio',
'FileSpreadsheet',
'Files',
'Archive',
// Code & Development
'Code',
'Code2',
'Braces',
'Brackets',
'Terminal',
'TerminalSquare',
'Command',
'GitBranch',
'GitCommit',
'GitMerge',
'GitPullRequest',
'GitCompare',
'GitFork',
'GitHub',
'Gitlab',
'Bitbucket',
'Vscode',
// Packages & Containers
'Package',
'PackageSearch',
'PackageCheck',
'PackageX',
'Box',
'Boxes',
'Container',
// UI & Design
'Layout',
'LayoutGrid',
'LayoutList',
'LayoutDashboard',
'LayoutTemplate',
'Layers',
'Layers2',
'Layers3',
'Blocks',
'Component',
'Palette',
'Paintbrush',
'Brush',
'PenTool',
'Ruler',
'Grid',
'Grid3x3',
'Square',
'RectangleHorizontal',
'RectangleVertical',
'Circle',
// Tools & Settings
'Cog',
'Settings',
'Settings2',
'Wrench',
'Hammer',
'Screwdriver',
'WrenchIcon',
'Tool',
'ScrewdriverWrench',
'Sliders',
'SlidersHorizontal',
'Filter',
'FilterX',
// Technology & Infrastructure
'Server',
'ServerCrash',
'ServerCog',
'Database',
'DatabaseBackup',
'CloudUpload',
'CloudDownload',
'CloudOff',
'Globe',
'Globe2',
'Network',
'Wifi',
'WifiOff',
'Router',
'Cpu',
'MemoryStick',
'HardDrive',
'HardDriveIcon',
'CircuitBoard',
'Microchip',
'Monitor',
'MonitorSpeaker',
'Laptop',
'Smartphone',
'Tablet',
'Mouse',
'Keyboard',
'Headphones',
'Printer',
'Scanner',
// Workflow & Process
'Workflow',
'Zap',
'Rocket',
'Flame',
'Lightning',
'Bolt',
'Target',
'Flag',
'FlagTriangleRight',
'CheckCircle',
'CheckCircle2',
'XCircle',
'AlertCircle',
'Info',
'HelpCircle',
'Clock',
'Timer',
'Stopwatch',
'Calendar',
'CalendarDays',
'CalendarCheck',
'CalendarClock',
// Security & Access
'Shield',
'ShieldCheck',
'ShieldAlert',
'ShieldOff',
'Lock',
'Unlock',
'Key',
'KeyRound',
'Eye',
'EyeOff',
'User',
'Users',
'UserCheck',
'UserX',
'UserPlus',
'UserCog',
// Business & Finance
'Briefcase',
'Building',
'Building2',
'Store',
'ShoppingCart',
'ShoppingBag',
'CreditCard',
'Wallet',
'DollarSign',
'Euro',
'PoundSterling',
'Yen',
'Coins',
'Receipt',
'ChartBar',
'ChartLine',
'ChartPie',
'TrendingUp',
'TrendingDown',
'Activity',
'BarChart',
'LineChart',
'PieChart',
// Communication & Media
'MessageSquare',
'MessageCircle',
'Mail',
'MailOpen',
'Send',
'Inbox',
'Phone',
'PhoneCall',
'Video',
'VideoOff',
'Camera',
'CameraOff',
'Image',
'ImageIcon',
'Film',
'Music',
'Mic',
'MicOff',
'Volume',
'Volume2',
'VolumeX',
'Radio',
'Podcast',
// Social & Community
'Heart',
'HeartHandshake',
'Star',
'StarOff',
'ThumbsUp',
'ThumbsDown',
'Share',
'Share2',
'Link',
'Link2',
'ExternalLink',
'AtSign',
'Hash',
'Hashtag',
'Tag',
'Tags',
// Navigation & Location
'Compass',
'Map',
'MapPin',
'Navigation',
'Navigation2',
'Route',
'Plane',
'Car',
'Bike',
'Ship',
'Train',
'Bus',
// Science & Education
'FlaskConical',
'FlaskRound',
'Beaker',
'TestTube',
'TestTube2',
'Microscope',
'Atom',
'Brain',
'GraduationCap',
'Book',
'BookOpen',
'BookMarked',
'Library',
'School',
'University',
// Food & Health
'Coffee',
'Utensils',
'UtensilsCrossed',
'Apple',
'Cherry',
'Cookie',
'Cake',
'Pizza',
'Beer',
'Wine',
'HeartPulse',
'Dumbbell',
'Running',
// Nature & Weather
'Tree',
'TreePine',
'Leaf',
'Flower',
'Flower2',
'Sun',
'Moon',
'CloudRain',
'CloudSnow',
'CloudLightning',
'Droplet',
'Wind',
'Snowflake',
'Umbrella',
// Objects & Symbols
'Puzzle',
'PuzzleIcon',
'Gamepad',
'Gamepad2',
'Dice',
'Dice1',
'Dice6',
'Gem',
'Crown',
'Trophy',
'Medal',
'Award',
'Gift',
'GiftIcon',
'Bell',
'BellOff',
'BellRing',
'Home',
'House',
'DoorOpen',
'DoorClosed',
'Window',
'Lightbulb',
'LightbulbOff',
'Candle',
'Flashlight',
'FlashlightOff',
'Battery',
'BatteryFull',
'BatteryLow',
'BatteryCharging',
'Plug',
'PlugZap',
'Power',
'PowerOff',
// Arrows & Directions
'ArrowRight',
'ArrowLeft',
'ArrowUp',
'ArrowDown',
'ArrowUpRight',
'ArrowDownRight',
'ArrowDownLeft',
'ArrowUpLeft',
'ChevronRight',
'ChevronLeft',
'ChevronUp',
'ChevronDown',
'Move',
'MoveUp',
'MoveDown',
'MoveLeft',
'MoveRight',
'RotateCw',
'RotateCcw',
'RefreshCw',
'RefreshCcw',
// Shapes & Symbols
'Diamond',
'Pentagon',
'Cross',
'Plus',
'Minus',
'X',
'Check',
'Divide',
'Equal',
'Infinity',
'Percent',
// Miscellaneous
'Bot',
'Wand',
'Wand2',
'Magic',
'Stars',
'Comet',
'Satellite',
'SatelliteDish',
'Radar',
'RadarIcon',
'Scan',
'ScanLine',
'QrCode',
'Barcode',
'ScanSearch',
'Search',
'SearchX',
'ZoomIn',
'ZoomOut',
'Maximize',
'Minimize',
'Maximize2',
'Minimize2',
'Expand',
'Shrink',
'Copy',
'CopyCheck',
'Clipboard',
'ClipboardCheck',
'ClipboardCopy',
'ClipboardList',
'ClipboardPaste',
'Scissors',
'Cut',
'FileEdit',
'Pen',
'Pencil',
'Eraser',
'Trash',
'Trash2',
'Delete',
'ArchiveRestore',
'Download',
'Upload',
'Save',
'SaveAll',
'FilePlus',
'FileMinus',
'FileX',
'FileCheck',
'FileQuestion',
'FileWarning',
'FileSearch',
'FolderSearch',
'FolderX',
'FolderCheck',
'FolderMinus',
'FolderSync',
'FolderUp',
'FolderDown',
];
export function IconPicker({ selectedIcon, onSelectIcon }: IconPickerProps) {
const [search, setSearch] = useState('');
const filteredIcons = POPULAR_ICONS.filter((icon) =>
icon.toLowerCase().includes(search.toLowerCase())
);
const getIconComponent = (iconName: string) => {
return (LucideIcons as Record<string, React.ComponentType<{ className?: string }>>)[iconName];
};
return (
<div className="space-y-3">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search icons..."
className="pl-9"
/>
</div>
{/* Selected Icon Display */}
{selectedIcon && (
<div className="flex items-center gap-2 p-2 rounded-md bg-accent/50 border border-border">
<div className="flex items-center gap-2 flex-1">
{(() => {
const IconComponent = getIconComponent(selectedIcon);
return IconComponent ? <IconComponent className="w-5 h-5 text-brand-500" /> : null;
})()}
<span className="text-sm font-medium">{selectedIcon}</span>
</div>
<button
onClick={() => onSelectIcon(null)}
className="p-1 hover:bg-background rounded transition-colors"
title="Clear icon"
>
<X className="w-4 h-4" />
</button>
</div>
)}
{/* Icons Grid */}
<ScrollArea className="h-96 rounded-md border">
<div className="grid grid-cols-6 gap-1 p-2">
{filteredIcons.map((iconName) => {
const IconComponent = getIconComponent(iconName);
if (!IconComponent) return null;
const isSelected = selectedIcon === iconName;
return (
<button
key={iconName}
onClick={() => onSelectIcon(iconName)}
className={cn(
'aspect-square rounded-md flex items-center justify-center',
'transition-all duration-150',
'hover:bg-accent hover:scale-110',
isSelected
? 'bg-brand-500/20 border-2 border-brand-500'
: 'border border-transparent'
)}
title={iconName}
>
<IconComponent
className={cn('w-5 h-5', isSelected ? 'text-brand-500' : 'text-foreground')}
/>
</button>
);
})}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { ProjectSwitcherItem } from './project-switcher-item';
export { ProjectContextMenu } from './project-context-menu';
export { EditProjectDialog } from './edit-project-dialog';
export { IconPicker } from './icon-picker';

View File

@@ -0,0 +1,333 @@
import { useEffect, useRef, useState, memo } from 'react';
import type { LucideIcon } from 'lucide-react';
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react';
import { cn } from '@/lib/utils';
import { type ThemeMode, useAppStore } from '@/store/app-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import type { Project } from '@/lib/electron';
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '@/components/layout/sidebar/constants';
import { useThemePreview } from '@/components/layout/sidebar/hooks';
// Constants for z-index values
const Z_INDEX = {
CONTEXT_MENU: 100,
THEME_SUBMENU: 101,
} as const;
// Theme option type - using ThemeMode for type safety
interface ThemeOption {
value: ThemeMode;
label: string;
icon: LucideIcon;
color: string;
}
// Reusable theme button component to avoid duplication (DRY principle)
interface ThemeButtonProps {
option: ThemeOption;
isSelected: boolean;
onPointerEnter: () => void;
onPointerLeave: (e: React.PointerEvent) => void;
onClick: () => void;
}
const ThemeButton = memo(function ThemeButton({
option,
isSelected,
onPointerEnter,
onPointerLeave,
onClick,
}: ThemeButtonProps) {
const Icon = option.icon;
return (
<button
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onClick={onClick}
className={cn(
'w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md',
'text-xs text-left',
'hover:bg-accent transition-colors',
'focus:outline-none focus:bg-accent',
isSelected && 'bg-accent'
)}
data-testid={`project-theme-${option.value}`}
>
<Icon className="w-3.5 h-3.5" style={{ color: option.color }} />
<span>{option.label}</span>
</button>
);
});
// Reusable theme column component
interface ThemeColumnProps {
title: string;
icon: LucideIcon;
themes: ThemeOption[];
selectedTheme: ThemeMode | null;
onPreviewEnter: (value: ThemeMode) => void;
onPreviewLeave: (e: React.PointerEvent) => void;
onSelect: (value: ThemeMode) => void;
}
const ThemeColumn = memo(function ThemeColumn({
title,
icon: Icon,
themes,
selectedTheme,
onPreviewEnter,
onPreviewLeave,
onSelect,
}: ThemeColumnProps) {
return (
<div className="flex-1">
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground">
<Icon className="w-3 h-3" />
{title}
</div>
<div className="space-y-0.5">
{themes.map((option) => (
<ThemeButton
key={option.value}
option={option}
isSelected={selectedTheme === option.value}
onPointerEnter={() => onPreviewEnter(option.value)}
onPointerLeave={onPreviewLeave}
onClick={() => onSelect(option.value)}
/>
))}
</div>
</div>
);
});
interface ProjectContextMenuProps {
project: Project;
position: { x: number; y: number };
onClose: () => void;
onEdit: (project: Project) => void;
}
export function ProjectContextMenu({
project,
position,
onClose,
onEdit,
}: ProjectContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const {
moveProjectToTrash,
theme: globalTheme,
setTheme,
setProjectTheme,
setPreviewTheme,
} = useAppStore();
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
const themeSubmenuRef = useRef<HTMLDivElement>(null);
const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme });
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setPreviewTheme(null);
onClose();
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setPreviewTheme(null);
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [onClose, setPreviewTheme]);
const handleEdit = () => {
onEdit(project);
};
const handleRemove = () => {
setShowRemoveDialog(true);
};
const handleThemeSelect = (value: ThemeMode | '') => {
setPreviewTheme(null);
if (value !== '') {
setTheme(value);
} else {
setTheme(globalTheme);
}
setProjectTheme(project.id, value === '' ? null : value);
setShowThemeSubmenu(false);
};
const handleConfirmRemove = () => {
moveProjectToTrash(project.id);
onClose();
};
return (
<>
<div
ref={menuRef}
className={cn(
'fixed min-w-48 rounded-lg',
'bg-popover text-popover-foreground',
'border border-border shadow-lg',
'animate-in fade-in zoom-in-95 duration-100'
)}
style={{
top: position.y,
left: position.x,
zIndex: Z_INDEX.CONTEXT_MENU,
}}
data-testid="project-context-menu"
>
<div className="p-1">
<button
onClick={handleEdit}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',
'hover:bg-accent transition-colors',
'focus:outline-none focus:bg-accent'
)}
data-testid="edit-project-button"
>
<Edit2 className="w-4 h-4" />
<span>Edit Name & Icon</span>
</button>
{/* Theme Submenu Trigger */}
<div
className="relative"
onMouseEnter={() => setShowThemeSubmenu(true)}
onMouseLeave={() => {
setShowThemeSubmenu(false);
setPreviewTheme(null);
}}
>
<button
onClick={() => setShowThemeSubmenu(!showThemeSubmenu)}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',
'hover:bg-accent transition-colors',
'focus:outline-none focus:bg-accent'
)}
data-testid="theme-project-button"
>
<Palette className="w-4 h-4" />
<span className="flex-1">Project Theme</span>
{project.theme && (
<span className="text-[10px] text-muted-foreground capitalize">
{project.theme}
</span>
)}
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
{/* Theme Submenu */}
{showThemeSubmenu && (
<div
ref={themeSubmenuRef}
className={cn(
'absolute left-full top-0 ml-1 min-w-[420px] rounded-lg',
'bg-popover text-popover-foreground',
'border border-border shadow-lg',
'animate-in fade-in zoom-in-95 duration-100'
)}
style={{ zIndex: Z_INDEX.THEME_SUBMENU }}
data-testid="project-theme-submenu"
>
<div className="p-2">
{/* Use Global Option */}
<button
onPointerEnter={() => handlePreviewEnter(globalTheme)}
onPointerLeave={handlePreviewLeave}
onClick={() => handleThemeSelect('')}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',
'hover:bg-accent transition-colors',
'focus:outline-none focus:bg-accent',
!project.theme && 'bg-accent'
)}
data-testid="project-theme-global"
>
<Monitor className="w-4 h-4" />
<span>Use Global</span>
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
({globalTheme})
</span>
</button>
<div className="h-px bg-border my-2" />
{/* Two Column Layout - Using reusable ThemeColumn component */}
<div className="flex gap-2">
<ThemeColumn
title="Dark"
icon={Moon}
themes={PROJECT_DARK_THEMES as ThemeOption[]}
selectedTheme={project.theme as ThemeMode | null}
onPreviewEnter={handlePreviewEnter}
onPreviewLeave={handlePreviewLeave}
onSelect={handleThemeSelect}
/>
<ThemeColumn
title="Light"
icon={Sun}
themes={PROJECT_LIGHT_THEMES as ThemeOption[]}
selectedTheme={project.theme as ThemeMode | null}
onPreviewEnter={handlePreviewEnter}
onPreviewLeave={handlePreviewLeave}
onSelect={handleThemeSelect}
/>
</div>
</div>
</div>
)}
</div>
<button
onClick={handleRemove}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',
'text-destructive hover:bg-destructive/10',
'transition-colors',
'focus:outline-none focus:bg-destructive/10'
)}
data-testid="remove-project-button"
>
<Trash2 className="w-4 h-4" />
<span>Remove Project</span>
</button>
</div>
</div>
<ConfirmDialog
open={showRemoveDialog}
onOpenChange={setShowRemoveDialog}
onConfirm={handleConfirmRemove}
title="Remove Project"
description={`Are you sure you want to remove "${project.name}" from the project list? This won't delete any files on disk.`}
icon={Trash2}
iconClassName="text-destructive"
confirmText="Remove"
confirmVariant="destructive"
/>
</>
);
}

View File

@@ -0,0 +1,116 @@
import { Folder, LucideIcon } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { cn } from '@/lib/utils';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import type { Project } from '@/lib/electron';
interface ProjectSwitcherItemProps {
project: Project;
isActive: boolean;
hotkeyIndex?: number; // 0-9 for hotkeys 1-9, 0
onClick: () => void;
onContextMenu: (event: React.MouseEvent) => void;
}
export function ProjectSwitcherItem({
project,
isActive,
hotkeyIndex,
onClick,
onContextMenu,
}: ProjectSwitcherItemProps) {
// Convert index to hotkey label: 0 -> "1", 1 -> "2", ..., 8 -> "9", 9 -> "0"
const hotkeyLabel =
hotkeyIndex !== undefined && hotkeyIndex >= 0 && hotkeyIndex <= 9
? hotkeyIndex === 9
? '0'
: String(hotkeyIndex + 1)
: undefined;
// Get the icon component from lucide-react
const getIconComponent = (): LucideIcon => {
if (project.icon && project.icon in LucideIcons) {
return (LucideIcons as Record<string, LucideIcon>)[project.icon];
}
return Folder;
};
const IconComponent = getIconComponent();
const hasCustomIcon = !!project.customIconPath;
return (
<button
onClick={onClick}
onContextMenu={onContextMenu}
className={cn(
'group w-full aspect-square rounded-xl flex items-center justify-center relative overflow-hidden',
'transition-all duration-200 ease-out',
isActive
? [
// Active: Premium gradient with glow
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
// Inactive: Subtle hover state
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
'hover:shadow-sm',
],
'hover:scale-105 active:scale-95'
)}
title={project.name}
data-testid={`project-switcher-${project.id}`}
>
{hasCustomIcon ? (
<img
src={getAuthenticatedImageUrl(project.customIconPath!, project.path)}
alt={project.name}
className={cn(
'w-8 h-8 rounded-lg object-cover transition-all duration-200',
isActive ? 'ring-1 ring-brand-500/50' : 'group-hover:scale-110'
)}
/>
) : (
<IconComponent
className={cn(
'w-6 h-6 transition-all duration-200',
isActive
? 'text-brand-500 drop-shadow-sm'
: 'text-muted-foreground group-hover:text-brand-400 group-hover:scale-110'
)}
/>
)}
{/* Tooltip on hover */}
<span
className={cn(
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
'bg-popover text-popover-foreground text-xs font-medium',
'border border-border shadow-lg',
'opacity-0 group-hover:opacity-100',
'transition-all duration-200 whitespace-nowrap z-50',
'translate-x-1 group-hover:translate-x-0 pointer-events-none'
)}
>
{project.name}
</span>
{/* Hotkey badge */}
{hotkeyLabel && (
<span
className={cn(
'absolute bottom-0.5 right-0.5 min-w-[16px] h-4 px-1',
'flex items-center justify-center',
'text-[10px] font-medium rounded',
'bg-muted/80 text-muted-foreground',
'border border-border/50',
'pointer-events-none'
)}
>
{hotkeyLabel}
</span>
)}
</button>
);
}

View File

@@ -0,0 +1 @@
export { ProjectSwitcher } from './project-switcher';

View File

@@ -0,0 +1,486 @@
import { useState, useCallback, useEffect } from 'react';
import { Plus, Bug, FolderOpen } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import { useOSDetection } from '@/hooks/use-os-detection';
import { ProjectSwitcherItem } from './components/project-switcher-item';
import { ProjectContextMenu } from './components/project-context-menu';
import { EditProjectDialog } from './components/edit-project-dialog';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks';
import type { Project } from '@/lib/electron';
import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
import { toast } from 'sonner';
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
function getOSAbbreviation(os: string): string {
switch (os) {
case 'mac':
return 'M';
case 'windows':
return 'W';
case 'linux':
return 'L';
default:
return '?';
}
}
export function ProjectSwitcher() {
const navigate = useNavigate();
const {
projects,
currentProject,
setCurrentProject,
trashedProjects,
upsertAndSetCurrentProject,
specCreatingForProject,
setSpecCreatingForProject,
} = useAppStore();
const [contextMenuProject, setContextMenuProject] = useState<Project | null>(null);
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(
null
);
const [editDialogProject, setEditDialogProject] = useState<Project | null>(null);
// Setup dialog state for opening existing projects
const [showSetupDialog, setShowSetupDialog] = useState(false);
const [setupProjectPath, setSetupProjectPath] = useState<string | null>(null);
const [projectOverview, setProjectOverview] = useState('');
const [generateFeatures, setGenerateFeatures] = useState(true);
const [analyzeProject, setAnalyzeProject] = useState(true);
const [featureCount, setFeatureCount] = useState(5);
// Derive isCreatingSpec from store state
const isCreatingSpec = specCreatingForProject !== null;
// Version info
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
const { os } = useOSDetection();
const appMode = import.meta.env.VITE_APP_MODE || '?';
const versionSuffix = `${getOSAbbreviation(os)}${appMode}`;
// Get global theme for project creation
const { globalTheme } = useProjectTheme();
// Project creation state and handlers
const {
showNewProjectModal,
setShowNewProjectModal,
isCreatingProject,
showOnboardingDialog,
setShowOnboardingDialog,
newProjectName,
handleCreateBlankProject,
handleCreateFromTemplate,
handleCreateFromCustomUrl,
} = useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
});
const handleContextMenu = (project: Project, event: React.MouseEvent) => {
event.preventDefault();
setContextMenuProject(project);
setContextMenuPosition({ x: event.clientX, y: event.clientY });
};
const handleCloseContextMenu = () => {
setContextMenuProject(null);
setContextMenuPosition(null);
};
const handleEditProject = (project: Project) => {
setEditDialogProject(project);
handleCloseContextMenu();
};
const handleProjectClick = useCallback(
(project: Project) => {
setCurrentProject(project);
// Navigate to board view when switching projects
navigate({ to: '/board' });
},
[setCurrentProject, navigate]
);
const handleNewProject = () => {
// Open the new project modal
setShowNewProjectModal(true);
};
const handleOnboardingSkip = () => {
setShowOnboardingDialog(false);
navigate({ to: '/board' });
};
const handleBugReportClick = useCallback(() => {
const api = getElectronAPI();
api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues');
}, []);
/**
* Opens the system folder selection dialog and initializes the selected project.
*/
const handleOpenFolder = useCallback(async () => {
const api = getElectronAPI();
const result = await api.openDirectory();
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
// Extract folder name from path (works on both Windows and Mac/Linux)
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
try {
// Check if this is a brand new project (no .automaker directory)
const hadAutomakerDir = await hasAutomakerDir(path);
// Initialize the .automaker directory structure
const initResult = await initializeProject(path);
if (!initResult.success) {
toast.error('Failed to initialize project', {
description: initResult.error || 'Unknown error occurred',
});
return;
}
// Upsert project and set as current (handles both create and update cases)
// Theme preservation is handled by the store action
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(path, name, effectiveTheme);
// Check if app_spec.txt exists
const specExists = await hasAppSpec(path);
if (!hadAutomakerDir && !specExists) {
// This is a brand new project - show setup dialog
setSetupProjectPath(path);
setShowSetupDialog(true);
toast.success('Project opened', {
description: `Opened ${name}. Let's set up your app specification!`,
});
} else if (initResult.createdFiles && initResult.createdFiles.length > 0) {
toast.success(initResult.isNewProject ? 'Project initialized' : 'Project updated', {
description: `Set up ${initResult.createdFiles.length} file(s) in .automaker`,
});
} else {
toast.success('Project opened', {
description: `Opened ${name}`,
});
}
// Navigate to board view
navigate({ to: '/board' });
} catch (error) {
console.error('Failed to open project:', error);
toast.error('Failed to open project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
}
}, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme, navigate]);
// Handler for creating initial spec from the setup dialog
const handleCreateInitialSpec = useCallback(async () => {
if (!setupProjectPath) return;
setSpecCreatingForProject(setupProjectPath);
setShowSetupDialog(false);
try {
const api = getElectronAPI();
await api.generateAppSpec({
projectPath: setupProjectPath,
projectOverview,
generateFeatures,
analyzeProject,
featureCount,
});
} catch (error) {
console.error('Failed to generate spec:', error);
toast.error('Failed to generate spec', {
description: error instanceof Error ? error.message : 'Unknown error',
});
setSpecCreatingForProject(null);
}
}, [
setupProjectPath,
projectOverview,
generateFeatures,
analyzeProject,
featureCount,
setSpecCreatingForProject,
]);
const handleSkipSetup = useCallback(() => {
setShowSetupDialog(false);
setSetupProjectPath(null);
}, []);
// Keyboard shortcuts for project switching (1-9, 0)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Ignore if user is typing in an input, textarea, or contenteditable
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
// Ignore if modifier keys are pressed (except for standalone number keys)
if (event.ctrlKey || event.metaKey || event.altKey) {
return;
}
// Map key to project index: "1" -> 0, "2" -> 1, ..., "9" -> 8, "0" -> 9
const key = event.key;
let projectIndex: number | null = null;
if (key >= '1' && key <= '9') {
projectIndex = parseInt(key, 10) - 1; // "1" -> 0, "9" -> 8
} else if (key === '0') {
projectIndex = 9; // "0" -> 9
}
if (projectIndex !== null && projectIndex < projects.length) {
const targetProject = projects[projectIndex];
if (targetProject && targetProject.id !== currentProject?.id) {
handleProjectClick(targetProject);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [projects, currentProject, handleProjectClick]);
return (
<>
<aside
className={cn(
'flex-shrink-0 flex flex-col w-16 z-50 relative',
// Glass morphism background with gradient
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
// Premium border with subtle glow
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]'
)}
data-testid="project-switcher"
>
{/* Automaker Logo and Version */}
<div className="flex flex-col items-center pt-3 pb-2 px-2">
<button
onClick={() => navigate({ to: '/dashboard' })}
className="group flex flex-col items-center gap-0.5"
title="Go to Dashboard"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="size-10 group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient
id="bg-switcher"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
</linearGradient>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-switcher)" />
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium">
v{appVersion} {versionSuffix}
</span>
</button>
<div className="w-full h-px bg-border mt-3" />
</div>
{/* Projects List */}
<div className="flex-1 overflow-y-auto pt-1 pb-3 px-2 space-y-2">
{projects.map((project, index) => (
<ProjectSwitcherItem
key={project.id}
project={project}
isActive={currentProject?.id === project.id}
hotkeyIndex={index < 10 ? index : undefined}
onClick={() => handleProjectClick(project)}
onContextMenu={(e) => handleContextMenu(project, e)}
/>
))}
{/* Horizontal rule and Add Project Button - only show if there are projects */}
{projects.length > 0 && (
<>
<div className="w-full h-px bg-border my-2" />
<button
onClick={handleNewProject}
className={cn(
'w-full aspect-square rounded-xl flex items-center justify-center',
'transition-all duration-200 ease-out',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'hover:shadow-sm hover:scale-105 active:scale-95'
)}
title="New Project"
data-testid="new-project-button"
>
<Plus className="w-5 h-5" />
</button>
<button
onClick={handleOpenFolder}
className={cn(
'w-full aspect-square rounded-xl flex items-center justify-center',
'transition-all duration-200 ease-out',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'hover:shadow-sm hover:scale-105 active:scale-95'
)}
title="Open Project"
data-testid="open-project-button"
>
<FolderOpen className="w-5 h-5" />
</button>
</>
)}
{/* Add Project Button - when no projects, show without rule */}
{projects.length === 0 && (
<>
<button
onClick={handleNewProject}
className={cn(
'w-full aspect-square rounded-xl flex items-center justify-center',
'transition-all duration-200 ease-out',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'hover:shadow-sm hover:scale-105 active:scale-95'
)}
title="New Project"
data-testid="new-project-button"
>
<Plus className="w-5 h-5" />
</button>
<button
onClick={handleOpenFolder}
className={cn(
'w-full aspect-square rounded-xl flex items-center justify-center',
'transition-all duration-200 ease-out',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'hover:shadow-sm hover:scale-105 active:scale-95'
)}
title="Open Project"
data-testid="open-project-button"
>
<FolderOpen className="w-5 h-5" />
</button>
</>
)}
</div>
{/* Bug Report Button at the very bottom */}
<div className="p-2 border-t border-border/40">
<button
onClick={handleBugReportClick}
className={cn(
'w-full aspect-square rounded-xl flex items-center justify-center',
'transition-all duration-200 ease-out',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'hover:shadow-sm hover:scale-105 active:scale-95'
)}
title="Report Bug / Feature Request"
data-testid="bug-report-button"
>
<Bug className="w-5 h-5" />
</button>
</div>
</aside>
{/* Context Menu */}
{contextMenuProject && contextMenuPosition && (
<ProjectContextMenu
project={contextMenuProject}
position={contextMenuPosition}
onClose={handleCloseContextMenu}
onEdit={handleEditProject}
/>
)}
{/* Edit Project Dialog */}
{editDialogProject && (
<EditProjectDialog
project={editDialogProject}
open={!!editDialogProject}
onOpenChange={(open) => !open && setEditDialogProject(null)}
/>
)}
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreatingProject}
/>
{/* Onboarding Dialog */}
<OnboardingDialog
open={showOnboardingDialog}
onOpenChange={setShowOnboardingDialog}
newProjectName={newProjectName}
onSkip={handleOnboardingSkip}
onGenerateSpec={handleOnboardingSkip}
/>
{/* Setup Dialog for Open Project */}
<CreateSpecDialog
open={showSetupDialog}
onOpenChange={setShowSetupDialog}
projectOverview={projectOverview}
onProjectOverviewChange={setProjectOverview}
generateFeatures={generateFeatures}
onGenerateFeaturesChange={setGenerateFeatures}
analyzeProject={analyzeProject}
onAnalyzeProjectChange={setAnalyzeProject}
featureCount={featureCount}
onFeatureCountChange={setFeatureCount}
onCreateSpec={handleCreateInitialSpec}
onSkip={handleSkipSetup}
isCreatingSpec={isCreatingSpec}
showSkipButton={true}
title="Set Up Your Project"
description="We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification."
/>
</>
);
}

View File

@@ -18,7 +18,6 @@ import {
CollapseToggleButton,
SidebarHeader,
SidebarNavigation,
ProjectSelectorWithOptions,
SidebarFooter,
} from './sidebar/components';
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
@@ -64,9 +63,6 @@ export function Sidebar() {
// Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
// State for project picker (needed for keyboard shortcuts)
const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false);
// State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
@@ -240,7 +236,6 @@ export function Sidebar() {
navigate,
toggleSidebar,
handleOpenFolder,
setIsProjectPickerOpen,
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
@@ -258,26 +253,25 @@ export function Sidebar() {
return (
<>
{/* Mobile overlay backdrop */}
{/* Mobile backdrop overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={toggleSidebar}
aria-hidden="true"
data-testid="sidebar-backdrop"
/>
)}
<aside
className={cn(
'flex-shrink-0 flex flex-col z-50 relative',
'flex-shrink-0 flex flex-col z-30',
// Glass morphism background with gradient
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
// Premium border with subtle glow
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
// Smooth width transition
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
// Mobile: hidden when closed, full width overlay when open
// Desktop: always visible, toggle between narrow and wide
sidebarOpen ? 'fixed lg:relative left-0 top-0 h-full w-72' : 'hidden lg:flex w-16'
// Mobile: overlay when open, collapsed when closed
sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16'
)}
data-testid="sidebar"
>
@@ -288,14 +282,7 @@ export function Sidebar() {
/>
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
<ProjectSelectorWithOptions
sidebarOpen={sidebarOpen}
isProjectPickerOpen={isProjectPickerOpen}
setIsProjectPickerOpen={setIsProjectPickerOpen}
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
/>
<SidebarHeader sidebarOpen={sidebarOpen} currentProject={currentProject} />
<SidebarNavigation
currentProject={currentProject}

View File

@@ -17,9 +17,7 @@ export function CollapseToggleButton({
<button
onClick={toggleSidebar}
className={cn(
// Show on desktop always, show on mobile only when sidebar is open
sidebarOpen ? 'flex' : 'hidden lg:flex',
'absolute top-[68px] -right-3 z-9999',
'flex absolute top-[68px] -right-3 z-9999',
'group/toggle items-center justify-center w-7 h-7 rounded-full',
// Glass morphism button
'bg-card/95 backdrop-blur-sm border border-border/80',

View File

@@ -40,7 +40,7 @@ export function ProjectActions({
data-testid="new-project-button"
>
<Plus className="w-4 h-4 shrink-0 transition-transform duration-200 group-hover:rotate-90 group-hover:text-brand-500" />
<span className="ml-2 text-sm font-medium hidden lg:block whitespace-nowrap">New</span>
<span className="ml-2 text-sm font-medium block whitespace-nowrap">New</span>
</button>
<button
onClick={handleOpenFolder}
@@ -59,7 +59,7 @@ export function ProjectActions({
data-testid="open-project-button"
>
<FolderOpen className="w-4 h-4 shrink-0 transition-transform duration-200 group-hover:scale-110" />
<span className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted/80 text-muted-foreground ml-2">
<span className="flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted/80 text-muted-foreground ml-2">
{formatShortcut(shortcuts.openProject, true)}
</span>
</button>

View File

@@ -235,7 +235,7 @@ export function SidebarFooter({
{sidebarOpen && (
<span
className={cn(
'hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
isActiveRoute('settings')
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'

View File

@@ -1,43 +1,67 @@
import type { NavigateOptions } from '@tanstack/react-router';
import { Folder, LucideIcon } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { cn, isMac } from '@/lib/utils';
import { AutomakerLogo } from './automaker-logo';
import { BugReportButton } from './bug-report-button';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { isElectron, type Project } from '@/lib/electron';
interface SidebarHeaderProps {
sidebarOpen: boolean;
navigate: (opts: NavigateOptions) => void;
currentProject: Project | null;
}
export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) {
return (
<>
{/* Logo */}
<div
className={cn(
'h-20 shrink-0 titlebar-drag-region',
// Subtle bottom border with gradient fade
'border-b border-border/40',
// Background gradient for depth
'bg-gradient-to-b from-transparent to-background/5',
'flex items-center',
sidebarOpen ? 'px-4 lg:px-5 justify-start' : 'px-3 justify-center',
// Add padding on macOS to avoid overlapping with traffic light buttons
isMac && sidebarOpen && 'pt-4',
// Smaller top padding on macOS when collapsed
isMac && !sidebarOpen && 'pt-4'
)}
>
<AutomakerLogo sidebarOpen={sidebarOpen} navigate={navigate} />
{/* Bug Report Button - Inside logo container when expanded */}
{sidebarOpen && <BugReportButton sidebarExpanded />}
</div>
export function SidebarHeader({ sidebarOpen, currentProject }: SidebarHeaderProps) {
// Get the icon component from lucide-react
const getIconComponent = (): LucideIcon => {
if (currentProject?.icon && currentProject.icon in LucideIcons) {
return (LucideIcons as Record<string, LucideIcon>)[currentProject.icon];
}
return Folder;
};
{/* Bug Report Button - Collapsed sidebar version */}
{!sidebarOpen && (
<div className="px-3 mt-1.5 flex justify-center">
<BugReportButton sidebarExpanded={false} />
const IconComponent = getIconComponent();
const hasCustomIcon = !!currentProject?.customIconPath;
return (
<div
className={cn(
'shrink-0 flex flex-col',
// Add padding on macOS Electron for traffic light buttons
isMac && isElectron() && 'pt-[10px]'
)}
>
{/* Project name and icon display */}
{currentProject && (
<div
className={cn(
'flex items-center gap-3 px-4 pt-3 pb-1',
!sidebarOpen && 'justify-center px-2'
)}
>
{/* Project Icon */}
<div className="shrink-0">
{hasCustomIcon ? (
<img
src={getAuthenticatedImageUrl(currentProject.customIconPath!, currentProject.path)}
alt={currentProject.name}
className="w-8 h-8 rounded-lg object-cover ring-1 ring-border/50"
/>
) : (
<div className="w-8 h-8 rounded-lg bg-brand-500/10 border border-brand-500/20 flex items-center justify-center">
<IconComponent className="w-5 h-5 text-brand-500" />
</div>
)}
</div>
{/* Project Name - only show when sidebar is open */}
{sidebarOpen && (
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-foreground truncate">
{currentProject.name}
</h2>
</div>
)}
</div>
)}
</>
</div>
);
}

View File

@@ -21,12 +21,12 @@ export function SidebarNavigation({
navigate,
}: SidebarNavigationProps) {
return (
<nav className={cn('flex-1 overflow-y-auto px-3 pb-2', sidebarOpen ? 'mt-5' : 'mt-1')}>
<nav className={cn('flex-1 overflow-y-auto px-3 pb-2', sidebarOpen ? 'mt-1' : 'mt-1')}>
{!currentProject && sidebarOpen ? (
// Placeholder when no project is selected (only in expanded state)
<div className="flex items-center justify-center h-full px-4">
<p className="text-muted-foreground text-sm text-center">
<span className="hidden lg:block">Select or create a project above</span>
<span className="block">Select or create a project above</span>
</p>
</div>
) : currentProject ? (
@@ -137,7 +137,7 @@ export function SidebarNavigation({
{item.shortcut && sidebarOpen && !item.count && (
<span
className={cn(
'hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
isActive
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'

View File

@@ -45,7 +45,6 @@ interface UseNavigationProps {
navigate: (opts: NavigateOptions) => void;
toggleSidebar: () => void;
handleOpenFolder: () => void;
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
cyclePrevProject: () => void;
cycleNextProject: () => void;
/** Count of unviewed validations to show on GitHub Issues nav item */
@@ -65,7 +64,6 @@ export function useNavigation({
navigate,
toggleSidebar,
handleOpenFolder,
setIsProjectPickerOpen,
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
@@ -230,15 +228,6 @@ export function useNavigation({
description: 'Open folder selection dialog',
});
// Project picker shortcut - only when we have projects
if (projects.length > 0) {
shortcutsList.push({
key: shortcuts.projectPicker,
action: () => setIsProjectPickerOpen((prev) => !prev),
description: 'Toggle project picker',
});
}
// Project cycling shortcuts - only when we have project history
if (projectHistory.length > 1) {
shortcutsList.push({
@@ -288,7 +277,6 @@ export function useNavigation({
cyclePrevProject,
cycleNextProject,
navSections,
setIsProjectPickerOpen,
]);
return {

View File

@@ -1,6 +1,6 @@
import { useState, useCallback, useMemo } from 'react';
import { useAppStore } from '@/store/app-store';
import type { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
import type { ModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
export interface UseModelOverrideOptions {
@@ -14,7 +14,7 @@ export interface UseModelOverrideResult {
/** The effective model entry (override or global default) */
effectiveModelEntry: PhaseModelEntry;
/** The effective model string (for backward compatibility with APIs that only accept strings) */
effectiveModel: ModelAlias | CursorModelId;
effectiveModel: ModelId;
/** Whether the model is currently overridden */
isOverridden: boolean;
/** Set a model override */
@@ -32,7 +32,7 @@ export interface UseModelOverrideResult {
*/
function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
if (typeof entry === 'string') {
return { model: entry as ModelAlias | CursorModelId };
return { model: entry as ModelId };
}
return entry;
}
@@ -40,9 +40,9 @@ function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
/**
* Extract model string from PhaseModelEntry or string
*/
function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModelId {
function extractModel(entry: PhaseModelEntry | string): ModelId {
if (typeof entry === 'string') {
return entry as ModelAlias | CursorModelId;
return entry as ModelId;
}
return entry.model;
}

View File

@@ -137,6 +137,8 @@ export function Autocomplete({
width: Math.max(triggerWidth, 200),
}}
data-testid={testId ? `${testId}-list` : undefined}
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
>
<Command shouldFilter={false}>
<CommandInput

View File

@@ -78,7 +78,14 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Comman
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}
className={cn(
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
// Mobile touch scrolling support
'touch-pan-y overscroll-contain',
// iOS Safari momentum scrolling
'[&]:[-webkit-overflow-scrolling:touch]',
className
)}
{...props}
/>
);

View File

@@ -0,0 +1,73 @@
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
const ScrollAreaRootPrimitive = ScrollAreaPrimitive.Root as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const ScrollAreaViewportPrimitive = ScrollAreaPrimitive.Viewport as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Viewport> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const ScrollAreaScrollbarPrimitive =
ScrollAreaPrimitive.Scrollbar as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Scrollbar> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const ScrollAreaThumbPrimitive = ScrollAreaPrimitive.Thumb as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Thumb> & {
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const ScrollArea = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaRootPrimitive
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaViewportPrimitive className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaViewportPrimitive>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaRootPrimitive>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Scrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaScrollbarPrimitive
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaThumbPrimitive className="relative flex-1 rounded-full bg-border" />
</ScrollAreaScrollbarPrimitive>
));
ScrollBar.displayName = ScrollAreaPrimitive.Scrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@@ -230,7 +230,7 @@ export function TaskProgressPanel({
)}
>
<div className="overflow-hidden">
<div className="p-4 pt-2 relative max-h-[300px] overflow-y-auto scrollbar-visible">
<div className="p-4 pt-2 relative max-h-[200px] overflow-y-auto scrollbar-visible">
{/* Vertical Connector Line */}
<div className="absolute left-[2.35rem] top-4 bottom-8 w-px bg-linear-to-b from-border/80 via-border/40 to-transparent" />

View File

@@ -0,0 +1,300 @@
import { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
import { useAppStore } from '@/store/app-store';
import { getTerminalTheme, DEFAULT_TERMINAL_FONT } from '@/config/terminal-themes';
// Types for dynamically imported xterm modules
type XTerminal = InstanceType<typeof import('@xterm/xterm').Terminal>;
type XFitAddon = InstanceType<typeof import('@xterm/addon-fit').FitAddon>;
export interface XtermLogViewerRef {
/** Append content to the log viewer */
append: (content: string) => void;
/** Clear all content */
clear: () => void;
/** Scroll to the bottom */
scrollToBottom: () => void;
/** Write content (replaces all content) */
write: (content: string) => void;
}
export interface XtermLogViewerProps {
/** Initial content to display */
initialContent?: string;
/** Font size in pixels (default: 13) */
fontSize?: number;
/** Whether to auto-scroll to bottom when new content is added (default: true) */
autoScroll?: boolean;
/** Custom class name for the container */
className?: string;
/** Minimum height for the container */
minHeight?: number;
/** Callback when user scrolls away from bottom */
onScrollAwayFromBottom?: () => void;
/** Callback when user scrolls to bottom */
onScrollToBottom?: () => void;
}
/**
* A read-only terminal log viewer using xterm.js for perfect ANSI color rendering.
* Use this component when you need to display terminal output with ANSI escape codes.
*/
export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>(
(
{
initialContent,
fontSize = 13,
autoScroll = true,
className,
minHeight = 300,
onScrollAwayFromBottom,
onScrollToBottom,
},
ref
) => {
const containerRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerminal | null>(null);
const fitAddonRef = useRef<XFitAddon | null>(null);
const [isReady, setIsReady] = useState(false);
const autoScrollRef = useRef(autoScroll);
const pendingContentRef = useRef<string[]>([]);
// Get theme from store
const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme);
const effectiveTheme = getEffectiveTheme();
// Track system dark mode for "system" theme
const [systemIsDark, setSystemIsDark] = useState(() => {
if (typeof window !== 'undefined') {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return true;
});
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => setSystemIsDark(e.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
const resolvedTheme =
effectiveTheme === 'system' ? (systemIsDark ? 'dark' : 'light') : effectiveTheme;
// Update autoScroll ref when prop changes
useEffect(() => {
autoScrollRef.current = autoScroll;
}, [autoScroll]);
// Initialize xterm
useEffect(() => {
if (!containerRef.current) return;
let mounted = true;
const initTerminal = async () => {
const [{ Terminal }, { FitAddon }] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
]);
await import('@xterm/xterm/css/xterm.css');
if (!mounted || !containerRef.current) return;
const terminalTheme = getTerminalTheme(resolvedTheme);
const terminal = new Terminal({
cursorBlink: false,
cursorStyle: 'underline',
cursorInactiveStyle: 'none',
fontSize,
fontFamily: DEFAULT_TERMINAL_FONT,
lineHeight: 1.2,
theme: terminalTheme,
disableStdin: true, // Read-only mode
scrollback: 10000,
convertEol: true,
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(containerRef.current);
// Try to load WebGL addon for better performance
try {
const { WebglAddon } = await import('@xterm/addon-webgl');
const webglAddon = new WebglAddon();
webglAddon.onContextLoss(() => webglAddon.dispose());
terminal.loadAddon(webglAddon);
} catch {
// WebGL not available, continue with canvas renderer
}
// Wait for layout to stabilize then fit
requestAnimationFrame(() => {
if (mounted && containerRef.current) {
try {
fitAddon.fit();
} catch {
// Ignore fit errors during initialization
}
}
});
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
setIsReady(true);
// Write initial content if provided
if (initialContent) {
terminal.write(initialContent);
}
// Write any pending content that was queued before terminal was ready
if (pendingContentRef.current.length > 0) {
pendingContentRef.current.forEach((content) => terminal.write(content));
pendingContentRef.current = [];
}
};
initTerminal();
return () => {
mounted = false;
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
fitAddonRef.current = null;
setIsReady(false);
};
}, []); // Only run once on mount
// Update theme when it changes
useEffect(() => {
if (xtermRef.current && isReady) {
const terminalTheme = getTerminalTheme(resolvedTheme);
xtermRef.current.options.theme = terminalTheme;
}
}, [resolvedTheme, isReady]);
// Update font size when it changes
useEffect(() => {
if (xtermRef.current && isReady) {
xtermRef.current.options.fontSize = fontSize;
fitAddonRef.current?.fit();
}
}, [fontSize, isReady]);
// Handle resize
useEffect(() => {
if (!containerRef.current || !isReady) return;
const handleResize = () => {
if (fitAddonRef.current && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
try {
fitAddonRef.current.fit();
} catch {
// Ignore fit errors
}
}
}
};
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(containerRef.current);
window.addEventListener('resize', handleResize);
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', handleResize);
};
}, [isReady]);
// Monitor scroll position
useEffect(() => {
if (!isReady || !containerRef.current) return;
const viewport = containerRef.current.querySelector('.xterm-viewport') as HTMLElement | null;
if (!viewport) return;
const checkScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = viewport;
const isAtBottom = scrollHeight - scrollTop - clientHeight <= 10;
if (isAtBottom) {
autoScrollRef.current = true;
onScrollToBottom?.();
} else {
autoScrollRef.current = false;
onScrollAwayFromBottom?.();
}
};
viewport.addEventListener('scroll', checkScroll, { passive: true });
return () => viewport.removeEventListener('scroll', checkScroll);
}, [isReady, onScrollAwayFromBottom, onScrollToBottom]);
// Expose methods via ref
const append = useCallback((content: string) => {
if (xtermRef.current) {
xtermRef.current.write(content);
if (autoScrollRef.current) {
xtermRef.current.scrollToBottom();
}
} else {
// Queue content if terminal isn't ready yet
pendingContentRef.current.push(content);
}
}, []);
const clear = useCallback(() => {
if (xtermRef.current) {
xtermRef.current.clear();
}
}, []);
const scrollToBottom = useCallback(() => {
if (xtermRef.current) {
xtermRef.current.scrollToBottom();
autoScrollRef.current = true;
}
}, []);
const write = useCallback((content: string) => {
if (xtermRef.current) {
xtermRef.current.reset();
xtermRef.current.write(content);
if (autoScrollRef.current) {
xtermRef.current.scrollToBottom();
}
} else {
pendingContentRef.current = [content];
}
}, []);
useImperativeHandle(ref, () => ({
append,
clear,
scrollToBottom,
write,
}));
const terminalTheme = getTerminalTheme(resolvedTheme);
return (
<div
ref={containerRef}
className={className}
style={{
minHeight,
backgroundColor: terminalTheme.background,
}}
/>
);
}
);
XtermLogViewer.displayName = 'XtermLogViewer';

View File

@@ -14,6 +14,7 @@ const ERROR_CODES = {
API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE',
AUTH_ERROR: 'AUTH_ERROR',
NOT_AVAILABLE: 'NOT_AVAILABLE',
TRUST_PROMPT: 'TRUST_PROMPT',
UNKNOWN: 'UNKNOWN',
} as const;
@@ -72,18 +73,17 @@ export function UsagePopover() {
const [codexError, setCodexError] = useState<UsageError | null>(null);
// Check authentication status
const isClaudeCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
const isCodexAuthenticated = codexAuthStatus?.authenticated;
// Determine which tab to show by default
useEffect(() => {
if (isClaudeCliVerified) {
if (isClaudeAuthenticated) {
setActiveTab('claude');
} else if (isCodexAuthenticated) {
setActiveTab('codex');
}
}, [isClaudeCliVerified, isCodexAuthenticated]);
}, [isClaudeAuthenticated, isCodexAuthenticated]);
// Check if data is stale (older than 2 minutes)
const isClaudeStale = useMemo(() => {
@@ -109,8 +109,12 @@ export function UsagePopover() {
}
const data = await api.claude.getUsage();
if ('error' in data) {
// Detect trust prompt error
const isTrustPrompt =
data.error === 'Trust prompt pending' ||
(data.message && data.message.includes('folder permission'));
setClaudeError({
code: ERROR_CODES.AUTH_ERROR,
code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR,
message: data.message || data.error,
});
return;
@@ -174,10 +178,10 @@ export function UsagePopover() {
// Auto-fetch on mount if data is stale
useEffect(() => {
if (isClaudeStale && isClaudeCliVerified) {
if (isClaudeStale && isClaudeAuthenticated) {
fetchClaudeUsage(true);
}
}, [isClaudeStale, isClaudeCliVerified, fetchClaudeUsage]);
}, [isClaudeStale, isClaudeAuthenticated, fetchClaudeUsage]);
useEffect(() => {
if (isCodexStale && isCodexAuthenticated) {
@@ -190,7 +194,7 @@ export function UsagePopover() {
if (!open) return;
// Fetch based on active tab
if (activeTab === 'claude' && isClaudeCliVerified) {
if (activeTab === 'claude' && isClaudeAuthenticated) {
if (!claudeUsage || isClaudeStale) {
fetchClaudeUsage();
}
@@ -214,7 +218,7 @@ export function UsagePopover() {
activeTab,
claudeUsage,
isClaudeStale,
isClaudeCliVerified,
isClaudeAuthenticated,
codexUsage,
isCodexStale,
isCodexAuthenticated,
@@ -349,7 +353,7 @@ export function UsagePopover() {
);
// Determine which tabs to show
const showClaudeTab = isClaudeCliVerified;
const showClaudeTab = isClaudeAuthenticated;
const showCodexTab = isCodexAuthenticated;
return (
@@ -405,6 +409,11 @@ export function UsagePopover() {
<p className="text-xs text-muted-foreground">
{claudeError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
'Ensure the Electron bridge is running or restart the app'
) : claudeError.code === ERROR_CODES.TRUST_PROMPT ? (
<>
Run <code className="font-mono bg-muted px-1 rounded">claude</code> in
your terminal and approve access to continue
</>
) : (
<>
Make sure Claude CLI is installed and authenticated via{' '}

View File

@@ -16,11 +16,32 @@ import {
import { NoProjectState, AgentHeader, ChatArea } from './agent-view/components';
import { AgentInputArea } from './agent-view/input-area';
/** Tailwind lg breakpoint in pixels */
const LG_BREAKPOINT = 1024;
export function AgentView() {
const { currentProject } = useAppStore();
const [input, setInput] = useState('');
const [currentTool, setCurrentTool] = useState<string | null>(null);
// Initialize session manager state - starts as true to match SSR
// Then updates on mount based on actual screen size to prevent hydration mismatch
const [showSessionManager, setShowSessionManager] = useState(true);
// Update session manager visibility based on screen size after mount and on resize
useEffect(() => {
const updateVisibility = () => {
const isDesktop = window.innerWidth >= LG_BREAKPOINT;
setShowSessionManager(isDesktop);
};
// Set initial value
updateVisibility();
// Listen for resize events
window.addEventListener('resize', updateVisibility);
return () => window.removeEventListener('resize', updateVisibility);
}, []);
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' });
// Input ref for auto-focus
@@ -119,6 +140,13 @@ export function AgentView() {
}
}, [currentSessionId]);
// Auto-close session manager on mobile when a session is selected
useEffect(() => {
if (currentSessionId && typeof window !== 'undefined' && window.innerWidth < 1024) {
setShowSessionManager(false);
}
}, [currentSessionId]);
// Show welcome message if no messages yet
const displayMessages =
messages.length === 0
@@ -139,9 +167,18 @@ export function AgentView() {
return (
<div className="flex-1 flex overflow-hidden bg-background" data-testid="agent-view">
{/* Mobile backdrop overlay for Session Manager */}
{showSessionManager && currentProject && (
<div
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={() => setShowSessionManager(false)}
data-testid="session-manager-backdrop"
/>
)}
{/* Session Manager Sidebar */}
{showSessionManager && currentProject && (
<div className="w-80 border-r border-border shrink-0 bg-card/50">
<div className="fixed inset-y-0 left-0 w-72 z-30 lg:relative lg:w-80 lg:z-auto border-r border-border shrink-0 bg-card">
<SessionManager
currentSessionId={currentSessionId}
onSelectSession={handleSelectSession}

View File

@@ -79,7 +79,7 @@ export function InputControls({
{/* Text Input and Controls */}
<div
className={cn(
'flex gap-2 transition-all duration-200 rounded-xl p-1',
'flex flex-col gap-2 transition-all duration-200 rounded-xl p-1',
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
)}
onDragEnter={onDragEnter}
@@ -87,7 +87,8 @@ export function InputControls({
onDragOver={onDragOver}
onDrop={onDrop}
>
<div className="flex-1 relative">
{/* Textarea - full width on mobile */}
<div className="relative w-full">
<Textarea
ref={inputRef}
placeholder={
@@ -105,14 +106,14 @@ export function InputControls({
data-testid="agent-input"
rows={1}
className={cn(
'min-h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all resize-none max-h-36 overflow-y-auto py-2.5',
'min-h-11 w-full bg-background border-border rounded-xl pl-4 pr-4 sm:pr-20 text-sm transition-all resize-none max-h-36 overflow-y-auto py-2.5',
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
hasFiles && 'border-primary/30',
isDragOver && 'border-primary bg-primary/5'
)}
/>
{hasFiles && !isDragOver && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
<div className="hidden sm:block absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
files attached
</div>
)}
@@ -124,58 +125,64 @@ export function InputControls({
)}
</div>
{/* Model Selector */}
<AgentModelSelector
value={modelSelection}
onChange={onModelSelect}
disabled={!isConnected}
/>
{/* File Attachment Button */}
<Button
variant="outline"
size="icon"
onClick={onToggleImageDropZone}
disabled={!isConnected}
className={cn(
'h-11 w-11 rounded-xl border-border',
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
hasFiles && 'border-primary/30 text-primary'
)}
title="Attach files (images, .txt, .md)"
>
<Paperclip className="w-4 h-4" />
</Button>
{/* Stop Button (only when processing) */}
{isProcessing && (
<Button
onClick={onStop}
{/* Controls row - responsive layout */}
<div className="flex items-center gap-2 flex-wrap">
{/* Model Selector */}
<AgentModelSelector
value={modelSelection}
onChange={onModelSelect}
disabled={!isConnected}
className="h-11 px-4 rounded-xl"
variant="destructive"
data-testid="stop-agent"
title="Stop generation"
>
<Square className="w-4 h-4 fill-current" />
</Button>
)}
/>
{/* Send / Queue Button */}
<Button
onClick={onSend}
disabled={!canSend}
className="h-11 px-4 rounded-xl"
variant={isProcessing ? 'outline' : 'default'}
data-testid="send-message"
title={isProcessing ? 'Add to queue' : 'Send message'}
>
{isProcessing ? <ListOrdered className="w-4 h-4" /> : <Send className="w-4 h-4" />}
</Button>
{/* File Attachment Button */}
<Button
variant="outline"
size="icon"
onClick={onToggleImageDropZone}
disabled={!isConnected}
className={cn(
'h-11 w-11 rounded-xl border-border shrink-0',
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
hasFiles && 'border-primary/30 text-primary'
)}
title="Attach files (images, .txt, .md)"
>
<Paperclip className="w-4 h-4" />
</Button>
{/* Spacer to push action buttons to the right */}
<div className="flex-1" />
{/* Stop Button (only when processing) */}
{isProcessing && (
<Button
onClick={onStop}
disabled={!isConnected}
className="h-11 px-4 rounded-xl shrink-0"
variant="destructive"
data-testid="stop-agent"
title="Stop generation"
>
<Square className="w-4 h-4 fill-current" />
</Button>
)}
{/* Send / Queue Button */}
<Button
onClick={onSend}
disabled={!canSend}
className="h-11 px-4 rounded-xl shrink-0"
variant={isProcessing ? 'outline' : 'default'}
data-testid="send-message"
title={isProcessing ? 'Add to queue' : 'Send message'}
>
{isProcessing ? <ListOrdered className="w-4 h-4" /> : <Send className="w-4 h-4" />}
</Button>
</div>
</div>
{/* Keyboard hint */}
<p className="text-[11px] text-muted-foreground mt-2 text-center">
<p className="text-[11px] text-muted-foreground mt-2 text-center hidden sm:block">
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
send,{' '}
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Shift+Enter</kbd>{' '}

View File

@@ -58,9 +58,10 @@ import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialo
import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog';
import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
import { MergeWorktreeDialog } from './board-view/dialogs/merge-worktree-dialog';
import { WorktreePanel } from './board-view/worktree-panel';
import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types';
import { COLUMNS } from './board-view/constants';
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
import {
useBoardFeatures,
useBoardDragDrop,
@@ -72,8 +73,9 @@ import {
useBoardPersistence,
useFollowUpState,
useSelectionMode,
useListViewState,
} from './board-view/hooks';
import { SelectionActionBar } from './board-view/components';
import { SelectionActionBar, ListView } from './board-view/components';
import { MassEditDialog } from './board-view/dialogs';
import { InitScriptIndicator } from './board-view/init-script-indicator';
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
@@ -147,6 +149,7 @@ export function BoardView() {
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
const [showMergeWorktreeDialog, setShowMergeWorktreeDialog] = useState(false);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
path: string;
branch: string;
@@ -194,6 +197,9 @@ export function BoardView() {
} = useSelectionMode();
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
// View mode state (kanban vs list)
const { viewMode, setViewMode, isListView, sortConfig, setSortColumn } = useListViewState();
// Search filter for Kanban cards
const [searchQuery, setSearchQuery] = useState('');
// Plan approval loading state
@@ -324,20 +330,6 @@ export function BoardView() {
fetchBranches();
}, [currentProject, worktreeRefreshKey]);
// Calculate unarchived card counts per branch
const branchCardCounts = useMemo(() => {
return hookFeatures.reduce(
(counts, feature) => {
if (feature.status !== 'completed') {
const branch = feature.branchName ?? 'main';
counts[branch] = (counts[branch] || 0) + 1;
}
return counts;
},
{} as Record<string, number>
);
}, [hookFeatures]);
// Custom collision detection that prioritizes columns over cards
const collisionDetectionStrategy = useCallback((args: any) => {
// First, check if pointer is within a column
@@ -422,6 +414,47 @@ export function BoardView() {
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
// Calculate unarchived card counts per branch
const branchCardCounts = useMemo(() => {
// Use primary worktree branch as default for features without branchName
const primaryBranch = worktrees.find((w) => w.isMain)?.branch || 'main';
return hookFeatures.reduce(
(counts, feature) => {
if (feature.status !== 'completed') {
const branch = feature.branchName ?? primaryBranch;
counts[branch] = (counts[branch] || 0) + 1;
}
return counts;
},
{} as Record<string, number>
);
}, [hookFeatures, worktrees]);
// Helper function to add and select a worktree
const addAndSelectWorktree = useCallback(
(worktreeResult: { path: string; branch: string }) => {
if (!currentProject) return;
const currentWorktrees = getWorktrees(currentProject.path);
const existingWorktree = currentWorktrees.find((w) => w.branch === worktreeResult.branch);
// Only add if it doesn't already exist (to avoid duplicates)
if (!existingWorktree) {
const newWorktreeInfo = {
path: worktreeResult.path,
branch: worktreeResult.branch,
isMain: false,
isCurrent: false,
hasWorktree: true,
};
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
}
// Select the worktree (whether it existed or was just added)
setCurrentWorktree(currentProject.path, worktreeResult.path, worktreeResult.branch);
},
[currentProject, getWorktrees, setWorktrees, setCurrentWorktree]
);
// Extract all action handlers into a hook
const {
handleAddFeature,
@@ -467,43 +500,90 @@ export function BoardView() {
outputFeature,
projectPath: currentProject?.path || null,
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
onWorktreeAutoSelect: (newWorktree) => {
if (!currentProject) return;
// Check if worktree already exists in the store (by branch name)
const currentWorktrees = getWorktrees(currentProject.path);
const existingWorktree = currentWorktrees.find((w) => w.branch === newWorktree.branch);
// Only add if it doesn't already exist (to avoid duplicates)
if (!existingWorktree) {
const newWorktreeInfo = {
path: newWorktree.path,
branch: newWorktree.branch,
isMain: false,
isCurrent: false,
hasWorktree: true,
};
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
}
// Select the worktree (whether it existed or was just added)
setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch);
},
onWorktreeAutoSelect: addAndSelectWorktree,
currentWorktreeBranch,
});
// Handler for bulk updating multiple features
const handleBulkUpdate = useCallback(
async (updates: Partial<Feature>) => {
async (updates: Partial<Feature>, workMode: 'current' | 'auto' | 'custom') => {
if (!currentProject || selectedFeatureIds.size === 0) return;
try {
// Determine final branch name based on work mode:
// - 'current': Empty string to clear branch assignment (work on main/current branch)
// - 'auto': Auto-generate branch name based on current branch
// - 'custom': Use the provided branch name
let finalBranchName: string | undefined;
if (workMode === 'current') {
// Empty string clears the branch assignment, moving features to main/current branch
finalBranchName = '';
} else if (workMode === 'auto') {
// Auto-generate a branch name based on current branch and timestamp
const baseBranch =
currentWorktreeBranch || getPrimaryWorktreeBranch(currentProject.path) || 'main';
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 6);
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
} else {
// Custom mode - use provided branch name
finalBranchName = updates.branchName || undefined;
}
// Create worktree for 'auto' or 'custom' modes when we have a branch name
if ((workMode === 'auto' || workMode === 'custom') && finalBranchName) {
try {
const electronApi = getElectronAPI();
if (electronApi?.worktree?.create) {
const result = await electronApi.worktree.create(
currentProject.path,
finalBranchName
);
if (result.success && result.worktree) {
logger.info(
`Worktree for branch "${finalBranchName}" ${
result.worktree?.isNew ? 'created' : 'already exists'
}`
);
// Auto-select the worktree when creating/using it for bulk update
addAndSelectWorktree(result.worktree);
// Refresh worktree list in UI
setWorktreeRefreshKey((k) => k + 1);
} else if (!result.success) {
logger.error(
`Failed to create worktree for branch "${finalBranchName}":`,
result.error
);
toast.error('Failed to create worktree', {
description: result.error || 'An error occurred',
});
return; // Don't proceed with update if worktree creation failed
}
}
} catch (error) {
logger.error('Error creating worktree:', error);
toast.error('Failed to create worktree', {
description: error instanceof Error ? error.message : 'An error occurred',
});
return; // Don't proceed with update if worktree creation failed
}
}
// Use the final branch name in updates
const finalUpdates = {
...updates,
branchName: finalBranchName,
};
const api = getHttpApiClient();
const featureIds = Array.from(selectedFeatureIds);
const result = await api.features.bulkUpdate(currentProject.path, featureIds, updates);
const result = await api.features.bulkUpdate(currentProject.path, featureIds, finalUpdates);
if (result.success) {
// Update local state
featureIds.forEach((featureId) => {
updateFeature(featureId, updates);
updateFeature(featureId, finalUpdates);
});
toast.success(`Updated ${result.updatedCount} features`);
exitSelectionMode();
@@ -517,7 +597,16 @@ export function BoardView() {
toast.error('Failed to update features');
}
},
[currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]
[
currentProject,
selectedFeatureIds,
updateFeature,
exitSelectionMode,
currentWorktreeBranch,
getPrimaryWorktreeBranch,
addAndSelectWorktree,
setWorktreeRefreshKey,
]
);
// Handler for bulk deleting multiple features
@@ -614,6 +703,7 @@ export function BoardView() {
model: 'opus' as const,
thinkingLevel: 'none' as const,
branchName: worktree.branch,
workMode: 'custom' as const, // Use the worktree's branch
priority: 1, // High priority for PR feedback
planningMode: 'skip' as const,
requirePlanApproval: false,
@@ -639,10 +729,11 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation, defaultSkipTests]
);
// Handler for resolving conflicts - creates a feature to pull from origin/main and resolve conflicts
// Handler for resolving conflicts - creates a feature to pull from the remote branch and resolve conflicts
const handleResolveConflicts = useCallback(
async (worktree: WorktreeInfo) => {
const description = `Pull latest from origin/main and resolve conflicts. Merge origin/main into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
const remoteBranch = `origin/${worktree.branch}`;
const description = `Pull latest from ${remoteBranch} and resolve conflicts. Merge ${remoteBranch} into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
// Create the feature
const featureData = {
@@ -655,6 +746,7 @@ export function BoardView() {
model: 'opus' as const,
thinkingLevel: 'none' as const,
branchName: worktree.branch,
workMode: 'custom' as const, // Use the worktree's branch
priority: 1, // High priority for conflict resolution
planningMode: 'skip' as const,
requirePlanApproval: false,
@@ -1038,6 +1130,19 @@ export function BoardView() {
projectPath: currentProject?.path || null,
});
// Build columnFeaturesMap for ListView
const pipelineConfig = currentProject?.path
? pipelineConfigByProject[currentProject.path] || null
: null;
const columnFeaturesMap = useMemo(() => {
const columns = getColumnsWithPipeline(pipelineConfig);
const map: Record<string, typeof hookFeatures> = {};
for (const column of columns) {
map[column.id] = getColumnFeatures(column.id as any);
}
return map;
}, [pipelineConfig, getColumnFeatures]);
// Use background hook
const { backgroundSettings, backgroundImageStyle } = useBoardBackground({
currentProject,
@@ -1223,8 +1328,8 @@ export function BoardView() {
isCreatingSpec={isCreatingSpec}
creatingSpecProjectPath={creatingSpecProjectPath}
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
onShowCompletedModal={() => setShowCompletedModal(true)}
completedCount={completedFeatures.length}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
{/* Worktree Panel - conditionally rendered based on visibility setting */}
@@ -1251,6 +1356,10 @@ export function BoardView() {
}}
onAddressPRComments={handleAddressPRComments}
onResolveConflicts={handleResolveConflicts}
onMerge={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowMergeWorktreeDialog(true);
}}
onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasks}
branchCardCounts={branchCardCounts}
@@ -1263,48 +1372,91 @@ export function BoardView() {
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* View Content - Kanban Board */}
<KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
onAddFeature={() => setShowAddDialog(true)}
pipelineConfig={
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
isDragging={activeFeature !== null}
onAiSuggest={() => setShowPlanDialog(true)}
/>
{/* View Content - Kanban Board or List View */}
{isListView ? (
<ListView
columnFeaturesMap={columnFeaturesMap}
allFeatures={hookFeatures}
sortConfig={sortConfig}
onSortChange={setSortColumn}
actionHandlers={{
onEdit: (feature) => setEditingFeature(feature),
onDelete: (featureId) => handleDeleteFeature(featureId),
onViewOutput: handleViewOutput,
onVerify: handleVerifyFeature,
onResume: handleResumeFeature,
onForceStop: handleForceStopFeature,
onManualVerify: handleManualVerify,
onFollowUp: handleOpenFollowUp,
onImplement: handleStartImplementation,
onComplete: handleCompleteFeature,
onViewPlan: (feature) => setViewPlanFeature(feature),
onApprovePlan: handleOpenApprovalDialog,
onSpawnTask: (feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
},
}}
runningAutoTasks={runningAutoTasks}
pipelineConfig={pipelineConfig}
onAddFeature={() => setShowAddDialog(true)}
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onRowClick={(feature) => {
if (feature.status === 'backlog') {
setEditingFeature(feature);
} else {
handleViewOutput(feature);
}
}}
className="transition-opacity duration-200"
/>
) : (
<KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
onAddFeature={() => setShowAddDialog(true)}
onShowCompletedModal={() => setShowCompletedModal(true)}
completedCount={completedFeatures.length}
pipelineConfig={pipelineConfig}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
viewMode={viewMode}
isDragging={activeFeature !== null}
onAiSuggest={() => setShowPlanDialog(true)}
className="transition-opacity duration-200"
/>
)}
</div>
{/* Selection Action Bar */}
@@ -1325,6 +1477,9 @@ export function BoardView() {
onClose={() => setShowMassEditDialog(false)}
selectedFeatures={selectedFeatures}
onApply={handleBulkUpdate}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentWorktreeBranch || undefined}
/>
{/* Board Background Modal */}
@@ -1423,7 +1578,7 @@ export function BoardView() {
open={showPipelineSettings}
onClose={() => setShowPipelineSettings(false)}
projectPath={currentProject.path}
pipelineConfig={pipelineConfigByProject[currentProject.path] || null}
pipelineConfig={pipelineConfig}
onSave={async (config) => {
const api = getHttpApiClient();
const result = await api.pipeline.saveConfig(currentProject.path, config);
@@ -1549,6 +1704,35 @@ export function BoardView() {
}}
/>
{/* Merge Worktree Dialog */}
<MergeWorktreeDialog
open={showMergeWorktreeDialog}
onOpenChange={setShowMergeWorktreeDialog}
projectPath={currentProject.path}
worktree={selectedWorktreeForAction}
affectedFeatureCount={
selectedWorktreeForAction
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
: 0
}
onMerged={(mergedWorktree) => {
// Reset features that were assigned to the merged worktree (by branch)
hookFeatures.forEach((feature) => {
if (feature.branchName === mergedWorktree.branch) {
// 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);
}
});
setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null);
}}
/>
{/* Commit Worktree Dialog */}
<CommitWorktreeDialog
open={showCommitWorktreeDialog}
@@ -1566,6 +1750,7 @@ export function BoardView() {
onOpenChange={setShowCreatePRDialog}
worktree={selectedWorktreeForAction}
projectPath={currentProject?.path || null}
defaultBaseBranch={selectedWorktreeBranch}
onCreated={(prUrl) => {
// If a PR was created and we have the worktree branch, update all features on that branch with the PR URL
if (prUrl && selectedWorktreeForAction?.branch) {

View File

@@ -1,65 +1,38 @@
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ImageIcon, Archive } from 'lucide-react';
import { ImageIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
interface BoardControlsProps {
isMounted: boolean;
onShowBoardBackground: () => void;
onShowCompletedModal: () => void;
completedCount: number;
}
export function BoardControls({
isMounted,
onShowBoardBackground,
onShowCompletedModal,
completedCount,
}: BoardControlsProps) {
export function BoardControls({ isMounted, onShowBoardBackground }: BoardControlsProps) {
if (!isMounted) return null;
return (
<TooltipProvider>
<div className="flex items-center gap-2">
<div className="flex items-center gap-5">
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
<button
onClick={onShowBoardBackground}
className="h-8 px-2"
className={cn(
'inline-flex h-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-all duration-200 cursor-pointer',
'text-muted-foreground hover:text-foreground hover:bg-accent',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'border border-border'
)}
data-testid="board-background-button"
>
<ImageIcon className="w-4 h-4" />
</Button>
</button>
</TooltipTrigger>
<TooltipContent>
<p>Board Background Settings</p>
</TooltipContent>
</Tooltip>
{/* Completed/Archived Features Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={onShowCompletedModal}
className="h-8 px-2 relative"
data-testid="completed-features-button"
>
<Archive className="w-4 h-4" />
{completedCount > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
{completedCount > 99 ? '99+' : completedCount}
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Completed Features ({completedCount})</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);

View File

@@ -1,19 +1,21 @@
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { useCallback } from 'react';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Bot, Wand2, Settings2, GitBranch } from 'lucide-react';
import { Wand2, GitBranch } from 'lucide-react';
import { UsagePopover } from '@/components/usage-popover';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog';
import { PlanSettingsDialog } from './dialogs/plan-settings-dialog';
import { useIsMobile } from '@/hooks/use-media-query';
import { AutoModeSettingsPopover } from './dialogs/auto-mode-settings-popover';
import { WorktreeSettingsPopover } from './dialogs/worktree-settings-popover';
import { PlanSettingsPopover } from './dialogs/plan-settings-popover';
import { getHttpApiClient } from '@/lib/http-api-client';
import { BoardSearchBar } from './board-search-bar';
import { BoardControls } from './board-controls';
import { ViewToggle, type ViewMode } from './components';
import { HeaderMobileMenu } from './header-mobile-menu';
export type { ViewMode };
interface BoardHeaderProps {
projectPath: string;
@@ -31,8 +33,9 @@ interface BoardHeaderProps {
creatingSpecProjectPath?: string;
// Board controls props
onShowBoardBackground: () => void;
onShowCompletedModal: () => void;
completedCount: number;
// View toggle props
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
}
// Shared styles for header control containers
@@ -53,13 +56,9 @@ export function BoardHeader({
isCreatingSpec,
creatingSpecProjectPath,
onShowBoardBackground,
onShowCompletedModal,
completedCount,
viewMode,
onViewModeChange,
}: BoardHeaderProps) {
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
const [showWorktreeSettings, setShowWorktreeSettings] = useState(false);
const [showPlanSettings, setShowPlanSettings] = useState(false);
const apiKeys = useAppStore((state) => state.apiKeys);
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
@@ -98,22 +97,17 @@ export function BoardHeader({
[projectPath, setWorktreePanelVisible]
);
// Claude usage tracking visibility logic
// Hide when using API key (only show for Claude Code CLI users)
// Also hide on Windows for now (CLI usage command not supported)
const isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const hasClaudeApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
const isClaudeCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
const showClaudeUsage = !hasClaudeApiKey && !isWindows && isClaudeCliVerified;
const isClaudeCliVerified = !!claudeAuthStatus?.authenticated;
const showClaudeUsage = isClaudeCliVerified;
// Codex usage tracking visibility logic
// Show if Codex is authenticated (CLI or API key)
const showCodexUsage = !!codexAuthStatus?.authenticated;
const isMobile = useIsMobile();
return (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-4">
<BoardSearchBar
searchQuery={searchQuery}
@@ -122,23 +116,40 @@ export function BoardHeader({
creatingSpecProjectPath={creatingSpecProjectPath}
currentProjectPath={projectPath}
/>
<BoardControls
isMounted={isMounted}
onShowBoardBackground={onShowBoardBackground}
onShowCompletedModal={onShowCompletedModal}
completedCount={completedCount}
/>
{isMounted && <ViewToggle viewMode={viewMode} onViewModeChange={onViewModeChange} />}
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
</div>
<div className="flex gap-2 items-center">
{/* Usage Popover - show if either provider is authenticated */}
{isMounted && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
<div className="flex gap-4 items-center">
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
{isMounted && !isMobile && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
{/* Mobile view: show hamburger menu with all controls */}
{isMounted && isMobile && (
<HeaderMobileMenu
isWorktreePanelVisible={isWorktreePanelVisible}
onWorktreePanelToggle={handleWorktreePanelToggle}
maxConcurrency={maxConcurrency}
runningAgentsCount={runningAgentsCount}
onConcurrencyChange={onConcurrencyChange}
isAutoModeRunning={isAutoModeRunning}
onAutoModeToggle={onAutoModeToggle}
onOpenAutoModeSettings={() => {}}
onOpenPlanDialog={onOpenPlanDialog}
showClaudeUsage={showClaudeUsage}
showCodexUsage={showCodexUsage}
/>
)}
{/* Desktop view: show full controls */}
{/* Worktrees Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
{isMounted && !isMobile && (
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<Label htmlFor="worktrees-toggle" className="text-sm font-medium cursor-pointer">
Worktrees
<Label
htmlFor="worktrees-toggle"
className="text-xs font-medium cursor-pointer whitespace-nowrap"
>
Worktree Bar
</Label>
<Switch
id="worktrees-toggle"
@@ -146,72 +157,20 @@ export function BoardHeader({
onCheckedChange={handleWorktreePanelToggle}
data-testid="worktrees-toggle"
/>
<button
onClick={() => setShowWorktreeSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Worktree Settings"
data-testid="worktree-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
<WorktreeSettingsPopover
addFeatureUseSelectedWorktreeBranch={addFeatureUseSelectedWorktreeBranch}
onAddFeatureUseSelectedWorktreeBranchChange={setAddFeatureUseSelectedWorktreeBranch}
/>
</div>
)}
{/* Worktree Settings Dialog */}
<WorktreeSettingsDialog
open={showWorktreeSettings}
onOpenChange={setShowWorktreeSettings}
addFeatureUseSelectedWorktreeBranch={addFeatureUseSelectedWorktreeBranch}
onAddFeatureUseSelectedWorktreeBranchChange={setAddFeatureUseSelectedWorktreeBranch}
/>
{/* Concurrency Control - only show after mount to prevent hydration issues */}
{isMounted && (
<Popover>
<PopoverTrigger asChild>
<button
className={`${controlContainerClass} cursor-pointer hover:bg-accent/50 transition-colors`}
data-testid="concurrency-slider-container"
>
<Bot className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Agents</span>
<span className="text-sm text-muted-foreground" data-testid="concurrency-value">
{runningAgentsCount}/{maxConcurrency}
</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="space-y-4">
<div>
<h4 className="font-medium text-sm mb-1">Max Concurrent Agents</h4>
<p className="text-xs text-muted-foreground">
Controls how many AI agents can run simultaneously. Higher values process more
features in parallel but use more API resources.
</p>
</div>
<div className="flex items-center gap-3">
<Slider
value={[maxConcurrency]}
onValueChange={(value) => onConcurrencyChange(value[0])}
min={1}
max={10}
step={1}
className="flex-1"
data-testid="concurrency-slider"
/>
<span className="text-sm font-medium min-w-[2ch] text-right">
{maxConcurrency}
</span>
</div>
</div>
</PopoverContent>
</Popover>
)}
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
{isMounted && !isMobile && (
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
<Label
htmlFor="auto-mode-toggle"
className="text-xs font-medium cursor-pointer whitespace-nowrap"
>
Auto Mode
</Label>
<Switch
@@ -220,52 +179,33 @@ export function BoardHeader({
onCheckedChange={onAutoModeToggle}
data-testid="auto-mode-toggle"
/>
<button
onClick={() => setShowAutoModeSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Auto Mode Settings"
data-testid="auto-mode-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
<AutoModeSettingsPopover
skipVerificationInAutoMode={skipVerificationInAutoMode}
onSkipVerificationChange={setSkipVerificationInAutoMode}
maxConcurrency={maxConcurrency}
runningAgentsCount={runningAgentsCount}
onConcurrencyChange={onConcurrencyChange}
/>
</div>
)}
{/* Auto Mode Settings Dialog */}
<AutoModeSettingsDialog
open={showAutoModeSettings}
onOpenChange={setShowAutoModeSettings}
skipVerificationInAutoMode={skipVerificationInAutoMode}
onSkipVerificationChange={setSkipVerificationInAutoMode}
/>
{/* Plan Button with Settings */}
<div className={controlContainerClass} data-testid="plan-button-container">
<button
onClick={onOpenPlanDialog}
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Plan</span>
</button>
<button
onClick={() => setShowPlanSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Plan Settings"
data-testid="plan-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* Plan Settings Dialog */}
<PlanSettingsDialog
open={showPlanSettings}
onOpenChange={setShowPlanSettings}
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
onPlanUseSelectedWorktreeBranchChange={setPlanUseSelectedWorktreeBranch}
/>
{/* Plan Button with Settings - only show on desktop, mobile has it in the menu */}
{isMounted && !isMobile && (
<div className={controlContainerClass} data-testid="plan-button-container">
<button
onClick={onOpenPlanDialog}
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Plan</span>
</button>
<PlanSettingsPopover
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
onPlanUseSelectedWorktreeBranchChange={setPlanUseSelectedWorktreeBranch}
/>
</div>
)}
</div>
</div>
);

View File

@@ -2,3 +2,33 @@ export { KanbanCard } from './kanban-card/kanban-card';
export { KanbanColumn } from './kanban-column';
export { SelectionActionBar } from './selection-action-bar';
export { EmptyStateCard } from './empty-state-card';
export { ViewToggle, type ViewMode } from './view-toggle';
// List view components
export {
ListHeader,
LIST_COLUMNS,
getColumnById,
getColumnWidth,
getColumnAlign,
ListRow,
getFeatureSortValue,
sortFeatures,
ListView,
getFlatFeatures,
getTotalFeatureCount,
RowActions,
createRowActionHandlers,
StatusBadge,
getStatusLabel,
getStatusOrder,
} from './list-view';
export type {
ListHeaderProps,
ListRowProps,
ListViewProps,
ListViewActionHandlers,
RowActionsProps,
RowActionHandlers,
StatusBadgeProps,
} from './list-view';

View File

@@ -1,6 +1,6 @@
// @ts-nocheck
import { useEffect, useState } from 'react';
import { Feature, ThinkingLevel } from '@/store/app-store';
import { useEffect, useState, useMemo } from 'react';
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
import type { ReasoningEffort } from '@automaker/types';
import { getProviderFromModel } from '@/lib/utils';
import {
@@ -10,6 +10,7 @@ import {
DEFAULT_MODEL,
} from '@/lib/agent-context-parser';
import { cn } from '@/lib/utils';
import type { AutoModeEvent } from '@/types/electron';
import {
Brain,
ListTodo,
@@ -71,6 +72,66 @@ export function AgentInfoPanel({
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
// Track real-time task status updates from WebSocket events
const [taskStatusMap, setTaskStatusMap] = useState<
Map<string, 'pending' | 'in_progress' | 'completed'>
>(new Map());
// Fresh planSpec data fetched from API (store data is stale for task progress)
const [freshPlanSpec, setFreshPlanSpec] = useState<{
tasks?: ParsedTask[];
tasksCompleted?: number;
currentTaskId?: string;
} | null>(null);
// Derive effective todos from planSpec.tasks when available, fallback to agentInfo.todos
// Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates
const effectiveTodos = useMemo(() => {
// Use freshPlanSpec if available (fetched from API), fallback to store's feature.planSpec
const planSpec = freshPlanSpec?.tasks?.length ? freshPlanSpec : feature.planSpec;
// First priority: use planSpec.tasks if available (modern approach)
if (planSpec?.tasks && planSpec.tasks.length > 0) {
const completedCount = planSpec.tasksCompleted || 0;
const currentTaskId = planSpec.currentTaskId;
return planSpec.tasks.map((task: ParsedTask, index: number) => {
// Use real-time status from WebSocket events if available
const realtimeStatus = taskStatusMap.get(task.id);
// Calculate status: WebSocket status > index-based status > task.status
let effectiveStatus: 'pending' | 'in_progress' | 'completed';
if (realtimeStatus) {
effectiveStatus = realtimeStatus;
} else if (index < completedCount) {
effectiveStatus = 'completed';
} else if (task.id === currentTaskId) {
effectiveStatus = 'in_progress';
} else {
// Fallback to task.status if available, otherwise pending
effectiveStatus =
task.status === 'completed'
? 'completed'
: task.status === 'in_progress'
? 'in_progress'
: 'pending';
}
return {
content: task.description,
status: effectiveStatus,
};
});
}
// Fallback: use parsed agentInfo.todos from agent-output.md
return agentInfo?.todos || [];
}, [
freshPlanSpec,
feature.planSpec?.tasks,
feature.planSpec?.tasksCompleted,
feature.planSpec?.currentTaskId,
agentInfo?.todos,
taskStatusMap,
]);
useEffect(() => {
const loadContext = async () => {
@@ -82,6 +143,7 @@ export function AgentInfoPanel({
if (feature.status === 'backlog') {
setAgentInfo(null);
setFreshPlanSpec(null);
return;
}
@@ -91,6 +153,21 @@ export function AgentInfoPanel({
if (!currentProject?.path) return;
if (api.features) {
// Fetch fresh feature data to get up-to-date planSpec (store data is stale)
try {
const featureResult = await api.features.get(currentProject.path, feature.id);
const freshFeature: any = (featureResult as any).feature;
if (featureResult.success && freshFeature?.planSpec) {
setFreshPlanSpec({
tasks: freshFeature.planSpec.tasks,
tasksCompleted: freshFeature.planSpec.tasksCompleted || 0,
currentTaskId: freshFeature.planSpec.currentTaskId,
});
}
} catch {
// Ignore errors fetching fresh planSpec
}
const result = await api.features.getAgentOutput(currentProject.path, feature.id);
if (result.success && result.content) {
@@ -113,13 +190,62 @@ export function AgentInfoPanel({
loadContext();
if (isCurrentAutoTask) {
// Poll for updates when feature is in_progress (not just isCurrentAutoTask)
// This ensures planSpec progress stays in sync
if (isCurrentAutoTask || feature.status === 'in_progress') {
const interval = setInterval(loadContext, 3000);
return () => {
clearInterval(interval);
};
}
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
// Listen to WebSocket events for real-time task status updates
// This ensures the Kanban card shows the same progress as the Agent Output modal
// Listen for ANY in-progress feature with planSpec tasks, not just isCurrentAutoTask
const hasPlanSpecTasks =
(freshPlanSpec?.tasks?.length ?? 0) > 0 || (feature.planSpec?.tasks?.length ?? 0) > 0;
const shouldListenToEvents = feature.status === 'in_progress' && hasPlanSpecTasks;
useEffect(() => {
if (!shouldListenToEvents) return;
const api = getElectronAPI();
if (!api?.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
// Only handle events for this feature
if (!('featureId' in event) || event.featureId !== feature.id) return;
switch (event.type) {
case 'auto_mode_task_started':
if ('taskId' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_started' }>;
setTaskStatusMap((prev) => {
const newMap = new Map(prev);
// Mark current task as in_progress
newMap.set(taskEvent.taskId, 'in_progress');
return newMap;
});
}
break;
case 'auto_mode_task_complete':
if ('taskId' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_complete' }>;
setTaskStatusMap((prev) => {
const newMap = new Map(prev);
newMap.set(taskEvent.taskId, 'completed');
return newMap;
});
}
break;
}
});
return unsubscribe;
}, [feature.id, shouldListenToEvents]);
// Model/Preset Info for Backlog Cards
if (feature.status === 'backlog') {
const provider = getProviderFromModel(feature.model);
@@ -158,7 +284,9 @@ export function AgentInfoPanel({
}
// Agent Info Panel for non-backlog cards
if (feature.status !== 'backlog' && agentInfo) {
// Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode)
// Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec
if (feature.status !== 'backlog' && (agentInfo || hasPlanSpecTasks)) {
return (
<>
<div className="mb-3 space-y-2 overflow-hidden">
@@ -171,7 +299,7 @@ export function AgentInfoPanel({
})()}
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
{agentInfo.currentPhase && (
{agentInfo?.currentPhase && (
<div
className={cn(
'px-1.5 py-0.5 rounded-md text-[10px] font-medium',
@@ -189,13 +317,13 @@ export function AgentInfoPanel({
</div>
{/* Task List Progress */}
{agentInfo.todos.length > 0 && (
{effectiveTodos.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
<ListTodo className="w-3 h-3" />
<span>
{agentInfo.todos.filter((t) => t.status === 'completed').length}/
{agentInfo.todos.length} tasks
{effectiveTodos.filter((t) => t.status === 'completed').length}/
{effectiveTodos.length} tasks
</span>
</div>
<div
@@ -204,7 +332,7 @@ export function AgentInfoPanel({
isTodosExpanded ? 'max-h-40' : 'max-h-16'
)}
>
{(isTodosExpanded ? agentInfo.todos : agentInfo.todos.slice(0, 3)).map(
{(isTodosExpanded ? effectiveTodos : effectiveTodos.slice(0, 3)).map(
(todo, idx) => (
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
{todo.status === 'completed' ? (
@@ -227,7 +355,7 @@ export function AgentInfoPanel({
</div>
)
)}
{agentInfo.todos.length > 3 && (
{effectiveTodos.length > 3 && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -237,7 +365,7 @@ export function AgentInfoPanel({
onMouseDown={(e) => e.stopPropagation()}
className="text-[10px] text-muted-foreground/60 pl-4 hover:text-muted-foreground transition-colors cursor-pointer"
>
{isTodosExpanded ? 'Show less' : `+${agentInfo.todos.length - 3} more`}
{isTodosExpanded ? 'Show less' : `+${effectiveTodos.length - 3} more`}
</button>
)}
</div>
@@ -247,7 +375,7 @@ export function AgentInfoPanel({
{/* Summary for waiting_approval and verified */}
{(feature.status === 'waiting_approval' || feature.status === 'verified') && (
<>
{(feature.summary || summary || agentInfo.summary) && (
{(feature.summary || summary || agentInfo?.summary) && (
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0">
@@ -273,23 +401,23 @@ export function AgentInfoPanel({
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{feature.summary || summary || agentInfo.summary}
{feature.summary || summary || agentInfo?.summary}
</p>
</div>
)}
{!feature.summary &&
!summary &&
!agentInfo.summary &&
agentInfo.toolCallCount > 0 && (
!agentInfo?.summary &&
(agentInfo?.toolCallCount ?? 0) > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
{agentInfo?.toolCallCount ?? 0} tool calls
</span>
{agentInfo.todos.length > 0 && (
{effectiveTodos.length > 0 && (
<span className="flex items-center gap-1">
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
{agentInfo.todos.filter((t) => t.status === 'completed').length} tasks done
{effectiveTodos.filter((t) => t.status === 'completed').length} tasks done
</span>
)}
</div>

View File

@@ -117,73 +117,90 @@ export function CardActions({
)}
</>
)}
{!isCurrentAutoTask && feature.status === 'in_progress' && (
<>
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
{feature.planSpec?.status === 'generated' && onApprovePlan && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
onClick={(e) => {
e.stopPropagation();
onApprovePlan();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`approve-plan-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
Approve Plan
</Button>
)}
{feature.skipTests && onManualVerify ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onManualVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`manual-verify-${feature.id}`}
>
<CheckCircle2 className="w-3 h-3 mr-1" />
Verify
</Button>
) : onResume ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
onClick={(e) => {
e.stopPropagation();
onResume();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`resume-feature-${feature.id}`}
>
<RotateCcw className="w-3 h-3 mr-1" />
Resume
</Button>
) : null}
{onViewOutput && !feature.skipTests && (
<Button
variant="secondary"
size="sm"
className="h-7 text-[11px] px-2"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-inprogress-${feature.id}`}
>
<FileText className="w-3 h-3" />
</Button>
)}
</>
)}
{!isCurrentAutoTask &&
(feature.status === 'in_progress' ||
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'))) && (
<>
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
{feature.planSpec?.status === 'generated' && onApprovePlan && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
onClick={(e) => {
e.stopPropagation();
onApprovePlan();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`approve-plan-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
Approve Plan
</Button>
)}
{feature.skipTests && onManualVerify ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onManualVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`manual-verify-${feature.id}`}
>
<CheckCircle2 className="w-3 h-3 mr-1" />
Verify
</Button>
) : onResume ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
onClick={(e) => {
e.stopPropagation();
onResume();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`resume-feature-${feature.id}`}
>
<RotateCcw className="w-3 h-3 mr-1" />
Resume
</Button>
) : onVerify ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
onClick={(e) => {
e.stopPropagation();
onVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`verify-feature-${feature.id}`}
>
<CheckCircle2 className="w-3 h-3 mr-1" />
Verify
</Button>
) : null}
{onViewOutput && !feature.skipTests && (
<Button
variant="secondary"
size="sm"
className="h-7 text-[11px] px-2"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-inprogress-${feature.id}`}
>
<FileText className="w-3 h-3" />
</Button>
)}
</>
)}
{!isCurrentAutoTask && feature.status === 'verified' && (
<>
{/* Logs button */}

View File

@@ -0,0 +1,20 @@
export {
ListHeader,
LIST_COLUMNS,
getColumnById,
getColumnWidth,
getColumnAlign,
} from './list-header';
export type { ListHeaderProps } from './list-header';
export { ListRow, getFeatureSortValue, sortFeatures } from './list-row';
export type { ListRowProps } from './list-row';
export { ListView, getFlatFeatures, getTotalFeatureCount } from './list-view';
export type { ListViewProps, ListViewActionHandlers } from './list-view';
export { RowActions, createRowActionHandlers } from './row-actions';
export type { RowActionsProps, RowActionHandlers } from './row-actions';
export { StatusBadge, getStatusLabel, getStatusOrder } from './status-badge';
export type { StatusBadgeProps } from './status-badge';

View File

@@ -0,0 +1,284 @@
import { memo, useCallback } from 'react';
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SortColumn, SortConfig, SortDirection } from '../../hooks/use-list-view-state';
/**
* Column definition for the list header
*/
interface ColumnDef {
id: SortColumn;
label: string;
/** Whether this column is sortable */
sortable?: boolean;
/** Minimum width for the column */
minWidth?: string;
/** Width class for the column */
width?: string;
/** Alignment of the column content */
align?: 'left' | 'center' | 'right';
/** Additional className for the column */
className?: string;
}
/**
* Default column definitions for the list view
* Only showing title column with full width for a cleaner, more spacious layout
*/
export const LIST_COLUMNS: ColumnDef[] = [
{
id: 'title',
label: 'Title',
sortable: true,
width: 'flex-1',
minWidth: 'min-w-0',
align: 'left',
},
];
export interface ListHeaderProps {
/** Current sort configuration */
sortConfig: SortConfig;
/** Callback when a sortable column is clicked */
onSortChange: (column: SortColumn) => void;
/** Whether to show a checkbox column for selection */
showCheckbox?: boolean;
/** Whether all items are selected (for checkbox state) */
allSelected?: boolean;
/** Whether some but not all items are selected */
someSelected?: boolean;
/** Callback when the select all checkbox is clicked */
onSelectAll?: () => void;
/** Custom column definitions (defaults to LIST_COLUMNS) */
columns?: ColumnDef[];
/** Additional className for the header */
className?: string;
}
/**
* SortIcon displays the current sort state for a column
*/
function SortIcon({ column, sortConfig }: { column: SortColumn; sortConfig: SortConfig }) {
if (sortConfig.column !== column) {
// Not sorted by this column - show neutral indicator
return (
<ChevronsUpDown className="w-3.5 h-3.5 text-muted-foreground/50 group-hover:text-muted-foreground transition-colors" />
);
}
// Currently sorted by this column
if (sortConfig.direction === 'asc') {
return <ChevronUp className="w-3.5 h-3.5 text-foreground" />;
}
return <ChevronDown className="w-3.5 h-3.5 text-foreground" />;
}
/**
* SortableColumnHeader renders a clickable header cell that triggers sorting
*/
const SortableColumnHeader = memo(function SortableColumnHeader({
column,
sortConfig,
onSortChange,
}: {
column: ColumnDef;
sortConfig: SortConfig;
onSortChange: (column: SortColumn) => void;
}) {
const handleClick = useCallback(() => {
onSortChange(column.id);
}, [column.id, onSortChange]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSortChange(column.id);
}
},
[column.id, onSortChange]
);
const isSorted = sortConfig.column === column.id;
const sortDirection: SortDirection | undefined = isSorted ? sortConfig.direction : undefined;
return (
<div
role="columnheader"
aria-sort={isSorted ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'}
tabIndex={0}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
'group flex items-center gap-1.5 px-3 py-2 text-xs font-medium text-muted-foreground',
'cursor-pointer select-none transition-colors duration-200',
'hover:text-foreground hover:bg-accent/50 rounded-md',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
column.width,
column.minWidth,
column.align === 'center' && 'justify-center',
column.align === 'right' && 'justify-end',
isSorted && 'text-foreground',
column.className
)}
data-testid={`list-header-${column.id}`}
>
<span>{column.label}</span>
<SortIcon column={column.id} sortConfig={sortConfig} />
</div>
);
});
/**
* StaticColumnHeader renders a non-sortable header cell
*/
const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column: ColumnDef }) {
return (
<div
role="columnheader"
className={cn(
'flex items-center px-3 py-2 text-xs font-medium text-muted-foreground',
column.width,
column.minWidth,
column.align === 'center' && 'justify-center',
column.align === 'right' && 'justify-end',
column.className
)}
data-testid={`list-header-${column.id}`}
>
<span>{column.label}</span>
</div>
);
});
/**
* ListHeader displays the header row for the list view table with sortable columns.
*
* Features:
* - Clickable column headers for sorting
* - Visual sort direction indicators (chevron up/down)
* - Keyboard accessible (Tab + Enter/Space to sort)
* - ARIA attributes for screen readers
* - Optional checkbox column for bulk selection
* - Customizable column definitions
*
* @example
* ```tsx
* const { sortConfig, setSortColumn } = useListViewState();
*
* <ListHeader
* sortConfig={sortConfig}
* onSortChange={setSortColumn}
* />
* ```
*
* @example
* ```tsx
* // With selection support
* <ListHeader
* sortConfig={sortConfig}
* onSortChange={setSortColumn}
* showCheckbox
* allSelected={allSelected}
* someSelected={someSelected}
* onSelectAll={handleSelectAll}
* />
* ```
*/
export const ListHeader = memo(function ListHeader({
sortConfig,
onSortChange,
showCheckbox = false,
allSelected = false,
someSelected = false,
onSelectAll,
columns = LIST_COLUMNS,
className,
}: ListHeaderProps) {
return (
<div
role="row"
className={cn(
'flex items-center w-full border-b border-border bg-muted/30',
'sticky top-0 z-10 backdrop-blur-sm',
className
)}
data-testid="list-header"
>
{/* Checkbox column for selection */}
{showCheckbox && (
<div
role="columnheader"
className="flex items-center justify-center w-10 px-2 py-2 shrink-0"
>
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) {
el.indeterminate = someSelected && !allSelected;
}
}}
onChange={onSelectAll}
className={cn(
'h-4 w-4 rounded border-border text-primary cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'
)}
aria-label={allSelected ? 'Deselect all' : 'Select all'}
data-testid="list-header-select-all"
/>
</div>
)}
{/* Column headers */}
{columns.map((column) =>
column.sortable !== false ? (
<SortableColumnHeader
key={column.id}
column={column}
sortConfig={sortConfig}
onSortChange={onSortChange}
/>
) : (
<StaticColumnHeader key={column.id} column={column} />
)
)}
{/* Actions column (placeholder for row action buttons) */}
<div
role="columnheader"
className="w-[80px] px-3 py-2 text-xs font-medium text-muted-foreground shrink-0"
aria-label="Actions"
data-testid="list-header-actions"
>
<span className="sr-only">Actions</span>
</div>
</div>
);
});
/**
* Helper function to get a column definition by ID
*/
export function getColumnById(columnId: SortColumn): ColumnDef | undefined {
return LIST_COLUMNS.find((col) => col.id === columnId);
}
/**
* Helper function to get column width class for consistent styling in rows
*/
export function getColumnWidth(columnId: SortColumn): string {
const column = getColumnById(columnId);
return cn(column?.width, column?.minWidth);
}
/**
* Helper function to get column alignment class
*/
export function getColumnAlign(columnId: SortColumn): string {
const column = getColumnById(columnId);
if (column?.align === 'center') return 'justify-center text-center';
if (column?.align === 'right') return 'justify-end text-right';
return '';
}

View File

@@ -0,0 +1,382 @@
// TODO: Remove @ts-nocheck after fixing BaseFeature's index signature issue
// The `[key: string]: unknown` in BaseFeature causes property access type errors
// @ts-nocheck
import { memo, useCallback, useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { AlertCircle, Lock, Hand, Sparkles, FileText } from 'lucide-react';
import type { Feature } from '@/store/app-store';
import { RowActions, type RowActionHandlers } from './row-actions';
import { getColumnWidth, getColumnAlign } from './list-header';
export interface ListRowProps {
/** The feature to display */
feature: Feature;
/** Action handlers for the row */
handlers: RowActionHandlers;
/** Whether this feature is the current auto task (agent is running) */
isCurrentAutoTask?: boolean;
/** Whether the row is selected */
isSelected?: boolean;
/** Whether to show the checkbox for selection */
showCheckbox?: boolean;
/** Callback when the row selection is toggled */
onToggleSelect?: () => void;
/** Callback when the row is clicked */
onClick?: () => void;
/** Blocking dependency feature IDs */
blockingDependencies?: string[];
/** Additional className for custom styling */
className?: string;
}
/**
* IndicatorBadges shows small indicator icons for special states (error, blocked, manual verification, just finished)
*/
const IndicatorBadges = memo(function IndicatorBadges({
feature,
blockingDependencies = [],
isCurrentAutoTask,
}: {
feature: Feature;
blockingDependencies?: string[];
isCurrentAutoTask?: boolean;
}) {
const hasError = feature.error && !isCurrentAutoTask;
const isBlocked =
blockingDependencies.length > 0 && !feature.error && feature.status === 'backlog';
const showManualVerification =
feature.skipTests && !feature.error && feature.status === 'backlog';
const hasPlan = feature.planSpec?.content;
// Check if just finished (within 2 minutes) - uses timer to auto-expire
const [isJustFinished, setIsJustFinished] = useState(false);
useEffect(() => {
if (!feature.justFinishedAt || feature.status !== 'waiting_approval' || feature.error) {
setIsJustFinished(false);
return;
}
const finishedTime = new Date(feature.justFinishedAt).getTime();
const twoMinutes = 2 * 60 * 1000;
const elapsed = Date.now() - finishedTime;
if (elapsed >= twoMinutes) {
setIsJustFinished(false);
return;
}
// Set as just finished
setIsJustFinished(true);
// Set a timeout to clear the "just finished" state when 2 minutes have passed
const remainingTime = twoMinutes - elapsed;
const timeoutId = setTimeout(() => {
setIsJustFinished(false);
}, remainingTime);
return () => clearTimeout(timeoutId);
}, [feature.justFinishedAt, feature.status, feature.error]);
const badges: Array<{
key: string;
icon: typeof AlertCircle;
tooltip: string;
colorClass: string;
bgClass: string;
borderClass: string;
animate?: boolean;
}> = [];
if (hasError) {
badges.push({
key: 'error',
icon: AlertCircle,
tooltip: feature.error || 'Error',
colorClass: 'text-[var(--status-error)]',
bgClass: 'bg-[var(--status-error)]/15',
borderClass: 'border-[var(--status-error)]/30',
});
}
if (isBlocked) {
badges.push({
key: 'blocked',
icon: Lock,
tooltip: `Blocked by ${blockingDependencies.length} incomplete ${blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}`,
colorClass: 'text-orange-500',
bgClass: 'bg-orange-500/15',
borderClass: 'border-orange-500/30',
});
}
if (showManualVerification) {
badges.push({
key: 'manual',
icon: Hand,
tooltip: 'Manual verification required',
colorClass: 'text-[var(--status-warning)]',
bgClass: 'bg-[var(--status-warning)]/15',
borderClass: 'border-[var(--status-warning)]/30',
});
}
if (hasPlan) {
badges.push({
key: 'plan',
icon: FileText,
tooltip: 'Has implementation plan',
colorClass: 'text-[var(--status-info)]',
bgClass: 'bg-[var(--status-info)]/15',
borderClass: 'border-[var(--status-info)]/30',
});
}
if (isJustFinished) {
badges.push({
key: 'just-finished',
icon: Sparkles,
tooltip: 'Agent just finished working on this feature',
colorClass: 'text-[var(--status-success)]',
bgClass: 'bg-[var(--status-success)]/15',
borderClass: 'border-[var(--status-success)]/30',
animate: true,
});
}
if (badges.length === 0) return null;
return (
<div className="flex items-center gap-1 ml-2">
<TooltipProvider delayDuration={200}>
{badges.map((badge) => (
<Tooltip key={badge.key}>
<TooltipTrigger asChild>
<div
className={cn(
'inline-flex items-center justify-center w-5 h-5 rounded border',
badge.colorClass,
badge.bgClass,
badge.borderClass,
badge.animate && 'animate-pulse'
)}
data-testid={`list-row-badge-${badge.key}`}
>
<badge.icon className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs max-w-[250px]">
<p>{badge.tooltip}</p>
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
);
});
/**
* ListRow displays a single feature row in the list view table.
*
* Features:
* - Displays feature data in columns matching ListHeader
* - Hover state with highlight and action buttons
* - Click handler for opening feature details
* - Animated border for currently running auto task
* - Status badge with appropriate colors
* - Priority indicator
* - Indicator badges for errors, blocked state, manual verification, etc.
* - Selection checkbox for bulk operations
*
* @example
* ```tsx
* <ListRow
* feature={feature}
* handlers={{
* onEdit: () => handleEdit(feature.id),
* onDelete: () => handleDelete(feature.id),
* // ... other handlers
* }}
* onClick={() => handleViewDetails(feature)}
* />
* ```
*/
export const ListRow = memo(function ListRow({
feature,
handlers,
isCurrentAutoTask = false,
isSelected = false,
showCheckbox = false,
onToggleSelect,
onClick,
blockingDependencies = [],
className,
}: ListRowProps) {
const handleRowClick = useCallback(
(e: React.MouseEvent) => {
// Don't trigger row click if clicking on checkbox or actions
if ((e.target as HTMLElement).closest('[data-testid^="row-actions"]')) {
return;
}
if ((e.target as HTMLElement).closest('input[type="checkbox"]')) {
return;
}
onClick?.();
},
[onClick]
);
const handleCheckboxChange = useCallback(() => {
onToggleSelect?.();
}, [onToggleSelect]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.();
}
},
[onClick]
);
const hasError = feature.error && !isCurrentAutoTask;
const rowContent = (
<div
role="row"
tabIndex={onClick ? 0 : undefined}
onClick={handleRowClick}
onKeyDown={onClick ? handleKeyDown : undefined}
className={cn(
'group flex items-center w-full border-b border-border/50',
'transition-colors duration-200',
onClick && 'cursor-pointer',
'hover:bg-accent/50',
isSelected && 'bg-accent/70',
hasError && 'bg-[var(--status-error)]/5 hover:bg-[var(--status-error)]/10',
className
)}
data-testid={`list-row-${feature.id}`}
>
{/* Checkbox column */}
{showCheckbox && (
<div role="cell" className="flex items-center justify-center w-10 px-2 py-3 shrink-0">
<input
type="checkbox"
checked={isSelected}
onChange={handleCheckboxChange}
className={cn(
'h-4 w-4 rounded border-border text-primary cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'
)}
aria-label={`Select ${feature.title || feature.description}`}
data-testid={`list-row-checkbox-${feature.id}`}
/>
</div>
)}
{/* Title column - full width with margin for actions */}
<div
role="cell"
className={cn(
'flex items-center px-3 py-3 gap-2',
getColumnWidth('title'),
getColumnAlign('title')
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center">
<span
className={cn(
'font-medium truncate',
feature.titleGenerating && 'animate-pulse text-muted-foreground'
)}
title={feature.title || feature.description}
>
{feature.title || feature.description}
</span>
<IndicatorBadges
feature={feature}
blockingDependencies={blockingDependencies}
isCurrentAutoTask={isCurrentAutoTask}
/>
</div>
{/* Show description as subtitle if title exists and is different */}
{feature.title && feature.title !== feature.description && (
<p
className="text-xs text-muted-foreground truncate mt-0.5"
title={feature.description}
>
{feature.description}
</p>
)}
</div>
</div>
{/* Actions column */}
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isCurrentAutoTask} />
</div>
</div>
);
// Wrap with animated border for currently running auto task
if (isCurrentAutoTask) {
return <div className="animated-border-wrapper-row">{rowContent}</div>;
}
return rowContent;
});
/**
* Helper function to get feature sort value for a column
*/
export function getFeatureSortValue(
feature: Feature,
column: 'title' | 'status' | 'category' | 'priority' | 'createdAt' | 'updatedAt'
): string | number | Date {
switch (column) {
case 'title':
return (feature.title || feature.description).toLowerCase();
case 'status':
return feature.status;
case 'category':
return (feature.category || '').toLowerCase();
case 'priority':
return feature.priority || 999; // No priority sorts last
case 'createdAt':
return feature.createdAt ? new Date(feature.createdAt) : new Date(0);
case 'updatedAt':
return feature.updatedAt ? new Date(feature.updatedAt) : new Date(0);
default:
return '';
}
}
/**
* Helper function to sort features by a column
*/
export function sortFeatures(
features: Feature[],
column: 'title' | 'status' | 'category' | 'priority' | 'createdAt' | 'updatedAt',
direction: 'asc' | 'desc'
): Feature[] {
return [...features].sort((a, b) => {
const aValue = getFeatureSortValue(a, column);
const bValue = getFeatureSortValue(b, column);
let comparison = 0;
if (aValue instanceof Date && bValue instanceof Date) {
comparison = aValue.getTime() - bValue.getTime();
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
comparison = aValue - bValue;
} else {
comparison = String(aValue).localeCompare(String(bValue));
}
return direction === 'asc' ? comparison : -comparison;
});
}

View File

@@ -0,0 +1,460 @@
import { memo, useMemo, useCallback, useState } from 'react';
import { ChevronDown, ChevronRight, Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import type { Feature } from '@/store/app-store';
import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types';
import { ListHeader } from './list-header';
import { ListRow, sortFeatures } from './list-row';
import { createRowActionHandlers, type RowActionHandlers } from './row-actions';
import { getStatusLabel, getStatusOrder } from './status-badge';
import { getColumnsWithPipeline } from '../../constants';
import type { SortConfig, SortColumn } from '../../hooks/use-list-view-state';
/** Empty set constant to avoid creating new instances on each render */
const EMPTY_SET = new Set<string>();
/**
* Status group configuration for the list view
*/
interface StatusGroup {
id: FeatureStatusWithPipeline;
title: string;
colorClass: string;
features: Feature[];
}
/**
* Props for action handlers passed from the parent board view
*/
export interface ListViewActionHandlers {
onEdit: (feature: Feature) => void;
onDelete: (featureId: string) => void;
onViewOutput?: (feature: Feature) => void;
onVerify?: (feature: Feature) => void;
onResume?: (feature: Feature) => void;
onForceStop?: (feature: Feature) => void;
onManualVerify?: (feature: Feature) => void;
onFollowUp?: (feature: Feature) => void;
onImplement?: (feature: Feature) => void;
onComplete?: (feature: Feature) => void;
onViewPlan?: (feature: Feature) => void;
onApprovePlan?: (feature: Feature) => void;
onSpawnTask?: (feature: Feature) => void;
}
export interface ListViewProps {
/** Map of column/status ID to features in that column */
columnFeaturesMap: Record<string, Feature[]>;
/** All features (for dependency checking) */
allFeatures: Feature[];
/** Current sort configuration */
sortConfig: SortConfig;
/** Callback when sort column is changed */
onSortChange: (column: SortColumn) => void;
/** Action handlers for rows */
actionHandlers: ListViewActionHandlers;
/** Set of feature IDs that are currently running */
runningAutoTasks: string[];
/** Pipeline configuration for custom statuses */
pipelineConfig?: PipelineConfig | null;
/** Callback to add a new feature */
onAddFeature?: () => void;
/** Whether selection mode is enabled */
isSelectionMode?: boolean;
/** Set of selected feature IDs */
selectedFeatureIds?: Set<string>;
/** Callback when a feature's selection is toggled */
onToggleFeatureSelection?: (featureId: string) => void;
/** Callback when the row is clicked */
onRowClick?: (feature: Feature) => void;
/** Additional className for custom styling */
className?: string;
}
/**
* StatusGroupHeader displays the header for a status group with collapse toggle
*/
const StatusGroupHeader = memo(function StatusGroupHeader({
group,
isExpanded,
onToggle,
}: {
group: StatusGroup;
isExpanded: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
onClick={onToggle}
className={cn(
'flex items-center gap-2 w-full px-3 py-2 text-left',
'bg-muted/50 hover:bg-muted/70 transition-colors duration-200',
'border-b border-border/50',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset'
)}
aria-expanded={isExpanded}
data-testid={`list-group-header-${group.id}`}
>
{/* Collapse indicator */}
<span className="text-muted-foreground">
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</span>
{/* Status color indicator */}
<span
className={cn('w-2.5 h-2.5 rounded-full shrink-0', group.colorClass)}
aria-hidden="true"
/>
{/* Group title */}
<span className="font-medium text-sm">{group.title}</span>
{/* Feature count */}
<span className="text-xs text-muted-foreground">({group.features.length})</span>
</button>
);
});
/**
* EmptyState displays a message when there are no features
*/
const EmptyState = memo(function EmptyState({ onAddFeature }: { onAddFeature?: () => void }) {
return (
<div
className={cn(
'flex flex-col items-center justify-center py-16 px-4',
'text-center text-muted-foreground'
)}
data-testid="list-view-empty"
>
<p className="text-sm mb-4">No features to display</p>
{onAddFeature && (
<Button variant="outline" size="sm" onClick={onAddFeature}>
<Plus className="w-4 h-4 mr-2" />
Add Feature
</Button>
)}
</div>
);
});
/**
* ListView displays features in a table format grouped by status.
*
* Features:
* - Groups features by status (backlog, in_progress, waiting_approval, verified, pipeline steps)
* - Collapsible status groups
* - Sortable columns (title, status, category, priority, dates)
* - Inline row actions with hover state
* - Selection support for bulk operations
* - Animated border for currently running features
* - Keyboard accessible
*
* The component receives features grouped by status via columnFeaturesMap
* and applies the current sort configuration within each group.
*
* @example
* ```tsx
* const { sortConfig, setSortColumn } = useListViewState();
* const { columnFeaturesMap } = useBoardColumnFeatures({ features, ... });
*
* <ListView
* columnFeaturesMap={columnFeaturesMap}
* allFeatures={features}
* sortConfig={sortConfig}
* onSortChange={setSortColumn}
* actionHandlers={{
* onEdit: handleEdit,
* onDelete: handleDelete,
* // ...
* }}
* runningAutoTasks={runningAutoTasks}
* pipelineConfig={pipelineConfig}
* onAddFeature={handleAddFeature}
* />
* ```
*/
export const ListView = memo(function ListView({
columnFeaturesMap,
allFeatures,
sortConfig,
onSortChange,
actionHandlers,
runningAutoTasks,
pipelineConfig = null,
onAddFeature,
isSelectionMode = false,
selectedFeatureIds = EMPTY_SET,
onToggleFeatureSelection,
onRowClick,
className,
}: ListViewProps) {
// Track collapsed state for each status group
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
// Generate status groups from columnFeaturesMap
const statusGroups = useMemo<StatusGroup[]>(() => {
const columns = getColumnsWithPipeline(pipelineConfig);
const groups: StatusGroup[] = [];
for (const column of columns) {
const features = columnFeaturesMap[column.id] || [];
if (features.length > 0) {
// Sort features within the group according to current sort config
const sortedFeatures = sortFeatures(features, sortConfig.column, sortConfig.direction);
groups.push({
id: column.id as FeatureStatusWithPipeline,
title: column.title,
colorClass: column.colorClass,
features: sortedFeatures,
});
}
}
// Sort groups by status order
return groups.sort((a, b) => getStatusOrder(a.id) - getStatusOrder(b.id));
}, [columnFeaturesMap, pipelineConfig, sortConfig]);
// Calculate total feature count
const totalFeatures = useMemo(
() => statusGroups.reduce((sum, group) => sum + group.features.length, 0),
[statusGroups]
);
// Toggle group collapse state
const toggleGroup = useCallback((groupId: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
}, []);
// Create row action handlers for a feature
const createHandlers = useCallback(
(feature: Feature): RowActionHandlers => {
return createRowActionHandlers(feature.id, {
editFeature: (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onEdit(f);
},
deleteFeature: (id) => actionHandlers.onDelete(id),
viewOutput: actionHandlers.onViewOutput
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onViewOutput?.(f);
}
: undefined,
verifyFeature: actionHandlers.onVerify
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onVerify?.(f);
}
: undefined,
resumeFeature: actionHandlers.onResume
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onResume?.(f);
}
: undefined,
forceStop: actionHandlers.onForceStop
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onForceStop?.(f);
}
: undefined,
manualVerify: actionHandlers.onManualVerify
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onManualVerify?.(f);
}
: undefined,
followUp: actionHandlers.onFollowUp
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onFollowUp?.(f);
}
: undefined,
implement: actionHandlers.onImplement
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onImplement?.(f);
}
: undefined,
complete: actionHandlers.onComplete
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onComplete?.(f);
}
: undefined,
viewPlan: actionHandlers.onViewPlan
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onViewPlan?.(f);
}
: undefined,
approvePlan: actionHandlers.onApprovePlan
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onApprovePlan?.(f);
}
: undefined,
spawnTask: actionHandlers.onSpawnTask
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onSpawnTask?.(f);
}
: undefined,
});
},
[actionHandlers, allFeatures]
);
// Get blocking dependencies for a feature
const getBlockingDeps = useCallback(
(feature: Feature): string[] => {
return getBlockingDependencies(feature, allFeatures);
},
[allFeatures]
);
// Calculate selection state for header checkbox
const selectionState = useMemo(() => {
if (!isSelectionMode || totalFeatures === 0) {
return { allSelected: false, someSelected: false };
}
const selectedCount = selectedFeatureIds.size;
return {
allSelected: selectedCount === totalFeatures && selectedCount > 0,
someSelected: selectedCount > 0 && selectedCount < totalFeatures,
};
}, [isSelectionMode, totalFeatures, selectedFeatureIds]);
// Handle select all toggle
const handleSelectAll = useCallback(() => {
if (!onToggleFeatureSelection) return;
// If all selected, deselect all; otherwise select all
if (selectionState.allSelected) {
// Clear all selections
selectedFeatureIds.forEach((id) => onToggleFeatureSelection(id));
} else {
// Select all features that aren't already selected
for (const group of statusGroups) {
for (const feature of group.features) {
if (!selectedFeatureIds.has(feature.id)) {
onToggleFeatureSelection(feature.id);
}
}
}
}
}, [onToggleFeatureSelection, selectionState.allSelected, selectedFeatureIds, statusGroups]);
// Show empty state if no features
if (totalFeatures === 0) {
return (
<div className={cn('flex flex-col h-full bg-background', className)} data-testid="list-view">
<EmptyState onAddFeature={onAddFeature} />
</div>
);
}
return (
<div
className={cn('flex flex-col h-full bg-background', className)}
role="table"
aria-label="Features list"
data-testid="list-view"
>
{/* Table header */}
<ListHeader
sortConfig={sortConfig}
onSortChange={onSortChange}
showCheckbox={isSelectionMode}
allSelected={selectionState.allSelected}
someSelected={selectionState.someSelected}
onSelectAll={handleSelectAll}
/>
{/* Table body with status groups */}
<div className="flex-1 overflow-y-auto" role="rowgroup">
{statusGroups.map((group) => {
const isExpanded = !collapsedGroups.has(group.id);
return (
<div
key={group.id}
className="border-b border-border/30"
data-testid={`list-group-${group.id}`}
>
{/* Group header */}
<StatusGroupHeader
group={group}
isExpanded={isExpanded}
onToggle={() => toggleGroup(group.id)}
/>
{/* Group rows */}
{isExpanded && (
<div role="rowgroup">
{group.features.map((feature) => (
<ListRow
key={feature.id}
feature={feature}
handlers={createHandlers(feature)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
pipelineConfig={pipelineConfig}
isSelected={selectedFeatureIds.has(feature.id)}
showCheckbox={isSelectionMode}
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
onClick={() => onRowClick?.(feature)}
blockingDependencies={getBlockingDeps(feature)}
/>
))}
</div>
)}
</div>
);
})}
</div>
{/* Footer with Add Feature button */}
{onAddFeature && (
<div className="border-t border-border px-4 py-3">
<Button
variant="outline"
size="sm"
onClick={onAddFeature}
className="w-full sm:w-auto"
data-testid="list-view-add-feature"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
</Button>
</div>
)}
</div>
);
});
/**
* Helper to get all features from the columnFeaturesMap as a flat array
*/
export function getFlatFeatures(columnFeaturesMap: Record<string, Feature[]>): Feature[] {
return Object.values(columnFeaturesMap).flat();
}
/**
* Helper to count total features across all groups
*/
export function getTotalFeatureCount(columnFeaturesMap: Record<string, Feature[]>): number {
return Object.values(columnFeaturesMap).reduce((sum, features) => sum + features.length, 0);
}

View File

@@ -0,0 +1,635 @@
import { memo, useCallback, useState } from 'react';
import {
MoreHorizontal,
Edit,
Trash2,
PlayCircle,
RotateCcw,
StopCircle,
CheckCircle2,
FileText,
Eye,
Wand2,
Archive,
GitBranch,
GitFork,
ExternalLink,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { Feature } from '@/store/app-store';
/**
* Action handler types for row actions
*/
export interface RowActionHandlers {
onEdit: () => void;
onDelete: () => void;
onViewOutput?: () => void;
onVerify?: () => void;
onResume?: () => void;
onForceStop?: () => void;
onManualVerify?: () => void;
onFollowUp?: () => void;
onImplement?: () => void;
onComplete?: () => void;
onViewPlan?: () => void;
onApprovePlan?: () => void;
onSpawnTask?: () => void;
}
export interface RowActionsProps {
/** The feature for this row */
feature: Feature;
/** Action handlers */
handlers: RowActionHandlers;
/** Whether this feature is the current auto task (agent is running) */
isCurrentAutoTask?: boolean;
/** Whether the dropdown menu is open */
isOpen?: boolean;
/** Callback when the dropdown open state changes */
onOpenChange?: (open: boolean) => void;
/** Additional className for custom styling */
className?: string;
}
/**
* MenuItem is a helper component for dropdown menu items with consistent styling
*/
const MenuItem = memo(function MenuItem({
icon: Icon,
label,
onClick,
variant = 'default',
disabled = false,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
variant?: 'default' | 'destructive' | 'primary' | 'success' | 'warning';
disabled?: boolean;
}) {
const variantClasses = {
default: '',
destructive: 'text-destructive focus:text-destructive focus:bg-destructive/10',
primary: 'text-primary focus:text-primary focus:bg-primary/10',
success:
'text-[var(--status-success)] focus:text-[var(--status-success)] focus:bg-[var(--status-success)]/10',
warning:
'text-[var(--status-waiting)] focus:text-[var(--status-waiting)] focus:bg-[var(--status-waiting)]/10',
};
return (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onClick();
}}
disabled={disabled}
className={cn('gap-2', variantClasses[variant])}
>
<Icon className="w-4 h-4" />
<span>{label}</span>
</DropdownMenuItem>
);
});
/**
* Get the primary action for quick access button based on feature status
*/
function getPrimaryAction(
feature: Feature,
handlers: RowActionHandlers,
isCurrentAutoTask: boolean
): {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
variant?: 'default' | 'destructive' | 'primary' | 'success' | 'warning';
} | null {
// Running task - force stop is primary
if (isCurrentAutoTask) {
if (handlers.onForceStop) {
return {
icon: StopCircle,
label: 'Stop',
onClick: handlers.onForceStop,
variant: 'destructive',
};
}
return null;
}
// Backlog - implement is primary
if (feature.status === 'backlog' && handlers.onImplement) {
return {
icon: PlayCircle,
label: 'Make',
onClick: handlers.onImplement,
variant: 'primary',
};
}
// In progress with plan approval pending
if (
feature.status === 'in_progress' &&
feature.planSpec?.status === 'generated' &&
handlers.onApprovePlan
) {
return {
icon: FileText,
label: 'Approve',
onClick: handlers.onApprovePlan,
variant: 'warning',
};
}
// In progress - resume is primary
if (feature.status === 'in_progress' && handlers.onResume) {
return {
icon: RotateCcw,
label: 'Resume',
onClick: handlers.onResume,
variant: 'success',
};
}
// Waiting approval - verify is primary
if (feature.status === 'waiting_approval' && handlers.onManualVerify) {
return {
icon: CheckCircle2,
label: 'Verify',
onClick: handlers.onManualVerify,
variant: 'success',
};
}
// Verified - complete is primary
if (feature.status === 'verified' && handlers.onComplete) {
return {
icon: Archive,
label: 'Complete',
onClick: handlers.onComplete,
variant: 'primary',
};
}
return null;
}
/**
* Get secondary actions for inline display based on feature status
*/
function getSecondaryActions(
feature: Feature,
handlers: RowActionHandlers
): Array<{
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
}> {
const actions = [];
// Refine action for waiting_approval status
if (feature.status === 'waiting_approval' && handlers.onFollowUp) {
actions.push({
icon: Wand2,
label: 'Refine',
onClick: handlers.onFollowUp,
});
}
return actions;
}
/**
* RowActions provides an inline action menu for list view rows.
*
* Features:
* - Quick access button for primary action (Make, Resume, Verify, etc.)
* - Dropdown menu with all available actions
* - Context-aware actions based on feature status
* - Support for running task actions (view logs, force stop)
* - Keyboard accessible (focus, Enter/Space to open)
*
* Actions by status:
* - Backlog: Edit, Delete, Make (implement), View Plan, Spawn Sub-Task
* - In Progress: View Logs, Resume, Approve Plan, Manual Verify, Edit, Spawn Sub-Task, Delete
* - Waiting Approval: Refine (inline secondary), Verify, View Logs, View PR, Edit, Spawn Sub-Task, Delete
* - Verified: View Logs, View PR, View Branch, Complete, Edit, Spawn Sub-Task, Delete
* - Running (auto task): View Logs, Approve Plan, Edit, Spawn Sub-Task, Force Stop
* - Pipeline statuses: View Logs, Edit, Spawn Sub-Task, Delete
*
* @example
* ```tsx
* <RowActions
* feature={feature}
* handlers={{
* onEdit: () => handleEdit(feature.id),
* onDelete: () => handleDelete(feature.id),
* onImplement: () => handleImplement(feature.id),
* // ... other handlers
* }}
* />
* ```
*/
export const RowActions = memo(function RowActions({
feature,
handlers,
isCurrentAutoTask = false,
isOpen,
onOpenChange,
className,
}: RowActionsProps) {
const [internalOpen, setInternalOpen] = useState(false);
// Use controlled or uncontrolled state
const open = isOpen ?? internalOpen;
const setOpen = (value: boolean) => {
if (onOpenChange) {
onOpenChange(value);
} else {
setInternalOpen(value);
}
};
const handleOpenChange = useCallback(
(newOpen: boolean) => {
setOpen(newOpen);
},
[setOpen]
);
const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask);
const secondaryActions = getSecondaryActions(feature, handlers);
// Helper to close menu after action
const withClose = useCallback(
(handler: () => void) => () => {
setOpen(false);
handler();
},
[setOpen]
);
return (
<div
className={cn('flex items-center gap-1', className)}
onClick={(e) => e.stopPropagation()}
data-testid={`row-actions-${feature.id}`}
>
{/* Primary action quick button */}
{primaryAction && (
<Button
variant="ghost"
size="icon-sm"
className={cn(
'h-7 w-7',
primaryAction.variant === 'destructive' &&
'hover:bg-destructive/10 hover:text-destructive',
primaryAction.variant === 'primary' && 'hover:bg-primary/10 hover:text-primary',
primaryAction.variant === 'success' &&
'hover:bg-[var(--status-success)]/10 hover:text-[var(--status-success)]',
primaryAction.variant === 'warning' &&
'hover:bg-[var(--status-waiting)]/10 hover:text-[var(--status-waiting)]'
)}
onClick={(e) => {
e.stopPropagation();
primaryAction.onClick();
}}
title={primaryAction.label}
data-testid={`row-action-primary-${feature.id}`}
>
<primaryAction.icon className="w-4 h-4" />
</Button>
)}
{/* Secondary action buttons */}
{secondaryActions.map((action, index) => (
<Button
key={`secondary-action-${index}`}
variant="ghost"
size="icon-sm"
className={cn('h-7 w-7', 'text-muted-foreground', 'hover:bg-muted hover:text-foreground')}
onClick={(e) => {
e.stopPropagation();
action.onClick();
}}
title={action.label}
data-testid={`row-action-secondary-${feature.id}-${action.label.toLowerCase()}`}
>
<action.icon className="w-4 h-4" />
</Button>
))}
{/* Dropdown menu */}
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
data-testid={`row-actions-trigger-${feature.id}`}
>
<MoreHorizontal className="w-4 h-4" />
<span className="sr-only">Open actions menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{/* Running task actions */}
{isCurrentAutoTask && (
<>
{handlers.onViewOutput && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
{feature.planSpec?.status === 'generated' && handlers.onApprovePlan && (
<MenuItem
icon={FileText}
label="Approve Plan"
onClick={withClose(handlers.onApprovePlan)}
variant="warning"
/>
)}
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
{handlers.onForceStop && (
<>
<DropdownMenuSeparator />
<MenuItem
icon={StopCircle}
label="Force Stop"
onClick={withClose(handlers.onForceStop)}
variant="destructive"
/>
</>
)}
</>
)}
{/* Backlog actions */}
{!isCurrentAutoTask && feature.status === 'backlog' && (
<>
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{feature.planSpec?.content && handlers.onViewPlan && (
<MenuItem icon={Eye} label="View Plan" onClick={withClose(handlers.onViewPlan)} />
)}
{handlers.onImplement && (
<MenuItem
icon={PlayCircle}
label="Make"
onClick={withClose(handlers.onImplement)}
variant="primary"
/>
)}
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<DropdownMenuSeparator />
<MenuItem
icon={Trash2}
label="Delete"
onClick={withClose(handlers.onDelete)}
variant="destructive"
/>
</>
)}
{/* In Progress actions */}
{!isCurrentAutoTask && feature.status === 'in_progress' && (
<>
{handlers.onViewOutput && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
{feature.planSpec?.status === 'generated' && handlers.onApprovePlan && (
<MenuItem
icon={FileText}
label="Approve Plan"
onClick={withClose(handlers.onApprovePlan)}
variant="warning"
/>
)}
{feature.skipTests && handlers.onManualVerify ? (
<MenuItem
icon={CheckCircle2}
label="Verify"
onClick={withClose(handlers.onManualVerify)}
variant="success"
/>
) : handlers.onResume ? (
<MenuItem
icon={RotateCcw}
label="Resume"
onClick={withClose(handlers.onResume)}
variant="success"
/>
) : null}
<DropdownMenuSeparator />
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<MenuItem
icon={Trash2}
label="Delete"
onClick={withClose(handlers.onDelete)}
variant="destructive"
/>
</>
)}
{/* Waiting Approval actions */}
{!isCurrentAutoTask && feature.status === 'waiting_approval' && (
<>
{handlers.onViewOutput && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
{handlers.onFollowUp && (
<MenuItem icon={Wand2} label="Refine" onClick={withClose(handlers.onFollowUp)} />
)}
{feature.prUrl && (
<MenuItem
icon={ExternalLink}
label="View PR"
onClick={withClose(() => window.open(feature.prUrl, '_blank'))}
/>
)}
{handlers.onManualVerify && (
<MenuItem
icon={CheckCircle2}
label={feature.prUrl ? 'Verify' : 'Mark as Verified'}
onClick={withClose(handlers.onManualVerify)}
variant="success"
/>
)}
<DropdownMenuSeparator />
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<MenuItem
icon={Trash2}
label="Delete"
onClick={withClose(handlers.onDelete)}
variant="destructive"
/>
</>
)}
{/* Verified actions */}
{!isCurrentAutoTask && feature.status === 'verified' && (
<>
{handlers.onViewOutput && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
{feature.prUrl && (
<MenuItem
icon={ExternalLink}
label="View PR"
onClick={withClose(() => window.open(feature.prUrl, '_blank'))}
/>
)}
{feature.worktree && (
<MenuItem
icon={GitBranch}
label="View Branch"
onClick={withClose(() => {})}
disabled
/>
)}
{handlers.onComplete && (
<MenuItem
icon={Archive}
label="Complete"
onClick={withClose(handlers.onComplete)}
variant="primary"
/>
)}
<DropdownMenuSeparator />
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<MenuItem
icon={Trash2}
label="Delete"
onClick={withClose(handlers.onDelete)}
variant="destructive"
/>
</>
)}
{/* Pipeline status actions (generic fallback) */}
{!isCurrentAutoTask && feature.status.startsWith('pipeline_') && (
<>
{handlers.onViewOutput && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<DropdownMenuSeparator />
<MenuItem
icon={Trash2}
label="Delete"
onClick={withClose(handlers.onDelete)}
variant="destructive"
/>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
});
/**
* Helper function to create action handlers from common patterns
*/
export function createRowActionHandlers(
featureId: string,
actions: {
editFeature?: (id: string) => void;
deleteFeature?: (id: string) => void;
viewOutput?: (id: string) => void;
verifyFeature?: (id: string) => void;
resumeFeature?: (id: string) => void;
forceStop?: (id: string) => void;
manualVerify?: (id: string) => void;
followUp?: (id: string) => void;
implement?: (id: string) => void;
complete?: (id: string) => void;
viewPlan?: (id: string) => void;
approvePlan?: (id: string) => void;
spawnTask?: (id: string) => void;
}
): RowActionHandlers {
return {
onEdit: () => actions.editFeature?.(featureId),
onDelete: () => actions.deleteFeature?.(featureId),
onViewOutput: actions.viewOutput ? () => actions.viewOutput!(featureId) : undefined,
onVerify: actions.verifyFeature ? () => actions.verifyFeature!(featureId) : undefined,
onResume: actions.resumeFeature ? () => actions.resumeFeature!(featureId) : undefined,
onForceStop: actions.forceStop ? () => actions.forceStop!(featureId) : undefined,
onManualVerify: actions.manualVerify ? () => actions.manualVerify!(featureId) : undefined,
onFollowUp: actions.followUp ? () => actions.followUp!(featureId) : undefined,
onImplement: actions.implement ? () => actions.implement!(featureId) : undefined,
onComplete: actions.complete ? () => actions.complete!(featureId) : undefined,
onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined,
onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined,
onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined,
};
}

View File

@@ -0,0 +1,218 @@
import { memo, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { COLUMNS, isPipelineStatus } from '../../constants';
import type { FeatureStatusWithPipeline, PipelineConfig } from '@automaker/types';
/**
* Status display configuration
*/
interface StatusDisplay {
label: string;
colorClass: string;
bgClass: string;
borderClass: string;
}
/**
* Base status display configurations using CSS variables
*/
const BASE_STATUS_DISPLAY: Record<string, StatusDisplay> = {
backlog: {
label: 'Backlog',
colorClass: 'text-[var(--status-backlog)]',
bgClass: 'bg-[var(--status-backlog)]/15',
borderClass: 'border-[var(--status-backlog)]/30',
},
in_progress: {
label: 'In Progress',
colorClass: 'text-[var(--status-in-progress)]',
bgClass: 'bg-[var(--status-in-progress)]/15',
borderClass: 'border-[var(--status-in-progress)]/30',
},
waiting_approval: {
label: 'Waiting Approval',
colorClass: 'text-[var(--status-waiting)]',
bgClass: 'bg-[var(--status-waiting)]/15',
borderClass: 'border-[var(--status-waiting)]/30',
},
verified: {
label: 'Verified',
colorClass: 'text-[var(--status-success)]',
bgClass: 'bg-[var(--status-success)]/15',
borderClass: 'border-[var(--status-success)]/30',
},
};
/**
* Get display configuration for a pipeline status
*/
function getPipelineStatusDisplay(
status: string,
pipelineConfig: PipelineConfig | null
): StatusDisplay | null {
if (!isPipelineStatus(status) || !pipelineConfig?.steps) {
return null;
}
const stepId = status.replace('pipeline_', '');
const step = pipelineConfig.steps.find((s) => s.id === stepId);
if (!step) {
return null;
}
// Extract the color variable from the colorClass (e.g., "bg-[var(--status-in-progress)]")
// and use it for the badge styling
const colorVar = step.colorClass?.match(/var\(([^)]+)\)/)?.[1] || '--status-in-progress';
return {
label: step.name || 'Pipeline Step',
colorClass: `text-[var(${colorVar})]`,
bgClass: `bg-[var(${colorVar})]/15`,
borderClass: `border-[var(${colorVar})]/30`,
};
}
/**
* Get the display configuration for a status
*/
function getStatusDisplay(
status: FeatureStatusWithPipeline,
pipelineConfig: PipelineConfig | null
): StatusDisplay {
// Check for pipeline status first
if (isPipelineStatus(status)) {
const pipelineDisplay = getPipelineStatusDisplay(status, pipelineConfig);
if (pipelineDisplay) {
return pipelineDisplay;
}
// Fallback for unknown pipeline status
return {
label: status.replace('pipeline_', '').replace(/_/g, ' '),
colorClass: 'text-[var(--status-in-progress)]',
bgClass: 'bg-[var(--status-in-progress)]/15',
borderClass: 'border-[var(--status-in-progress)]/30',
};
}
// Check base status
const baseDisplay = BASE_STATUS_DISPLAY[status];
if (baseDisplay) {
return baseDisplay;
}
// Try to find from COLUMNS constant
const column = COLUMNS.find((c) => c.id === status);
if (column) {
return {
label: column.title,
colorClass: 'text-muted-foreground',
bgClass: 'bg-muted/50',
borderClass: 'border-border/50',
};
}
// Fallback for unknown status
return {
label: status.replace(/_/g, ' '),
colorClass: 'text-muted-foreground',
bgClass: 'bg-muted/50',
borderClass: 'border-border/50',
};
}
export interface StatusBadgeProps {
/** The status to display */
status: FeatureStatusWithPipeline;
/** Optional pipeline configuration for custom pipeline steps */
pipelineConfig?: PipelineConfig | null;
/** Size variant for the badge */
size?: 'sm' | 'default' | 'lg';
/** Additional className for custom styling */
className?: string;
}
/**
* StatusBadge displays a feature status as a colored chip/badge for use in the list view table.
*
* Features:
* - Displays status with appropriate color based on status type
* - Supports base statuses (backlog, in_progress, waiting_approval, verified)
* - Supports pipeline statuses with custom colors from pipeline configuration
* - Size variants (sm, default, lg)
* - Uses CSS variables for consistent theming
*
* @example
* ```tsx
* // Basic usage
* <StatusBadge status="backlog" />
*
* // With pipeline configuration
* <StatusBadge status="pipeline_review" pipelineConfig={pipelineConfig} />
*
* // Small size
* <StatusBadge status="verified" size="sm" />
* ```
*/
export const StatusBadge = memo(function StatusBadge({
status,
pipelineConfig = null,
size = 'default',
className,
}: StatusBadgeProps) {
const display = useMemo(() => getStatusDisplay(status, pipelineConfig), [status, pipelineConfig]);
const sizeClasses = {
sm: 'px-1.5 py-0.5 text-[10px]',
default: 'px-2 py-0.5 text-xs',
lg: 'px-2.5 py-1 text-sm',
};
return (
<span
className={cn(
'inline-flex items-center rounded-full border font-medium whitespace-nowrap',
'transition-colors duration-200',
sizeClasses[size],
display.colorClass,
display.bgClass,
display.borderClass,
className
)}
data-testid={`status-badge-${status}`}
>
{display.label}
</span>
);
});
/**
* Helper function to get the status label without rendering the badge
* Useful for sorting or filtering operations
*/
export function getStatusLabel(
status: FeatureStatusWithPipeline,
pipelineConfig: PipelineConfig | null = null
): string {
return getStatusDisplay(status, pipelineConfig).label;
}
/**
* Helper function to get the status order for sorting
* Returns a numeric value representing the status position in the workflow
*/
export function getStatusOrder(status: FeatureStatusWithPipeline): number {
const baseOrder: Record<string, number> = {
backlog: 0,
in_progress: 1,
waiting_approval: 2,
verified: 3,
};
if (isPipelineStatus(status)) {
// Pipeline statuses come after in_progress but before waiting_approval
return 1.5;
}
return baseOrder[status] ?? 0;
}

View File

@@ -30,9 +30,7 @@ export function SelectionActionBar({
}: SelectionActionBarProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
if (selectedCount === 0) return null;
const allSelected = selectedCount === totalCount;
const allSelected = selectedCount === totalCount && totalCount > 0;
const handleDeleteClick = () => {
setShowDeleteDialog(true);
@@ -55,7 +53,9 @@ export function SelectionActionBar({
data-testid="selection-action-bar"
>
<span className="text-sm font-medium text-foreground">
{selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected
{selectedCount === 0
? 'Select features to edit'
: `${selectedCount} feature${selectedCount !== 1 ? 's' : ''} selected`}
</span>
<div className="h-4 w-px bg-border" />
@@ -65,7 +65,8 @@ export function SelectionActionBar({
variant="default"
size="sm"
onClick={onEdit}
className="h-8 bg-brand-500 hover:bg-brand-600"
disabled={selectedCount === 0}
className="h-8 bg-brand-500 hover:bg-brand-600 disabled:opacity-50"
data-testid="selection-edit-button"
>
<Pencil className="w-4 h-4 mr-1.5" />
@@ -76,7 +77,8 @@ export function SelectionActionBar({
variant="outline"
size="sm"
onClick={handleDeleteClick}
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
disabled={selectedCount === 0}
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10 disabled:opacity-50"
data-testid="selection-delete-button"
>
<Trash2 className="w-4 h-4 mr-1.5" />

View File

@@ -0,0 +1,62 @@
import { LayoutGrid, List } from 'lucide-react';
import { cn } from '@/lib/utils';
export type ViewMode = 'kanban' | 'list';
interface ViewToggleProps {
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
className?: string;
}
/**
* A segmented control component for switching between kanban (grid) and list views.
* Uses icons to represent each view mode with clear visual feedback.
*/
export function ViewToggle({ viewMode, onViewModeChange, className }: ViewToggleProps) {
return (
<div
className={cn(
'inline-flex h-8 items-center rounded-md bg-muted p-[3px] border border-border',
className
)}
role="tablist"
aria-label="View mode"
>
<button
role="tab"
aria-selected={viewMode === 'kanban'}
aria-label="Kanban view"
onClick={() => onViewModeChange('kanban')}
className={cn(
'inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium transition-all duration-200 cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
viewMode === 'kanban'
? 'bg-primary text-primary-foreground shadow-md'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="view-toggle-kanban"
>
<LayoutGrid className="w-4 h-4" />
<span className="sr-only">Kanban</span>
</button>
<button
role="tab"
aria-selected={viewMode === 'list'}
aria-label="List view"
onClick={() => onViewModeChange('list')}
className={cn(
'inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium transition-all duration-200 cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
viewMode === 'list'
? 'bg-primary text-primary-foreground shadow-md'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="view-toggle-list"
>
<List className="w-4 h-4" />
<span className="sr-only">List</span>
</button>
</div>
);
}

View File

@@ -21,7 +21,8 @@ import {
FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import { Play, Cpu, FolderKanban } from 'lucide-react';
import { Play, Cpu, FolderKanban, Settings2 } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { modelSupportsThinking } from '@/lib/utils';
@@ -33,7 +34,7 @@ import {
PlanningMode,
Feature,
} from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry } from '@automaker/types';
import type { ReasoningEffort, PhaseModelEntry, AgentModel } from '@automaker/types';
import { supportsReasoningEffort, isClaudeModel } from '@automaker/types';
import {
TestingTabContent,
@@ -122,7 +123,7 @@ interface AddFeatureDialogProps {
selectedNonMainWorktreeBranch?: string;
/**
* When true, forces the dialog to default to 'current' work mode (work on current branch).
* This is used when the "Use selected worktree branch" setting is disabled.
* This is used when the "Default to worktree mode" setting is disabled.
*/
forceCurrentBranchMode?: boolean;
}
@@ -152,6 +153,7 @@ export function AddFeatureDialog({
forceCurrentBranchMode,
}: AddFeatureDialogProps) {
const isSpawnMode = !!parentFeature;
const navigate = useNavigate();
const [workMode, setWorkMode] = useState<WorkMode>('current');
// Form state
@@ -187,7 +189,8 @@ export function AddFeatureDialog({
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
// Get defaults from store
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees, defaultFeatureModel } =
useAppStore();
// Track previous open state to detect when dialog opens
const wasOpenRef = useRef(false);
@@ -207,7 +210,7 @@ export function AddFeatureDialog({
);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setModelEntry({ model: 'opus' });
setModelEntry(defaultFeatureModel);
// Initialize description history (empty for new feature)
setDescriptionHistory([]);
@@ -228,6 +231,7 @@ export function AddFeatureDialog({
defaultBranch,
defaultPlanningMode,
defaultRequirePlanApproval,
defaultFeatureModel,
useWorktrees,
selectedNonMainWorktreeBranch,
forceCurrentBranchMode,
@@ -318,7 +322,7 @@ export function AddFeatureDialog({
// When a non-main worktree is selected, use its branch name for custom mode
setBranchName(selectedNonMainWorktreeBranch || '');
setPriority(2);
setModelEntry({ model: 'opus' });
setModelEntry(defaultFeatureModel);
setWorkMode(
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
);
@@ -473,9 +477,31 @@ export function AddFeatureDialog({
{/* AI & Execution Section */}
<div className={cardClass}>
<div className={sectionHeaderClass}>
<Cpu className="w-4 h-4 text-muted-foreground" />
<span>AI & Execution</span>
<div className="flex items-center justify-between">
<div className={sectionHeaderClass}>
<Cpu className="w-4 h-4 text-muted-foreground" />
<span>AI & Execution</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
onOpenChange(false);
navigate({ to: '/settings', search: { view: 'defaults' } });
}}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Settings2 className="w-3.5 h-3.5" />
<span>Edit Defaults</span>
</button>
</TooltipTrigger>
<TooltipContent>
<p>Change default model and planning settings for new features</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="space-y-1.5">

View File

@@ -304,22 +304,22 @@ export function AgentOutputModal({
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
className="w-full h-full max-w-full max-h-full sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col"
data-testid="agent-output-modal"
>
<DialogHeader className="shrink-0">
<div className="flex items-center justify-between pr-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-8">
<DialogTitle className="flex items-center gap-2">
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
Agent Output
</DialogTitle>
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<div className="flex items-center gap-1 bg-muted rounded-lg p-1 overflow-x-auto">
{summary && (
<button
onClick={() => setViewMode('summary')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
effectiveViewMode === 'summary'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -332,7 +332,7 @@ export function AgentOutputModal({
)}
<button
onClick={() => setViewMode('parsed')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
effectiveViewMode === 'parsed'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -344,7 +344,7 @@ export function AgentOutputModal({
</button>
<button
onClick={() => setViewMode('changes')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
effectiveViewMode === 'changes'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -356,7 +356,7 @@ export function AgentOutputModal({
</button>
<button
onClick={() => setViewMode('raw')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
effectiveViewMode === 'raw'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -380,11 +380,11 @@ export function AgentOutputModal({
<TaskProgressPanel
featureId={featureId}
projectPath={projectPath}
className="flex-shrink-0 mx-1"
className="flex-shrink-0 mx-3 my-2"
/>
{effectiveViewMode === 'changes' ? (
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? (
<GitDiffPanel
projectPath={projectPath}
@@ -401,7 +401,7 @@ export function AgentOutputModal({
)}
</div>
) : effectiveViewMode === 'summary' && summary ? (
<div className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 min-h-[400px] max-h-[60vh] scrollbar-visible">
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto bg-card border border-border/50 rounded-lg p-4 scrollbar-visible">
<Markdown>{summary}</Markdown>
</div>
) : (
@@ -409,7 +409,7 @@ export function AgentOutputModal({
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[400px] max-h-[60vh] scrollbar-visible"
className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs scrollbar-visible"
>
{isLoading && !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">

View File

@@ -1,68 +0,0 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { FastForward, Settings2 } from 'lucide-react';
interface AutoModeSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
skipVerificationInAutoMode: boolean;
onSkipVerificationChange: (value: boolean) => void;
}
export function AutoModeSettingsDialog({
open,
onOpenChange,
skipVerificationInAutoMode,
onSkipVerificationChange,
}: AutoModeSettingsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="auto-mode-settings-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="w-5 h-5" />
Auto Mode Settings
</DialogTitle>
<DialogDescription>
Configure how auto mode handles feature execution and dependencies.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Skip Verification Setting */}
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<Label
htmlFor="skip-verification-toggle"
className="text-sm font-medium cursor-pointer flex items-center gap-2"
>
<FastForward className="w-4 h-4 text-brand-500" />
Skip verification requirement
</Label>
<Switch
id="skip-verification-toggle"
checked={skipVerificationInAutoMode}
onCheckedChange={onSkipVerificationChange}
data-testid="skip-verification-toggle"
/>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, auto mode will grab features even if their dependencies are not
verified, as long as they are not currently running. This allows faster pipeline
execution without waiting for manual verification.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,95 @@
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Slider } from '@/components/ui/slider';
import { FastForward, Bot, Settings2 } from 'lucide-react';
interface AutoModeSettingsPopoverProps {
skipVerificationInAutoMode: boolean;
onSkipVerificationChange: (value: boolean) => void;
maxConcurrency: number;
runningAgentsCount: number;
onConcurrencyChange: (value: number) => void;
}
export function AutoModeSettingsPopover({
skipVerificationInAutoMode,
onSkipVerificationChange,
maxConcurrency,
runningAgentsCount,
onConcurrencyChange,
}: AutoModeSettingsPopoverProps) {
return (
<Popover>
<PopoverTrigger asChild>
<button
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Auto Mode Settings"
data-testid="auto-mode-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-72" align="end" sideOffset={8}>
<div className="space-y-4">
<div>
<h4 className="font-medium text-sm mb-1">Auto Mode Settings</h4>
<p className="text-xs text-muted-foreground">
Configure auto mode execution and agent concurrency.
</p>
</div>
{/* Max Concurrent Agents */}
<div className="space-y-2 p-2 rounded-md bg-secondary/50">
<div className="flex items-center gap-2">
<Bot className="w-4 h-4 text-brand-500 shrink-0" />
<Label className="text-xs font-medium">Max Concurrent Agents</Label>
<span className="ml-auto text-xs text-muted-foreground">
{runningAgentsCount}/{maxConcurrency}
</span>
</div>
<div className="flex items-center gap-3">
<Slider
value={[maxConcurrency]}
onValueChange={(value) => onConcurrencyChange(value[0])}
min={1}
max={10}
step={1}
className="flex-1"
data-testid="concurrency-slider"
/>
<span className="text-xs font-medium min-w-[2ch] text-right">{maxConcurrency}</span>
</div>
<p className="text-[10px] text-muted-foreground">
Higher values process more features in parallel but use more API resources.
</p>
</div>
{/* Skip Verification Setting */}
<div className="flex items-center justify-between gap-3 p-2 rounded-md bg-secondary/50">
<div className="flex items-center gap-2 flex-1 min-w-0">
<FastForward className="w-4 h-4 text-brand-500 shrink-0" />
<Label
htmlFor="skip-verification-toggle"
className="text-xs font-medium cursor-pointer"
>
Skip verification requirement
</Label>
</div>
<Switch
id="skip-verification-toggle"
checked={skipVerificationInAutoMode}
onCheckedChange={onSkipVerificationChange}
data-testid="skip-verification-toggle"
/>
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
When enabled, auto mode will grab features even if their dependencies are not verified,
as long as they are not currently running.
</p>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -419,7 +419,7 @@ export function BacklogPlanDialog({
</DialogDescription>
</DialogHeader>
<div className="py-4">{renderContent()}</div>
<div className="py-4 overflow-y-auto">{renderContent()}</div>
<DialogFooter>
{mode === 'input' && (

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -10,9 +10,10 @@ import {
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { GitCommit, Loader2 } from 'lucide-react';
import { GitCommit, Loader2, Sparkles } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
interface WorktreeInfo {
path: string;
@@ -37,7 +38,9 @@ export function CommitWorktreeDialog({
}: CommitWorktreeDialogProps) {
const [message, setMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages);
const handleCommit = async () => {
if (!worktree || !message.trim()) return;
@@ -77,11 +80,68 @@ export function CommitWorktreeDialog({
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && e.metaKey && !isLoading && message.trim()) {
// Prevent commit while loading or while AI is generating a message
if (e.key === 'Enter' && e.metaKey && !isLoading && !isGenerating && message.trim()) {
handleCommit();
}
};
// Generate AI commit message when dialog opens (if enabled)
useEffect(() => {
if (open && worktree) {
// Reset state
setMessage('');
setError(null);
// Only generate AI commit message if enabled
if (!enableAiCommitMessages) {
return;
}
setIsGenerating(true);
let cancelled = false;
const generateMessage = async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.generateCommitMessage) {
if (!cancelled) {
setIsGenerating(false);
}
return;
}
const result = await api.worktree.generateCommitMessage(worktree.path);
if (cancelled) return;
if (result.success && result.message) {
setMessage(result.message);
} else {
// Don't show error toast, just log it and leave message empty
console.warn('Failed to generate commit message:', result.error);
setMessage('');
}
} catch (err) {
if (cancelled) return;
// Don't show error toast for generation failures
console.warn('Error generating commit message:', err);
setMessage('');
} finally {
if (!cancelled) {
setIsGenerating(false);
}
}
};
generateMessage();
return () => {
cancelled = true;
};
}
}, [open, worktree, enableAiCommitMessages]);
if (!worktree) return null;
return (
@@ -106,10 +166,20 @@ export function CommitWorktreeDialog({
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="commit-message">Commit Message</Label>
<Label htmlFor="commit-message" className="flex items-center gap-2">
Commit Message
{isGenerating && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Sparkles className="w-3 h-3 animate-pulse" />
Generating...
</span>
)}
</Label>
<Textarea
id="commit-message"
placeholder="Describe your changes..."
placeholder={
isGenerating ? 'Generating commit message...' : 'Describe your changes...'
}
value={message}
onChange={(e) => {
setMessage(e.target.value);
@@ -118,6 +188,7 @@ export function CommitWorktreeDialog({
onKeyDown={handleKeyDown}
className="min-h-[100px] font-mono text-sm"
autoFocus
disabled={isGenerating}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
@@ -128,10 +199,14 @@ export function CommitWorktreeDialog({
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading || isGenerating}
>
Cancel
</Button>
<Button onClick={handleCommit} disabled={isLoading || !message.trim()}>
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Dialog,
DialogContent,
@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import { GitPullRequest, Loader2, ExternalLink } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -30,6 +31,8 @@ interface CreatePRDialogProps {
worktree: WorktreeInfo | null;
projectPath: string | null;
onCreated: (prUrl?: string) => void;
/** Default base branch for the PR (defaults to 'main' if not provided) */
defaultBaseBranch?: string;
}
export function CreatePRDialog({
@@ -38,10 +41,11 @@ export function CreatePRDialog({
worktree,
projectPath,
onCreated,
defaultBaseBranch = 'main',
}: CreatePRDialogProps) {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [baseBranch, setBaseBranch] = useState('main');
const [baseBranch, setBaseBranch] = useState(defaultBaseBranch);
const [commitMessage, setCommitMessage] = useState('');
const [isDraft, setIsDraft] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@@ -49,40 +53,62 @@ export function CreatePRDialog({
const [prUrl, setPrUrl] = useState<string | null>(null);
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
// Branch fetching state
const [branches, setBranches] = useState<string[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
// Track whether an operation completed that warrants a refresh
const operationCompletedRef = useRef(false);
// Common state reset function to avoid duplication
const resetState = useCallback(() => {
setTitle('');
setBody('');
setCommitMessage('');
setBaseBranch(defaultBaseBranch);
setIsDraft(false);
setError(null);
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
operationCompletedRef.current = false;
setBranches([]);
}, [defaultBaseBranch]);
// Fetch branches for autocomplete
const fetchBranches = useCallback(async () => {
if (!worktree?.path) return;
setIsLoadingBranches(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
return;
}
// Fetch both local and remote branches for PR base branch selection
const result = await api.worktree.listBranches(worktree.path, true);
if (result.success && result.result) {
// Extract branch names, filtering out the current worktree branch
const branchNames = result.result.branches
.map((b) => b.name)
.filter((name) => name !== worktree.branch);
setBranches(branchNames);
}
} catch {
// Silently fail - branches will default to main only
} finally {
setIsLoadingBranches(false);
}
}, [worktree?.path, worktree?.branch]);
// Reset state when dialog opens or worktree changes
useEffect(() => {
// Reset all state on both open and close
resetState();
if (open) {
// Reset form fields
setTitle('');
setBody('');
setCommitMessage('');
setBaseBranch('main');
setIsDraft(false);
setError(null);
// Also reset result states when opening for a new worktree
// This prevents showing stale PR URLs from previous worktrees
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
// Reset operation tracking
operationCompletedRef.current = false;
} else {
// Reset everything when dialog closes
setTitle('');
setBody('');
setCommitMessage('');
setBaseBranch('main');
setIsDraft(false);
setError(null);
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
operationCompletedRef.current = false;
// Fetch fresh branches when dialog opens
fetchBranches();
}
}, [open, worktree?.path]);
}, [open, worktree?.path, resetState, fetchBranches]);
const handleCreate = async () => {
if (!worktree) return;
@@ -117,7 +143,7 @@ export function CreatePRDialog({
description: `PR already exists for ${result.result.branch}`,
action: {
label: 'View PR',
onClick: () => window.open(result.result!.prUrl!, '_blank'),
onClick: () => window.open(result.result!.prUrl!, '_blank', 'noopener,noreferrer'),
},
});
} else {
@@ -125,7 +151,7 @@ export function CreatePRDialog({
description: `PR created from ${result.result.branch}`,
action: {
label: 'View PR',
onClick: () => window.open(result.result!.prUrl!, '_blank'),
onClick: () => window.open(result.result!.prUrl!, '_blank', 'noopener,noreferrer'),
},
});
}
@@ -251,7 +277,10 @@ export function CreatePRDialog({
<p className="text-sm text-muted-foreground mt-1">Your PR is ready for review</p>
</div>
<div className="flex gap-2 justify-center">
<Button onClick={() => window.open(prUrl, '_blank')} className="gap-2">
<Button
onClick={() => window.open(prUrl, '_blank', 'noopener,noreferrer')}
className="gap-2"
>
<ExternalLink className="w-4 h-4" />
View Pull Request
</Button>
@@ -277,7 +306,7 @@ export function CreatePRDialog({
<Button
onClick={() => {
if (browserUrl) {
window.open(browserUrl, '_blank');
window.open(browserUrl, '_blank', 'noopener,noreferrer');
}
}}
className="gap-2 w-full"
@@ -340,15 +369,16 @@ export function CreatePRDialog({
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="base-branch">Base Branch</Label>
<Input
id="base-branch"
placeholder="main"
<BranchAutocomplete
value={baseBranch}
onChange={(e) => setBaseBranch(e.target.value)}
className="font-mono text-sm"
onChange={setBaseBranch}
branches={branches}
placeholder="Select base branch..."
disabled={isLoadingBranches}
data-testid="base-branch-autocomplete"
/>
</div>
<div className="flex items-end">

View File

@@ -21,7 +21,8 @@ import {
FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import { GitBranch, Cpu, FolderKanban } from 'lucide-react';
import { GitBranch, Cpu, FolderKanban, Settings2 } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import { toast } from 'sonner';
import { cn, modelSupportsThinking } from '@/lib/utils';
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
@@ -86,6 +87,7 @@ export function EditFeatureDialog({
isMaximized,
allFeatures,
}: EditFeatureDialogProps) {
const navigate = useNavigate();
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
// Derive initial workMode from feature's branchName
const [workMode, setWorkMode] = useState<WorkMode>(() => {
@@ -363,9 +365,31 @@ export function EditFeatureDialog({
{/* AI & Execution Section */}
<div className={cardClass}>
<div className={sectionHeaderClass}>
<Cpu className="w-4 h-4 text-muted-foreground" />
<span>AI & Execution</span>
<div className="flex items-center justify-between">
<div className={sectionHeaderClass}>
<Cpu className="w-4 h-4 text-muted-foreground" />
<span>AI & Execution</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
onClose();
navigate({ to: '/settings', search: { view: 'defaults' } });
}}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Settings2 className="w-3.5 h-3.5" />
<span>Edit Defaults</span>
</button>
</TooltipTrigger>
<TooltipContent>
<p>Change default model and planning settings for new features</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="space-y-1.5">

View File

@@ -13,7 +13,8 @@ import { Label } from '@/components/ui/label';
import { AlertCircle } from 'lucide-react';
import { modelSupportsThinking } from '@/lib/utils';
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
import { TestingTabContent, PrioritySelect, PlanningModeSelect } from '../shared';
import { TestingTabContent, PrioritySelect, PlanningModeSelect, WorkModeSelector } from '../shared';
import type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
import { cn } from '@/lib/utils';
@@ -23,7 +24,10 @@ interface MassEditDialogProps {
open: boolean;
onClose: () => void;
selectedFeatures: Feature[];
onApply: (updates: Partial<Feature>) => Promise<void>;
onApply: (updates: Partial<Feature>, workMode: WorkMode) => Promise<void>;
branchSuggestions: string[];
branchCardCounts?: Record<string, number>;
currentBranch?: string;
}
interface ApplyState {
@@ -33,6 +37,7 @@ interface ApplyState {
requirePlanApproval: boolean;
priority: boolean;
skipTests: boolean;
branchName: boolean;
}
function getMixedValues(features: Feature[]): Record<string, boolean> {
@@ -47,6 +52,7 @@ function getMixedValues(features: Feature[]): Record<string, boolean> {
),
priority: !features.every((f) => f.priority === first.priority),
skipTests: !features.every((f) => f.skipTests === first.skipTests),
branchName: !features.every((f) => f.branchName === first.branchName),
};
}
@@ -97,7 +103,15 @@ function FieldWrapper({ label, isMixed, willApply, onApplyChange, children }: Fi
);
}
export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: MassEditDialogProps) {
export function MassEditDialog({
open,
onClose,
selectedFeatures,
onApply,
branchSuggestions,
branchCardCounts,
currentBranch,
}: MassEditDialogProps) {
const [isApplying, setIsApplying] = useState(false);
// Track which fields to apply
@@ -108,6 +122,7 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
requirePlanApproval: false,
priority: false,
skipTests: false,
branchName: false,
});
// Field values
@@ -118,6 +133,18 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
const [priority, setPriority] = useState(2);
const [skipTests, setSkipTests] = useState(false);
// Work mode and branch name state
const [workMode, setWorkMode] = useState<WorkMode>(() => {
// Derive initial work mode from first selected feature's branchName
if (selectedFeatures.length > 0 && selectedFeatures[0].branchName) {
return 'custom';
}
return 'current';
});
const [branchName, setBranchName] = useState(() => {
return getInitialValue(selectedFeatures, 'branchName', '') as string;
});
// Calculate mixed values
const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]);
@@ -131,6 +158,7 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
requirePlanApproval: false,
priority: false,
skipTests: false,
branchName: false,
});
setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias);
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
@@ -138,6 +166,10 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
setPriority(getInitialValue(selectedFeatures, 'priority', 2));
setSkipTests(getInitialValue(selectedFeatures, 'skipTests', false));
// Reset work mode and branch name
const initialBranchName = getInitialValue(selectedFeatures, 'branchName', '') as string;
setBranchName(initialBranchName);
setWorkMode(initialBranchName ? 'custom' : 'current');
}
}, [open, selectedFeatures]);
@@ -150,6 +182,12 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
if (applyState.requirePlanApproval) updates.requirePlanApproval = requirePlanApproval;
if (applyState.priority) updates.priority = priority;
if (applyState.skipTests) updates.skipTests = skipTests;
if (applyState.branchName) {
// For 'current' mode, use empty string (work on current branch)
// For 'auto' mode, use empty string (will be auto-generated)
// For 'custom' mode, use the specified branch name
updates.branchName = workMode === 'custom' ? branchName : '';
}
if (Object.keys(updates).length === 0) {
onClose();
@@ -158,7 +196,7 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
setIsApplying(true);
try {
await onApply(updates);
await onApply(updates, workMode);
onClose();
} finally {
setIsApplying(false);
@@ -293,6 +331,25 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
testIdPrefix="mass-edit"
/>
</FieldWrapper>
{/* Branch / Work Mode */}
<FieldWrapper
label="Branch / Work Mode"
isMixed={mixedValues.branchName}
willApply={applyState.branchName}
onApplyChange={(apply) => setApplyState((prev) => ({ ...prev, branchName: apply }))}
>
<WorkModeSelector
workMode={workMode}
onWorkModeChange={setWorkMode}
branchName={branchName}
onBranchNameChange={setBranchName}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
testIdPrefix="mass-edit-work-mode"
/>
</FieldWrapper>
</div>
<DialogFooter>

View File

@@ -0,0 +1,234 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Loader2, GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface MergeWorktreeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectPath: string;
worktree: WorktreeInfo | null;
onMerged: (mergedWorktree: WorktreeInfo) => void;
/** Number of features assigned to this worktree's branch */
affectedFeatureCount?: number;
}
type DialogStep = 'confirm' | 'verify';
export function MergeWorktreeDialog({
open,
onOpenChange,
projectPath,
worktree,
onMerged,
affectedFeatureCount = 0,
}: MergeWorktreeDialogProps) {
const [isLoading, setIsLoading] = useState(false);
const [step, setStep] = useState<DialogStep>('confirm');
const [confirmText, setConfirmText] = useState('');
// Reset state when dialog opens
useEffect(() => {
if (open) {
setIsLoading(false);
setStep('confirm');
setConfirmText('');
}
}, [open]);
const handleProceedToVerify = () => {
setStep('verify');
};
const handleMerge = async () => {
if (!worktree) return;
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.mergeFeature) {
toast.error('Worktree API not available');
return;
}
// Pass branchName and worktreePath directly to the API
const result = await api.worktree.mergeFeature(projectPath, worktree.branch, worktree.path);
if (result.success) {
toast.success('Branch merged to main', {
description: `Branch "${worktree.branch}" has been merged and cleaned up`,
});
onMerged(worktree);
onOpenChange(false);
} else {
toast.error('Failed to merge branch', {
description: result.error,
});
}
} catch (err) {
toast.error('Failed to merge branch', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setIsLoading(false);
}
};
if (!worktree) return null;
const confirmationWord = 'merge';
const isConfirmValid = confirmText.toLowerCase() === confirmationWord;
// First step: Show what will happen and ask for confirmation
if (step === 'confirm') {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-green-600" />
Merge to Main
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<span className="block">
Merge branch{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> into
main?
</span>
<div className="text-sm text-muted-foreground mt-2">
This will:
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Merge the branch into the main branch</li>
<li>Remove the worktree directory</li>
<li>Delete the branch</li>
</ul>
</div>
{worktree.hasChanges && (
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
<span className="text-yellow-500 text-sm">
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
commit or discard them before merging.
</span>
</div>
)}
{affectedFeatureCount > 0 && (
<div className="flex items-start gap-2 p-3 rounded-md bg-blue-500/10 border border-blue-500/20 mt-2">
<AlertTriangle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
<span className="text-blue-500 text-sm">
{affectedFeatureCount} feature{affectedFeatureCount !== 1 ? 's' : ''}{' '}
{affectedFeatureCount !== 1 ? 'are' : 'is'} assigned to this branch and will
be unassigned after merge.
</span>
</div>
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleProceedToVerify}
disabled={worktree.hasChanges}
className="bg-green-600 hover:bg-green-700 text-white"
>
<GitMerge className="w-4 h-4 mr-2" />
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Second step: Type confirmation
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
Confirm Merge
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-4">
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="text-orange-600 dark:text-orange-400 text-sm">
This action cannot be undone. The branch{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> will be
permanently deleted after merging.
</span>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-merge" className="text-sm text-foreground">
Type <span className="font-bold text-foreground">{confirmationWord}</span> to
confirm:
</Label>
<Input
id="confirm-merge"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={confirmationWord}
disabled={isLoading}
className="font-mono"
autoComplete="off"
/>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setStep('confirm')} disabled={isLoading}>
Back
</Button>
<Button
onClick={handleMerge}
disabled={isLoading || !isConfirmValid}
className="bg-green-600 hover:bg-green-700 text-white"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Merging...
</>
) : (
<>
<CheckCircle2 className="w-4 h-4 mr-2" />
Merge to Main
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,67 +0,0 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { GitBranch, Settings2 } from 'lucide-react';
interface PlanSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
planUseSelectedWorktreeBranch: boolean;
onPlanUseSelectedWorktreeBranchChange: (value: boolean) => void;
}
export function PlanSettingsDialog({
open,
onOpenChange,
planUseSelectedWorktreeBranch,
onPlanUseSelectedWorktreeBranchChange,
}: PlanSettingsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="plan-settings-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="w-5 h-5" />
Plan Settings
</DialogTitle>
<DialogDescription>
Configure how the Plan feature creates and organizes new features.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Use Selected Worktree Branch Setting */}
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<Label
htmlFor="plan-worktree-branch-toggle"
className="text-sm font-medium cursor-pointer flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Use selected worktree branch
</Label>
<Switch
id="plan-worktree-branch-toggle"
checked={planUseSelectedWorktreeBranch}
onCheckedChange={onPlanUseSelectedWorktreeBranchChange}
data-testid="plan-worktree-branch-toggle"
/>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, features created via the Plan dialog will be assigned to the currently
selected worktree branch. When disabled, features will be added to the main branch.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,61 @@
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { GitBranch, Settings2 } from 'lucide-react';
interface PlanSettingsPopoverProps {
planUseSelectedWorktreeBranch: boolean;
onPlanUseSelectedWorktreeBranchChange: (value: boolean) => void;
}
export function PlanSettingsPopover({
planUseSelectedWorktreeBranch,
onPlanUseSelectedWorktreeBranchChange,
}: PlanSettingsPopoverProps) {
return (
<Popover>
<PopoverTrigger asChild>
<button
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Plan Settings"
data-testid="plan-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-72" align="end" sideOffset={8}>
<div className="space-y-3">
<div>
<h4 className="font-medium text-sm mb-1">Plan Settings</h4>
<p className="text-xs text-muted-foreground">
Configure how Plan creates and organizes features.
</p>
</div>
<div className="flex items-center justify-between gap-3 p-2 rounded-md bg-secondary/50">
<div className="flex items-center gap-2 flex-1 min-w-0">
<GitBranch className="w-4 h-4 text-brand-500 shrink-0" />
<Label
htmlFor="plan-worktree-branch-toggle"
className="text-xs font-medium cursor-pointer"
>
Default to worktree mode
</Label>
</div>
<Switch
id="plan-worktree-branch-toggle"
checked={planUseSelectedWorktreeBranch}
onCheckedChange={onPlanUseSelectedWorktreeBranchChange}
data-testid="plan-worktree-branch-toggle"
/>
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
Planned features will automatically use isolated worktrees, keeping changes separate
from your main branch until you're ready to merge.
</p>
</div>
</PopoverContent>
</Popover>
);
}

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