Compare commits

..

2 Commits

Author SHA1 Message Date
Kacper
d4ce1f331b feat: add onboarding wizard components and logic
- Introduced a new onboarding wizard for guiding users through the board features.
- Added shared onboarding components including OnboardingWizard, useOnboardingWizard, and related types and constants.
- Implemented onboarding logic in the BoardView, integrating sample feature generation for a quick start.
- Updated UI components to support onboarding interactions and improve user experience.
- Enhanced onboarding state management with localStorage persistence for user progress tracking.
2026-01-11 16:05:04 +01:00
Shirone
66b68ea4eb feat: implement onboarding wizard for board view
- Added a new onboarding wizard to guide users through the board features.
- Integrated sample feature generation for quick start onboarding.
- Enhanced BoardView component to manage onboarding state and actions.
- Updated BoardControls and BoardHeader to include tour functionality.
- Introduced utility functions for sample feature management in constants.
- Improved user experience with toast notifications for onboarding actions.
2026-01-11 12:49:29 +01:00
252 changed files with 5473 additions and 19846 deletions

View File

@@ -1,108 +0,0 @@
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

4
.gitignore vendored
View File

@@ -90,8 +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,12 +31,7 @@ fi
# Ensure common system paths are in PATH (for systems without nvm)
# This helps find node/npm installed via Homebrew, system packages, etc.
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
export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin"
# Run lint-staged - works with or without nvm
# Prefer npx, fallback to npm exec, both work with system-installed Node.js

View File

@@ -24,7 +24,6 @@ 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)
@@ -187,59 +186,6 @@ 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:
@@ -329,14 +275,14 @@ Follow these steps to submit your contribution:
#### 1. Prepare Your Changes
Ensure you've synced with the latest upstream changes from the RC branch:
Ensure you've synced with the latest upstream changes:
```bash
# Fetch latest changes from upstream
git fetch upstream
# Rebase your branch on the current RC branch (if needed)
git rebase upstream/v0.11.0rc # Use the current RC branch name
# Rebase your branch on main (if needed)
git rebase upstream/main
```
#### 2. Run Pre-submission Checks
@@ -368,19 +314,18 @@ git push origin feature/your-feature-name
1. Go to your fork on GitHub
2. Click "Compare & pull request" for your branch
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`
3. Ensure the base repository is `AutoMaker-Org/automaker` and base branch is `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 the RC branch
- [ ] **No merge conflicts** with main branch
- [ ] **Tests included** for new functionality
- [ ] **Documentation updated** if adding/changing public APIs

View File

@@ -1,253 +0,0 @@
# 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,22 +59,9 @@ 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 \
@@ -92,10 +79,8 @@ 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
# 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 && \
RUN groupadd -g 1001 automaker && \
useradd -u 1001 -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 && \
@@ -110,12 +95,6 @@ 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,17 +8,9 @@
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 \
@@ -35,15 +27,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Install Claude CLI globally
RUN npm install -g @anthropic-ai/claude-code
# 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 && \
# Create non-root user
RUN groupadd -g 1001 automaker && \
useradd -u 1001 -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

@@ -1,6 +1,6 @@
{
"name": "@automaker/server",
"version": "0.11.0",
"version": "0.9.0",
"description": "Backend server for Automaker - provides API for both web and Electron modes",
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",

View File

@@ -67,7 +67,6 @@ 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();
@@ -177,10 +176,6 @@ 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();
@@ -222,7 +217,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, settingsService));
app.use('/api/worktree', createWorktreeRoutes());
app.use('/api/git', createGitRoutes());
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
app.use('/api/models', createModelsRoutes());

View File

@@ -129,30 +129,10 @@ 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'] 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'] as const,
} as const;
/**

View File

@@ -21,12 +21,6 @@ export interface WorktreeMetadata {
branch: string;
createdAt: string;
pr?: WorktreePRInfo;
/** Whether the init script has been executed for this worktree */
initScriptRan?: boolean;
/** Status of the init script execution */
initScriptStatus?: 'running' | 'success' | 'failed';
/** Error message if init script failed */
initScriptError?: string;
}
/**

View File

@@ -8,28 +8,12 @@ import type { Request, Response, NextFunction } from 'express';
import { validatePath, PathNotAllowedError } from '@automaker/platform';
/**
* 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
* Creates a middleware that validates specified path parameters in req.body
* @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)
@@ -42,8 +26,8 @@ export function validatePathParams(...paramNames: string[]) {
// Handle optional parameters (paramName?)
if (paramName.endsWith('?')) {
const actualName = paramName.slice(0, -1);
const value = getParamValue(req, actualName);
if (value && typeof value === 'string') {
const value = req.body[actualName];
if (value) {
validatePath(value);
}
continue;
@@ -52,20 +36,18 @@ export function validatePathParams(...paramNames: string[]) {
// Handle array parameters (paramName[])
if (paramName.endsWith('[]')) {
const actualName = paramName.slice(0, -2);
const values = getParamValue(req, actualName);
const values = req.body[actualName];
if (Array.isArray(values) && values.length > 0) {
for (const value of values) {
if (typeof value === 'string') {
validatePath(value);
}
validatePath(value);
}
}
continue;
}
// Handle regular parameters
const value = getParamValue(req, paramName);
if (value && typeof value === 'string') {
const value = req.body[paramName];
if (value) {
validatePath(value);
}
}

View File

@@ -22,8 +22,6 @@ 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',
@@ -101,8 +99,6 @@ 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 {
createWslCommand,
findCliInWsl,
isWslAvailable,
spawnJSONLProcess,
windowsToWslPath,
type SubprocessOptions,
isWslAvailable,
findCliInWsl,
createWslCommand,
windowsToWslPath,
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,13 +522,8 @@ export abstract class CliProvider extends BaseProvider {
throw new Error(`${this.getCliName()} CLI not found. ${this.getInstallInstructions()}`);
}
// 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);
const cliArgs = this.buildCliArgs(options);
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
try {
for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
@@ -560,52 +555,4 @@ 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,7 +45,6 @@ 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';
@@ -143,7 +142,6 @@ type CodexExecutionMode = typeof CODEX_EXECUTION_MODE_CLI | typeof CODEX_EXECUTI
type CodexExecutionPlan = {
mode: CodexExecutionMode;
cliPath: string | null;
openAiApiKey?: string | null;
};
const ALLOWED_ENV_VARS = [
@@ -168,22 +166,6 @@ 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);
}
@@ -199,21 +181,18 @@ function isSdkEligible(options: ExecuteOptions): boolean {
async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<CodexExecutionPlan> {
const cliPath = await findCodexCliPath();
const authIndicators = await getCodexAuthIndicators();
const openAiApiKey = await resolveOpenAiApiKey();
const hasApiKey = Boolean(openAiApiKey);
const hasApiKey = Boolean(process.env[OPENAI_API_KEY_ENV]);
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);
}
@@ -230,7 +209,6 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<Codex
return {
mode: CODEX_EXECUTION_MODE_CLI,
cliPath,
openAiApiKey,
};
}
@@ -723,14 +701,7 @@ export class CodexProvider extends BaseProvider {
const executionPlan = await resolveCodexExecutionPlan(options);
if (executionPlan.mode === CODEX_EXECUTION_MODE_SDK) {
const cleanupEnv = executionPlan.openAiApiKey
? createTempEnvOverride({ [OPENAI_API_KEY_ENV]: executionPlan.openAiApiKey })
: null;
try {
yield* executeCodexSdkQuery(options, combinedSystemPrompt);
} finally {
cleanupEnv?.();
}
yield* executeCodexSdkQuery(options, combinedSystemPrompt);
return;
}
@@ -809,16 +780,11 @@ 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: envOverrides,
env: buildEnv(),
abortController: options.abortController,
timeout: DEFAULT_TIMEOUT_MS,
stdinData: promptText, // Pass prompt via stdin
@@ -1005,7 +971,7 @@ export class CodexProvider extends BaseProvider {
async detectInstallation(): Promise<InstallationStatus> {
const cliPath = await findCodexCliPath();
const hasApiKey = Boolean(await resolveOpenAiApiKey());
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
const authIndicators = await getCodexAuthIndicators();
const installed = !!cliPath;
@@ -1047,7 +1013,7 @@ export class CodexProvider extends BaseProvider {
*/
async checkAuth(): Promise<CodexAuthStatus> {
const cliPath = await findCodexCliPath();
const hasApiKey = Boolean(await resolveOpenAiApiKey());
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
const authIndicators = await getCodexAuthIndicators();
// Check for API key in environment

View File

@@ -30,11 +30,3 @@ 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';

File diff suppressed because it is too large Load Diff

View File

@@ -1,254 +0,0 @@
/**
* 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,12 +5,15 @@
* (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 } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { logAuthStatus } from './common.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
@@ -112,30 +115,121 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
logger.info('Using model:', model);
// 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,
});
},
});
let responseText = '';
let messageCount = 0;
const responseText = result.text;
// Route to appropriate provider based on model type
if (isCursorModel(model)) {
// Use Cursor provider for Cursor models
logger.info('[FeatureGeneration] Using Cursor provider');
logger.info(`Feature stream complete.`);
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 response length: ${responseText.length} chars`);
logger.info('========== FULL RESPONSE TEXT ==========');
logger.info(responseText);

View File

@@ -5,6 +5,8 @@
* (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 {
@@ -14,10 +16,12 @@ import {
type SpecOutput,
} from '../../lib/app-spec-format.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
import { extractJson } from '../../lib/json-extractor.js';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { logAuthStatus } from './common.js';
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
@@ -105,15 +109,21 @@ ${getStructuredSpecPromptInstruction()}`;
logger.info('Using model:', model);
let responseText = '';
let messageCount = 0;
let structuredOutput: SpecOutput | null = null;
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
const useStructuredOutput = !isCursorModel(model);
// Route to appropriate provider based on model type
if (isCursorModel(model)) {
// Use Cursor provider for Cursor models
logger.info('[SpecGeneration] Using Cursor provider');
// Build the final prompt - for Cursor, include JSON schema instructions
let finalPrompt = prompt;
if (!useStructuredOutput) {
finalPrompt = `${prompt}
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}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. DO NOT create any files like "project_specification.json".
@@ -123,57 +133,153 @@ CRITICAL INSTRUCTIONS:
${JSON.stringify(specOutputSchema, null, 2)}
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
}
// 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,
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,
});
}
}
: 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,
});
},
});
} 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;
}
}
}
// 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 });
// 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.`);
logger.info(`Stream iteration complete. Total messages: ${messageCount}`);
logger.info(`Response text length: ${responseText.length} chars`);
// Determine XML content to save

View File

@@ -12,22 +12,11 @@ const featureLoader = new FeatureLoader();
export function createApplyHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const {
projectPath,
plan,
branchName: rawBranchName,
} = req.body as {
const { projectPath, plan } = req.body as {
projectPath: string;
plan: BacklogPlanResult;
branchName?: string;
};
// Validate branchName: must be undefined or a non-empty trimmed string
const branchName =
typeof rawBranchName === 'string' && rawBranchName.trim().length > 0
? rawBranchName.trim()
: undefined;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
@@ -93,7 +82,6 @@ export function createApplyHandler() {
dependencies: change.feature.dependencies,
priority: change.feature.priority,
status: 'backlog',
branchName,
});
appliedChanges.push(`added:${newFeature.id}`);

View File

@@ -34,13 +34,6 @@ 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,11 +11,13 @@
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { PathNotAllowedError } from '@automaker/platform';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import { createCustomOptions } from '../../../lib/sdk-options.js';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import * as secureFs from '../../../lib/secure-fs.js';
import * as path from 'path';
import type { SettingsService } from '../../../services/settings-service.js';
@@ -47,6 +49,31 @@ 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
*
@@ -132,14 +159,16 @@ export function createDescribeFileHandler(
// Build prompt with file content passed as structured data
// The file content is included directly, not via tool invocation
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").
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").
Respond with ONLY the description text, no additional formatting, preamble, or explanation.
File: ${fileName}${truncated ? ' (truncated)' : ''}
File: ${fileName}${truncated ? ' (truncated)' : ''}`;
--- FILE CONTENT ---
${contentToAnalyze}`;
const promptContent = [
{ type: 'text' as const, text: instructionText },
{ type: 'text' as const, text: `\n\n--- FILE CONTENT ---\n${contentToAnalyze}` },
];
// Use the file's directory as the working directory
const cwd = path.dirname(resolvedPath);
@@ -161,19 +190,67 @@ ${contentToAnalyze}`;
logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`);
// 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,
});
let description: string;
const description = result.text;
// 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);
}
if (!description || description.trim().length === 0) {
logger.warn('Received empty response from Claude');

View File

@@ -12,10 +12,12 @@
*/
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 } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import { createCustomOptions } from '../../../lib/sdk-options.js';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import * as secureFs from '../../../lib/secure-fs.js';
import * as path from 'path';
import type { SettingsService } from '../../../services/settings-service.js';
@@ -176,10 +178,57 @@ 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 the provider abstraction with multi-part content blocks to include the image (base64),
* Uses Claude SDK query with multi-part content blocks to include the image (base64),
* matching the agent runner behavior.
*
* @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
@@ -260,6 +309,27 @@ 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}`);
@@ -278,59 +348,85 @@ export function createDescribeImageHandler(
logger.info(`[${requestId}] Using model: ${model}`);
// 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 }>;
let description: string;
// Route to appropriate provider based on model type
if (isCursorModel(model)) {
// 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}`;
// 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;
} else {
// 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,
},
},
];
// 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}`);
}
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 AI`);
logger.warn(`[${requestId}] Received empty response from Claude`);
const response: DescribeImageErrorResponse = {
success: false,
error: 'Failed to generate description - empty response',

View File

@@ -1,16 +1,22 @@
/**
* POST /enhance-prompt endpoint - Enhance user input text
*
* 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
* Uses Claude AI or Cursor to enhance text based on the specified enhancement mode.
* Supports modes: improve, technical, simplify, acceptance
*/
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, type ThinkingLevel } from '@automaker/types';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import {
CLAUDE_MODEL_MAP,
isCursorModel,
stripProviderPrefix,
ThinkingLevel,
getThinkingTokenBudget,
} from '@automaker/types';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
import {
@@ -31,7 +37,7 @@ interface EnhanceRequestBody {
enhancementMode: string;
/** Optional model override */
model?: string;
/** Optional thinking level for Claude models */
/** Optional thinking level for Claude models (ignored for Cursor models) */
thinkingLevel?: ThinkingLevel;
}
@@ -51,6 +57,76 @@ 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 Cursor provider
*
* @param prompt - The enhancement prompt
* @param model - The Cursor model to use
* @returns The enhanced text
*/
async function executeWithCursor(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 === '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
*
@@ -119,6 +195,7 @@ 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
@@ -126,20 +203,40 @@ export function createEnhanceHandler(
logger.debug(`Using model: ${resolvedModel}`);
// 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
});
let enhancedText: string;
const enhancedText = result.text;
// 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 executeWithCursor(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);
}
if (!enhancedText || enhancedText.trim().length === 0) {
logger.warn('Received empty response from AI');

View File

@@ -1,14 +1,13 @@
/**
* POST /features/generate-title endpoint - Generate a concise title from description
*
* Uses the provider abstraction to generate a short, descriptive title
* from a feature description. Works with any configured provider (Claude, Cursor, etc.).
* Uses Claude Haiku to generate a short, descriptive title from feature description.
*/
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');
@@ -35,6 +34,33 @@ 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 {
@@ -63,19 +89,21 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
// 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 stream = query({
prompt: userPrompt,
options: {
model: CLAUDE_MODEL_MAP.haiku,
systemPrompt: SYSTEM_PROMPT,
maxTurns: 1,
allowedTools: [],
permissionMode: 'default',
},
});
const title = result.text;
const title = await extractTextFromStream(stream);
if (!title || title.trim().length === 0) {
logger.warn('Received empty response from AI');
logger.warn('Received empty response from Claude');
const response: GenerateTitleErrorResponse = {
success: false,
error: 'Failed to generate title - empty response',

View File

@@ -10,21 +10,14 @@ import { getErrorMessage, logError } from '../common.js';
export function createUpdateHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
try {
const {
projectPath,
featureId,
updates,
descriptionHistorySource,
enhancementMode,
preEnhancementDescription,
} = req.body as {
projectPath: string;
featureId: string;
updates: Partial<Feature>;
descriptionHistorySource?: 'enhance' | 'edit';
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
preEnhancementDescription?: string;
};
const { projectPath, featureId, updates, descriptionHistorySource, enhancementMode } =
req.body as {
projectPath: string;
featureId: string;
updates: Partial<Feature>;
descriptionHistorySource?: 'enhance' | 'edit';
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
};
if (!projectPath || !featureId || !updates) {
res.status(400).json({
@@ -39,8 +32,7 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
featureId,
updates,
descriptionHistorySource,
enhancementMode,
preEnhancementDescription
enhancementMode
);
res.json({ success: true, feature: updated });
} catch (error) {

View File

@@ -5,43 +5,6 @@
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;
@@ -58,38 +21,19 @@ export async function checkGitHubRemote(projectPath: string): Promise<GitHubRemo
};
try {
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
}
// Get the remote URL (origin by default)
const { stdout } = await execAsync('git remote get-url origin', {
cwd: projectPath,
env: execEnv,
});
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;
}
const remoteUrl = stdout.trim();
status.remoteUrl = remoteUrl;
// Check if it's a GitHub URL
// Formats: https://github.com/owner/repo.git, git@github.com:owner/repo.git
if (!remoteUrl) {
return status;
}
const httpsMatch = remoteUrl.match(GITHUB_HTTPS_REMOTE_REGEX);
const sshMatch = remoteUrl.match(GITHUB_SSH_REMOTE_REGEX);
const httpsMatch = remoteUrl.match(/https:\/\/github\.com\/([^/]+)\/([^/.]+)/);
const sshMatch = remoteUrl.match(/git@github\.com:([^/]+)\/([^/.]+)/);
const match = httpsMatch || sshMatch;
if (match) {

View File

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

View File

@@ -9,17 +9,6 @@ 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;
@@ -80,68 +69,34 @@ async function fetchLinkedPRs(
// Build GraphQL query for batch fetching linked PRs
// We fetch up to 20 issues at a time to avoid query limits
for (let i = 0; i < issueNumbers.length; i += LINKED_PRS_BATCH_SIZE) {
const batch = issueNumbers.slice(i, i + LINKED_PRS_BATCH_SIZE);
const batchSize = 20;
for (let i = 0; i < issueNumbers.length; i += batchSize) {
const batch = issueNumbers.slice(i, i + batchSize);
const issueQueries = batch
.map(
(num, idx) => `
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
}
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
}
}
}
}
}
... 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
}
... on ConnectedEvent {
subject {
... on PullRequest {
number
title
state
url
}
}
}
@@ -258,35 +213,16 @@ 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_COMMAND,
repoFlag,
`${GH_STATE_FLAG} ${ISSUE_STATE_OPEN}`,
`${GH_JSON_FLAG} ${ISSUE_LIST_FIELDS}`,
`${GH_LIMIT_FLAG} ${OPEN_ISSUES_LIMIT}`,
]
.filter(Boolean)
.join(' '),
'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body,assignees --limit 100',
{
cwd: projectPath,
env: execEnv,
}
),
execAsync(
[
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(' '),
'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body,assignees --limit 50',
{
cwd: projectPath,
env: execEnv,

View File

@@ -6,17 +6,6 @@ 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;
@@ -68,36 +57,16 @@ 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_COMMAND,
repoFlag,
`${GH_STATE_FLAG} ${PR_STATE_OPEN}`,
`${GH_JSON_FLAG} ${PR_LIST_FIELDS}`,
`${GH_LIMIT_FLAG} ${OPEN_PRS_LIMIT}`,
]
.filter(Boolean)
.join(' '),
'gh pr list --state open --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 100',
{
cwd: projectPath,
env: execEnv,
}
),
execAsync(
[
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(' '),
'gh pr list --state merged --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 50',
{
cwd: projectPath,
env: execEnv,

View File

@@ -1,33 +1,29 @@
/**
* POST /validate-issue endpoint - Validate a GitHub issue using provider abstraction (async)
* POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK or Cursor (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 Claude, Codex, Cursor, and OpenCode models.
* Supports both Claude models and Cursor 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,
ModelId,
ModelAlias,
CursorModelId,
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 { streamingQuery } from '../../../providers/simple-query-service.js';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import {
issueValidationSchema,
ISSUE_VALIDATION_SYSTEM_PROMPT,
@@ -45,6 +41,9 @@ 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
*/
@@ -54,12 +53,10 @@ interface ValidateIssueRequestBody {
issueTitle: string;
issueBody: string;
issueLabels?: string[];
/** Model to use for validation (Claude alias or provider model ID) */
model?: ModelId;
/** Thinking level for Claude models (ignored for non-Claude models) */
/** Model to use for validation (opus, sonnet, haiku, or cursor model IDs) */
model?: ModelAlias | CursorModelId;
/** Thinking level for Claude models (ignored for Cursor 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 */
@@ -71,7 +68,7 @@ interface ValidateIssueRequestBody {
*
* Emits events for start, progress, complete, and error.
* Stores result on completion.
* Supports Claude/Codex models (structured output) and Cursor/OpenCode models (JSON parsing).
* Supports both Claude models (with structured output) and Cursor models (with JSON parsing).
*/
async function runValidation(
projectPath: string,
@@ -79,14 +76,13 @@ async function runValidation(
issueTitle: string,
issueBody: string,
issueLabels: string[] | undefined,
model: ModelId,
model: ModelAlias | CursorModelId,
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService,
comments?: ValidationComment[],
linkedPRs?: ValidationLinkedPR[],
thinkingLevel?: ThinkingLevel,
reasoningEffort?: ReasoningEffort
thinkingLevel?: ThinkingLevel
): Promise<void> {
// Emit start event
const startEvent: IssueValidationEvent = {
@@ -106,7 +102,7 @@ async function runValidation(
try {
// Build the prompt (include comments and linked PRs if provided)
const basePrompt = buildValidationPrompt(
const prompt = buildValidationPrompt(
issueNumber,
issueTitle,
issueBody,
@@ -115,15 +111,20 @@ async function runValidation(
linkedPRs
);
let validationResult: IssueValidationResult | null = null;
let responseText = '';
// Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't)
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
// 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}`);
// Build the final prompt - for Cursor, include system prompt and JSON schema instructions
let finalPrompt = basePrompt;
if (!useStructuredOutput) {
finalPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT}
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}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. Return the JSON in your response only.
@@ -134,78 +135,121 @@ ${JSON.stringify(issueValidationSchema, null, 2)}
Your entire response should be valid JSON starting with { and ending with }. No text before or after.
${basePrompt}`;
}
${prompt}`;
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[ValidateIssue]'
);
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;
// 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);
// 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;
if (!effectiveThinkingLevel) {
const settings = await settingsService?.getGlobalSettings();
const phaseModelEntry =
settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel;
const resolved = resolvePhaseModel(phaseModelEntry);
effectiveThinkingLevel = resolved.thinkingLevel;
}
if (!effectiveReasoningEffort && typeof phaseModelEntry !== 'string') {
effectiveReasoningEffort = phaseModelEntry.reasoningEffort;
// 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');
}
}
}
}
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');
@@ -255,7 +299,7 @@ ${basePrompt}`;
/**
* Creates the handler for validating GitHub issues against the codebase.
*
* Uses the provider abstraction with:
* Uses Claude SDK with:
* - Read-only tools (Read, Glob, Grep) for codebase analysis
* - JSON schema structured output for reliable parsing
* - System prompt guiding the validation process
@@ -275,7 +319,6 @@ export function createValidateIssueHandler(
issueLabels,
model = 'opus',
thinkingLevel,
reasoningEffort,
comments: rawComments,
linkedPRs: rawLinkedPRs,
} = req.body as ValidateIssueRequestBody;
@@ -323,17 +366,14 @@ export function createValidateIssueHandler(
return;
}
// Validate model parameter at runtime - accept any supported provider model
const isValidModel =
isClaudeModel(model) ||
isCursorModel(model) ||
isCodexModel(model) ||
isOpencodeModel(model);
// Validate model parameter at runtime - accept Claude models or Cursor models
const isValidClaudeModel = VALID_CLAUDE_MODELS.includes(model as ModelAlias);
const isValidCursorModel = isCursorModel(model);
if (!isValidModel) {
if (!isValidClaudeModel && !isValidCursorModel) {
res.status(400).json({
success: false,
error: 'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias).',
error: `Invalid model. Must be one of: ${VALID_CLAUDE_MODELS.join(', ')}, or a Cursor model ID`,
});
return;
}
@@ -364,8 +404,7 @@ export function createValidateIssueHandler(
settingsService,
validationComments,
validationLinkedPRs,
thinkingLevel,
reasoningEffort
thinkingLevel
)
.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, google, openai } }`
* Response: `{ "success": true, "credentials": { anthropic } }`
*/
import type { Request, Response } from 'express';

View File

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

View File

@@ -24,12 +24,6 @@ import { createDeauthCursorHandler } from './routes/deauth-cursor.js';
import { createAuthOpencodeHandler } from './routes/auth-opencode.js';
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
import {
createGetOpencodeModelsHandler,
createRefreshOpencodeModelsHandler,
createGetOpencodeProvidersHandler,
createClearOpencodeCacheHandler,
} from './routes/opencode-models.js';
import {
createGetCursorConfigHandler,
createSetCursorDefaultModelHandler,
@@ -71,12 +65,6 @@ export function createSetupRoutes(): Router {
router.get('/opencode-status', createOpencodeStatusHandler());
router.post('/auth-opencode', createAuthOpencodeHandler());
router.post('/deauth-opencode', createDeauthOpencodeHandler());
// OpenCode Dynamic Model Discovery routes
router.get('/opencode/models', createGetOpencodeModelsHandler());
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());
router.get('/opencode/providers', createGetOpencodeProvidersHandler());
router.post('/opencode/cache/clear', createClearOpencodeCacheHandler());
router.get('/cursor-config', createGetCursorConfigHandler());
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
router.post('/cursor-config/models', createSetCursorModelsHandler());

View File

@@ -11,7 +11,6 @@ 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

@@ -1,189 +0,0 @@
/**
* OpenCode Dynamic Models API Routes
*
* Provides endpoints for:
* - GET /api/setup/opencode/models - Get available models (cached or refreshed)
* - POST /api/setup/opencode/models/refresh - Force refresh models from CLI
* - GET /api/setup/opencode/providers - Get authenticated providers
*/
import type { Request, Response } from 'express';
import {
OpencodeProvider,
type OpenCodeProviderInfo,
} from '../../../providers/opencode-provider.js';
import { getErrorMessage, logError } from '../common.js';
import type { ModelDefinition } from '@automaker/types';
import { createLogger } from '@automaker/utils';
const logger = createLogger('OpenCodeModelsRoute');
// Singleton provider instance for caching
let providerInstance: OpencodeProvider | null = null;
function getProvider(): OpencodeProvider {
if (!providerInstance) {
providerInstance = new OpencodeProvider();
}
return providerInstance;
}
/**
* Response type for models endpoint
*/
interface ModelsResponse {
success: boolean;
models?: ModelDefinition[];
count?: number;
cached?: boolean;
error?: string;
}
/**
* Response type for providers endpoint
*/
interface ProvidersResponse {
success: boolean;
providers?: OpenCodeProviderInfo[];
authenticated?: OpenCodeProviderInfo[];
error?: string;
}
/**
* Creates handler for GET /api/setup/opencode/models
*
* Returns currently available models (from cache if available).
* Query params:
* - refresh=true: Force refresh from CLI before returning
*
* Note: If cache is empty, this will trigger a refresh to get dynamic models.
*/
export function createGetOpencodeModelsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const forceRefresh = req.query.refresh === 'true';
let models: ModelDefinition[];
let cached = true;
if (forceRefresh) {
models = await provider.refreshModels();
cached = false;
} else {
// Check if we have cached models
const cachedModels = provider.getAvailableModels();
// If cache only has default models (provider.hasCachedModels() would be false),
// trigger a refresh to get dynamic models
if (!provider.hasCachedModels()) {
models = await provider.refreshModels();
cached = false;
} else {
models = cachedModels;
}
}
const response: ModelsResponse = {
success: true,
models,
count: models.length,
cached,
};
res.json(response);
} catch (error) {
logError(error, 'Get OpenCode models failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ModelsResponse);
}
};
}
/**
* Creates handler for POST /api/setup/opencode/models/refresh
*
* Forces a refresh of models from the OpenCode CLI.
*/
export function createRefreshOpencodeModelsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const models = await provider.refreshModels();
const response: ModelsResponse = {
success: true,
models,
count: models.length,
cached: false,
};
res.json(response);
} catch (error) {
logError(error, 'Refresh OpenCode models failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ModelsResponse);
}
};
}
/**
* Creates handler for GET /api/setup/opencode/providers
*
* Returns authenticated providers from OpenCode CLI.
* This calls `opencode auth list` to get provider status.
*/
export function createGetOpencodeProvidersHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const providers = await provider.fetchAuthenticatedProviders();
// Filter to only authenticated providers
const authenticated = providers.filter((p) => p.authenticated);
const response: ProvidersResponse = {
success: true,
providers,
authenticated,
};
res.json(response);
} catch (error) {
logError(error, 'Get OpenCode providers failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ProvidersResponse);
}
};
}
/**
* Creates handler for POST /api/setup/opencode/cache/clear
*
* Clears the model cache, forcing a fresh fetch on next access.
*/
export function createClearOpencodeCacheHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
provider.clearModelCache();
res.json({
success: true,
message: 'OpenCode model cache cleared',
});
} catch (error) {
logError(error, 'Clear OpenCode cache failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -21,25 +21,22 @@ export function createStoreApiKeyHandler() {
return;
}
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) {
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 {
res.status(400).json({
success: false,
error: `Unsupported provider: ${provider}. Only anthropic and openai are supported.`,
error: `Unsupported provider: ${provider}. Only anthropic is 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,12 +5,19 @@
* (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, type ThinkingLevel } from '@automaker/types';
import {
DEFAULT_PHASE_MODELS,
isCursorModel,
stripProviderPrefix,
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 { streamingQuery } from '../../providers/simple-query-service.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { FeatureLoader } from '../../services/feature-loader.js';
import { getAppSpecPath } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js';
@@ -197,14 +204,19 @@ 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;
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
const useStructuredOutput = !isCursorModel(model);
// Route to appropriate provider based on model type
if (isCursorModel(model)) {
// Use Cursor provider for Cursor models
logger.info('[Suggestions] Using Cursor provider');
// Build the final prompt - for Cursor, include JSON schema instructions
let finalPrompt = prompt;
if (!useStructuredOutput) {
finalPrompt = `${prompt}
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}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. Return the JSON in your response only.
@@ -214,60 +226,104 @@ CRITICAL INSTRUCTIONS:
${JSON.stringify(suggestionsSchema, null, 2)}
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
}
// 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,
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,
});
}
}
: 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,
});
},
});
} 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 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', {
@@ -278,7 +334,24 @@ Your entire response should be valid JSON starting with { and ending with }. No
})),
});
} else {
throw new Error('No valid JSON found in response');
// 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');
}
}
} catch (error) {
// Log the parsing error for debugging

View File

@@ -3,51 +3,15 @@
*/
import { createLogger } from '@automaker/utils';
import { spawnProcess } from '@automaker/platform';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
import { FeatureLoader } from '../../services/feature-loader.js';
const logger = createLogger('Worktree');
export const execAsync = promisify(exec);
// ============================================================================
// Secure Command Execution
// ============================================================================
/**
* Execute git command with array arguments to prevent command injection.
* Uses spawnProcess from @automaker/platform for secure, cross-platform execution.
*
* @param args - Array of git command arguments (e.g., ['worktree', 'add', path])
* @param cwd - Working directory to execute the command in
* @returns Promise resolving to stdout output
* @throws Error with stderr message if command fails
*
* @example
* ```typescript
* // Safe: no injection possible
* await execGitCommand(['branch', '-D', branchName], projectPath);
*
* // Instead of unsafe:
* // await execAsync(`git branch -D ${branchName}`, { cwd });
* ```
*/
export async function execGitCommand(args: string[], cwd: string): Promise<string> {
const result = await spawnProcess({
command: 'git',
args,
cwd,
});
// spawnProcess returns { stdout, stderr, exitCode }
if (result.exitCode === 0) {
return result.stdout;
} else {
const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`;
throw new Error(errorMessage);
}
}
const featureLoader = new FeatureLoader();
// ============================================================================
// Constants
@@ -135,6 +99,18 @@ export function normalizePath(p: string): string {
return p.replace(/\\/g, '/');
}
/**
* Check if a path is a git repo
*/
export async function isGitRepo(repoPath: string): Promise<boolean> {
try {
await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath });
return true;
} catch {
return false;
}
}
/**
* Check if a git repository has at least one commit (i.e., HEAD exists)
* Returns false for freshly initialized repos with no commits

View File

@@ -3,7 +3,6 @@
*/
import { Router } from 'express';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js';
import { createInfoHandler } from './routes/info.js';
@@ -17,7 +16,6 @@ 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';
@@ -26,27 +24,14 @@ import { createSwitchBranchHandler } from './routes/switch-branch.js';
import {
createOpenInEditorHandler,
createGetDefaultEditorHandler,
createGetAvailableEditorsHandler,
createRefreshEditorsHandler,
} from './routes/open-in-editor.js';
import { createInitGitHandler } from './routes/init-git.js';
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,
settingsService?: SettingsService
): Router {
export function createWorktreeRoutes(): Router {
const router = Router();
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
@@ -60,7 +45,7 @@ export function createWorktreeRoutes(
requireValidProject,
createMergeHandler()
);
router.post('/create', validatePathParams('projectPath'), createCreateHandler(events));
router.post('/create', validatePathParams('projectPath'), createCreateHandler());
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
router.post('/create-pr', createCreatePRHandler());
router.post('/pr-info', createPRInfoHandler());
@@ -70,12 +55,6 @@ export function createWorktreeRoutes(
requireGitRepoOnly,
createCommitHandler()
);
router.post(
'/generate-commit-message',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createGenerateCommitMessageHandler(settingsService)
);
router.post(
'/push',
validatePathParams('worktreePath'),
@@ -98,8 +77,6 @@ export function createWorktreeRoutes(
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
router.get('/default-editor', createGetDefaultEditorHandler());
router.get('/available-editors', createGetAvailableEditorsHandler());
router.post('/refresh-editors', createRefreshEditorsHandler());
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
router.post('/migrate', createMigrateHandler());
router.post(
@@ -109,21 +86,6 @@ export function createWorktreeRoutes(
);
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());
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
router.delete('/init-script', validatePathParams('projectPath'), createDeleteInitScriptHandler());
router.post(
'/run-init-script',
validatePathParams('projectPath', 'worktreePath'),
createRunInitScriptHandler(events)
);
return router;
}

View File

@@ -3,8 +3,7 @@
*/
import type { Request, Response, NextFunction } from 'express';
import { isGitRepo } from '@automaker/git-utils';
import { hasCommits } from './common.js';
import { isGitRepo, hasCommits } from './common.js';
interface ValidationOptions {
/** Check if the path is a git repository (default: true) */

View File

@@ -70,8 +70,9 @@ export function createCreatePRHandler() {
logger.debug(`Changed files:\n${status}`);
}
// If there are changes, commit them before creating the PR
// If there are changes, commit them
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}`);
@@ -97,13 +98,14 @@ export function createCreatePRHandler() {
logger.info(`Commit successful: ${commitHash}`);
} catch (commitErr: unknown) {
const err = commitErr as { stderr?: string; message?: string };
const commitError = err.stderr || err.message || 'Commit failed';
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;
}
@@ -379,8 +381,9 @@ export function createCreatePRHandler() {
success: true,
result: {
branch: branchName,
committed: hasChanges,
committed: hasChanges && !commitError,
commitHash,
commitError: commitError || undefined,
pushed: true,
prUrl,
prNumber,

View File

@@ -12,19 +12,15 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import type { EventEmitter } from '../../../lib/events.js';
import { isGitRepo } from '@automaker/git-utils';
import {
isGitRepo,
getErrorMessage,
logError,
normalizePath,
ensureInitialCommit,
isValidBranchName,
execGitCommand,
} from '../common.js';
import { trackBranch } from './branch-tracking.js';
import { createLogger } from '@automaker/utils';
import { runInitScript } from '../../../services/init-script-service.js';
const logger = createLogger('Worktree');
@@ -81,7 +77,7 @@ async function findExistingWorktreeForBranch(
}
}
export function createCreateHandler(events: EventEmitter) {
export function createCreateHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName, baseBranch } = req.body as {
@@ -98,26 +94,6 @@ export function createCreateHandler(events: EventEmitter) {
return;
}
// Validate branch name to prevent command injection
if (!isValidBranchName(branchName)) {
res.status(400).json({
success: false,
error:
'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
});
return;
}
// Validate base branch if provided
if (baseBranch && !isValidBranchName(baseBranch) && baseBranch !== 'HEAD') {
res.status(400).json({
success: false,
error:
'Invalid base branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
});
return;
}
if (!(await isGitRepo(projectPath))) {
res.status(400).json({
success: false,
@@ -167,28 +143,30 @@ export function createCreateHandler(events: EventEmitter) {
// Create worktrees directory if it doesn't exist
await secureFs.mkdir(worktreesDir, { recursive: true });
// Check if branch exists (using array arguments to prevent injection)
// Check if branch exists
let branchExists = false;
try {
await execGitCommand(['rev-parse', '--verify', branchName], projectPath);
await execAsync(`git rev-parse --verify ${branchName}`, {
cwd: projectPath,
});
branchExists = true;
} catch {
// Branch doesn't exist
}
// Create worktree (using array arguments to prevent injection)
// Create worktree
let createCmd: string;
if (branchExists) {
// Use existing branch
await execGitCommand(['worktree', 'add', worktreePath, branchName], projectPath);
createCmd = `git worktree add "${worktreePath}" ${branchName}`;
} else {
// Create new branch from base or HEAD
const base = baseBranch || 'HEAD';
await execGitCommand(
['worktree', 'add', '-b', branchName, worktreePath, base],
projectPath
);
createCmd = `git worktree add -b ${branchName} "${worktreePath}" ${base}`;
}
await execAsync(createCmd, { cwd: projectPath });
// Note: We intentionally do NOT symlink .automaker to worktrees
// Features and config are always accessed from the main project path
// This avoids symlink loop issues when activating worktrees
@@ -199,8 +177,6 @@ export function createCreateHandler(events: EventEmitter) {
// Resolve to absolute path for cross-platform compatibility
// normalizePath converts to forward slashes for API consistency
const absoluteWorktreePath = path.resolve(worktreePath);
// Respond immediately (non-blocking)
res.json({
success: true,
worktree: {
@@ -209,17 +185,6 @@ export function createCreateHandler(events: EventEmitter) {
isNew: !branchExists,
},
});
// Trigger init script asynchronously after response
// runInitScript internally checks if script exists and hasn't already run
runInitScript({
projectPath,
worktreePath: absoluteWorktreePath,
branch: branchName,
emitter: events,
}).catch((err) => {
logger.error(`Init script failed for ${branchName}:`, err);
});
} catch (error) {
logError(error, 'Create worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -6,11 +6,9 @@ import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
const logger = createLogger('Worktree');
export function createDeleteHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -48,28 +46,22 @@ export function createDeleteHandler() {
// Could not get branch name
}
// Remove the worktree (using array arguments to prevent injection)
// Remove the worktree
try {
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: projectPath,
});
} catch (error) {
// Try with prune if remove fails
await execGitCommand(['worktree', 'prune'], projectPath);
await execAsync('git worktree prune', { cwd: projectPath });
}
// Optionally delete the branch
let branchDeleted = false;
if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') {
// Validate branch name to prevent command injection
if (!isValidBranchName(branchName)) {
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
} else {
try {
await execGitCommand(['branch', '-D', branchName], projectPath);
branchDeleted = true;
} catch {
// Branch deletion failed, not critical
logger.warn(`Failed to delete branch: ${branchName}`);
}
try {
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
} catch {
// Branch deletion failed, not critical
}
}
@@ -77,8 +69,7 @@ export function createDeleteHandler() {
success: true,
deleted: {
worktreePath,
branch: branchDeleted ? branchName : null,
branchDeleted,
branch: deleteBranch ? branchName : null,
},
});
} catch (error) {

View File

@@ -1,52 +0,0 @@
/**
* 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

@@ -1,275 +0,0 @@
/**
* 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,280 +0,0 @@
/**
* Init Script routes - Read/write/run the worktree-init.sh file
*
* POST /init-script - Read the init script content
* PUT /init-script - Write content to the init script file
* DELETE /init-script - Delete the init script file
* POST /run-init-script - Run the init script for a worktree
*/
import type { Request, Response } from 'express';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../../../lib/events.js';
import { forceRunInitScript } from '../../../services/init-script-service.js';
const logger = createLogger('InitScript');
/** Fixed path for init script within .automaker directory */
const INIT_SCRIPT_FILENAME = 'worktree-init.sh';
/** Maximum allowed size for init scripts (1MB) */
const MAX_SCRIPT_SIZE_BYTES = 1024 * 1024;
/**
* Get the full path to the init script for a project
*/
function getInitScriptPath(projectPath: string): string {
return path.join(projectPath, '.automaker', INIT_SCRIPT_FILENAME);
}
/**
* GET /init-script - Read the init script content
*/
export function createGetInitScriptHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const rawProjectPath = req.query.projectPath;
// Validate projectPath is a non-empty string (not an array or undefined)
if (!rawProjectPath || typeof rawProjectPath !== 'string') {
res.status(400).json({
success: false,
error: 'projectPath query parameter is required',
});
return;
}
const projectPath = rawProjectPath.trim();
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath cannot be empty',
});
return;
}
const scriptPath = getInitScriptPath(projectPath);
try {
const content = await secureFs.readFile(scriptPath, 'utf-8');
res.json({
success: true,
exists: true,
content: content as string,
path: scriptPath,
});
} catch {
// File doesn't exist
res.json({
success: true,
exists: false,
content: '',
path: scriptPath,
});
}
} catch (error) {
logError(error, 'Read init script failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}
/**
* PUT /init-script - Write content to the init script file
*/
export function createPutInitScriptHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, content } = req.body as {
projectPath: string;
content: string;
};
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
if (typeof content !== 'string') {
res.status(400).json({
success: false,
error: 'content must be a string',
});
return;
}
// Validate script size to prevent disk exhaustion
const sizeBytes = Buffer.byteLength(content, 'utf-8');
if (sizeBytes > MAX_SCRIPT_SIZE_BYTES) {
res.status(400).json({
success: false,
error: `Script size (${Math.round(sizeBytes / 1024)}KB) exceeds maximum allowed size (${Math.round(MAX_SCRIPT_SIZE_BYTES / 1024)}KB)`,
});
return;
}
// Log warning if potentially dangerous patterns are detected (non-blocking)
const dangerousPatterns = [
/rm\s+-rf\s+\/(?!\s*\$)/i, // rm -rf / (not followed by variable)
/curl\s+.*\|\s*(?:bash|sh)/i, // curl | bash
/wget\s+.*\|\s*(?:bash|sh)/i, // wget | sh
];
for (const pattern of dangerousPatterns) {
if (pattern.test(content)) {
logger.warn(
`Init script contains potentially dangerous pattern: ${pattern.source}. User responsibility to verify script safety.`
);
}
}
const scriptPath = getInitScriptPath(projectPath);
const automakerDir = path.dirname(scriptPath);
// Ensure .automaker directory exists
await secureFs.mkdir(automakerDir, { recursive: true });
// Write the script content
await secureFs.writeFile(scriptPath, content, 'utf-8');
logger.info(`Wrote init script to ${scriptPath}`);
res.json({
success: true,
path: scriptPath,
});
} catch (error) {
logError(error, 'Write init script failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}
/**
* DELETE /init-script - Delete the init script file
*/
export function createDeleteInitScriptHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
const scriptPath = getInitScriptPath(projectPath);
await secureFs.rm(scriptPath, { force: true });
logger.info(`Deleted init script at ${scriptPath}`);
res.json({
success: true,
});
} catch (error) {
logError(error, 'Delete init script failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}
/**
* POST /run-init-script - Run (or re-run) the init script for a worktree
*/
export function createRunInitScriptHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, worktreePath, branch } = req.body as {
projectPath: string;
worktreePath: string;
branch: string;
};
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath is required',
});
return;
}
if (!branch) {
res.status(400).json({
success: false,
error: 'branch is required',
});
return;
}
// Validate branch name to prevent injection via environment variables
if (!isValidBranchName(branch)) {
res.status(400).json({
success: false,
error:
'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
});
return;
}
const scriptPath = getInitScriptPath(projectPath);
// Check if script exists
try {
await secureFs.access(scriptPath);
} catch {
res.status(404).json({
success: false,
error: 'No init script found. Create one in Settings > Worktrees.',
});
return;
}
logger.info(`Running init script for branch "${branch}" (forced)`);
// Run the script asynchronously (non-blocking)
forceRunInitScript({
projectPath,
worktreePath,
branch,
emitter: events,
});
// Return immediately - progress will be streamed via WebSocket events
res.json({
success: true,
message: 'Init script started',
});
} catch (error) {
logError(error, 'Run init script failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,5 +1,5 @@
/**
* POST /list-branches endpoint - List all local branches and optionally remote branches
* POST /list-branches endpoint - List all local branches
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
@@ -21,9 +21,8 @@ interface BranchInfo {
export function createListBranchesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, includeRemote = false } = req.body as {
const { worktreePath } = req.body as {
worktreePath: string;
includeRemote?: boolean;
};
if (!worktreePath) {
@@ -61,55 +60,6 @@ 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, execEnv, isGhCliAvailable } from '../common.js';
import { getErrorMessage, logError, normalizePath } from '../common.js';
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';
@@ -121,52 +121,6 @@ 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 {
@@ -287,23 +241,11 @@ export function createListHandler() {
}
}
// 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>();
// Add PR info from metadata for each worktree
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,6 +8,7 @@
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);
@@ -15,31 +16,28 @@ const execAsync = promisify(exec);
export function createMergeHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName, worktreePath, options } = req.body as {
const { projectPath, featureId, options } = req.body as {
projectPath: string;
branchName: string;
worktreePath: string;
featureId: string;
options?: { squash?: boolean; message?: string };
};
if (!projectPath || !branchName || !worktreePath) {
if (!projectPath || !featureId) {
res.status(400).json({
success: false,
error: 'projectPath, branchName, and worktreePath are required',
error: 'projectPath and featureId required',
});
return;
}
// 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;
}
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,
});
// Merge the feature branch
const mergeCmd = options?.squash

View File

@@ -1,40 +1,78 @@
/**
* POST /open-in-editor endpoint - Open a worktree directory in the default code editor
* GET /default-editor endpoint - Get the name of the default code editor
* POST /refresh-editors endpoint - Clear editor cache and re-detect available editors
*
* This module uses @automaker/platform for cross-platform editor detection and launching.
*/
import type { Request, Response } from 'express';
import { isAbsolute } from 'path';
import {
clearEditorCache,
detectAllEditors,
detectDefaultEditor,
openInEditor,
openInFileManager,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('open-in-editor');
const execAsync = promisify(exec);
export function createGetAvailableEditorsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const editors = await detectAllEditors();
res.json({
success: true,
result: {
editors,
},
});
} catch (error) {
logError(error, 'Get available editors failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
// Editor detection with caching
interface EditorInfo {
name: string;
command: string;
}
let cachedEditor: EditorInfo | null = null;
/**
* Detect which code editor is available on the system
*/
async function detectDefaultEditor(): Promise<EditorInfo> {
// Return cached result if available
if (cachedEditor) {
return cachedEditor;
}
// Try Cursor first (if user has Cursor, they probably prefer it)
try {
await execAsync('which cursor || where cursor');
cachedEditor = { name: 'Cursor', command: 'cursor' };
return cachedEditor;
} catch {
// Cursor not found
}
// Try VS Code
try {
await execAsync('which code || where code');
cachedEditor = { name: 'VS Code', command: 'code' };
return cachedEditor;
} catch {
// VS Code not found
}
// Try Zed
try {
await execAsync('which zed || where zed');
cachedEditor = { name: 'Zed', command: 'zed' };
return cachedEditor;
} catch {
// Zed not found
}
// Try Sublime Text
try {
await execAsync('which subl || where subl');
cachedEditor = { name: 'Sublime Text', command: 'subl' };
return cachedEditor;
} catch {
// Sublime not found
}
// Fallback to file manager
const platform = process.platform;
if (platform === 'darwin') {
cachedEditor = { name: 'Finder', command: 'open' };
} else if (platform === 'win32') {
cachedEditor = { name: 'Explorer', command: 'explorer' };
} else {
cachedEditor = { name: 'File Manager', command: 'xdg-open' };
}
return cachedEditor;
}
export function createGetDefaultEditorHandler() {
@@ -55,41 +93,11 @@ export function createGetDefaultEditorHandler() {
};
}
/**
* Handler to refresh the editor cache and re-detect available editors
* Useful when the user has installed/uninstalled editors
*/
export function createRefreshEditorsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Clear the cache
clearEditorCache();
// Re-detect editors (this will repopulate the cache)
const editors = await detectAllEditors();
logger.info(`Editor cache refreshed, found ${editors.length} editors`);
res.json({
success: true,
result: {
editors,
message: `Found ${editors.length} available editors`,
},
});
} catch (error) {
logError(error, 'Refresh editors failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
export function createOpenInEditorHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, editorCommand } = req.body as {
const { worktreePath } = req.body as {
worktreePath: string;
editorCommand?: string;
};
if (!worktreePath) {
@@ -100,44 +108,42 @@ export function createOpenInEditorHandler() {
return;
}
// Security: Validate that worktreePath is an absolute path
if (!isAbsolute(worktreePath)) {
res.status(400).json({
success: false,
error: 'worktreePath must be an absolute path',
});
return;
}
const editor = await detectDefaultEditor();
try {
// Use the platform utility to open in editor
const result = await openInEditor(worktreePath, editorCommand);
await execAsync(`${editor.command} "${worktreePath}"`);
res.json({
success: true,
result: {
message: `Opened ${worktreePath} in ${result.editorName}`,
editorName: result.editorName,
message: `Opened ${worktreePath} in ${editor.name}`,
editorName: editor.name,
},
});
} catch (editorError) {
// If the specified editor fails, try opening in default file manager as fallback
logger.warn(
`Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}`
);
// If the detected editor fails, try opening in default file manager as fallback
const platform = process.platform;
let openCommand: string;
let fallbackName: string;
try {
const result = await openInFileManager(worktreePath);
res.json({
success: true,
result: {
message: `Opened ${worktreePath} in ${result.editorName}`,
editorName: result.editorName,
},
});
} catch (fallbackError) {
// Both editor and file manager failed
throw fallbackError;
if (platform === 'darwin') {
openCommand = `open "${worktreePath}"`;
fallbackName = 'Finder';
} else if (platform === 'win32') {
openCommand = `explorer "${worktreePath}"`;
fallbackName = 'Explorer';
} else {
openCommand = `xdg-open "${worktreePath}"`;
fallbackName = 'File Manager';
}
await execAsync(openCommand);
res.json({
success: true,
result: {
message: `Opened ${worktreePath} in ${fallbackName}`,
editorName: fallbackName,
},
});
}
} catch (error) {
logError(error, 'Open in editor failed');

View File

@@ -10,18 +10,15 @@
*/
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, isClaudeModel, stripProviderPrefix } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, stripProviderPrefix } from '@automaker/types';
import {
buildPromptWithImages,
classifyError,
@@ -86,26 +83,6 @@ 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
@@ -940,25 +917,6 @@ 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');
@@ -978,252 +936,11 @@ 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
*/
@@ -3168,111 +2885,6 @@ 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.
@@ -3581,42 +3193,40 @@ 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;
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({
const stream = query({
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.',
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.',
},
});
const responseText = result.text;
// 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;
}
}
console.log(`[AutoMode] Learning extraction response: ${responseText.length} chars`);
console.log(`[AutoMode] Response preview: ${responseText.substring(0, 300)}`);

View File

@@ -49,11 +49,13 @@ export class ClaudeUsageService {
/**
* Execute the claude /usage command and return the output
* Uses node-pty on all platforms for consistency
* Uses platform-specific PTY implementation
*/
private executeClaudeUsageCommand(): Promise<string> {
// Use node-pty on all platforms - it's more reliable than expect on macOS
return this.executeClaudeUsageCommandPty();
if (this.isWindows || this.isLinux) {
return this.executeClaudeUsageCommandPty();
}
return this.executeClaudeUsageCommandMac();
}
/**
@@ -65,36 +67,24 @@ export class ClaudeUsageService {
let stderr = '';
let settled = false;
// Use current working directory - likely already trusted by Claude CLI
const workingDirectory = process.cwd();
// Use a simple working directory (home or tmp)
const workingDirectory = process.env.HOME || '/tmp';
// Use 'expect' with an inline script to run claude /usage with a PTY
// Running from cwd which should already be trusted
// Wait for "Current session" header, then wait for full output before exiting
const expectScript = `
set timeout 30
set timeout 20
spawn claude /usage
# Wait for usage data or handle trust prompt if needed
expect {
-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
}
"Current session" {
# Usage data appeared - wait for full output, then exit
sleep 2
send "\\x1b"
}
"Esc to cancel" {
sleep 3
send "\\x1b"
}
"% left" {
# Usage percentage appeared
sleep 3
send "\\x1b"
}
timeout {
send "\\x1b"
}
timeout {}
eof {}
}
expect eof
@@ -168,18 +158,14 @@ export class ClaudeUsageService {
let output = '';
let settled = false;
let hasSeenUsageData = false;
let hasSeenTrustPrompt = false;
// Use current working directory (project dir) - most likely already trusted by Claude CLI
const workingDirectory = process.cwd();
const workingDirectory = this.isWindows
? process.env.USERPROFILE || os.homedir() || 'C:\\'
: process.env.HOME || os.homedir() || '/tmp';
// Use platform-appropriate shell and command
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
// 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}"`];
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
let ptyProcess: any = null;
@@ -195,6 +181,8 @@ 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);
@@ -216,60 +204,17 @@ 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(
'The Claude CLI took too long to respond. This can happen if the CLI is waiting for a trust prompt or is otherwise busy.'
)
);
reject(new Error('Command timed out'));
}
}
}, 45000); // 45 second timeout
let hasSentCommand = false;
let hasApprovedTrust = false;
}, this.timeout);
ptyProcess.onData((data: string) => {
output += data;
// 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')))
) {
// Check if we've seen the usage data (look for "Current session")
if (!hasSeenUsageData && output.includes('Current session')) {
hasSeenUsageData = true;
// Wait for full output, then send escape to exit
setTimeout(() => {
@@ -283,62 +228,16 @@ export class ClaudeUsageService {
}
}, 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);
}, 2000);
}
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
if (
!hasSeenUsageData &&
cleanOutput.includes('Esc to cancel') &&
!cleanOutput.includes('Do you want to work in this folder?')
) {
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
setTimeout(() => {
if (!settled && ptyProcess && !ptyProcess.killed) {
ptyProcess.write('\x1b'); // Send escape key
}
}, 5000);
}, 3000);
}
});
@@ -347,11 +246,8 @@ export class ClaudeUsageService {
if (settled) return;
settled = true;
if (
output.includes('token_expired') ||
output.includes('authentication_error') ||
output.includes('permission_error')
) {
// Check for authentication errors in output
if (output.includes('token_expired') || output.includes('authentication_error')) {
reject(new Error("Authentication required - please run 'claude login'"));
return;
}

View File

@@ -12,123 +12,24 @@ 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)
@@ -343,9 +244,10 @@ class DevServerService {
// Reserve the port (port was already force-killed in findAvailablePort)
this.allocatedPorts.add(port);
// Also kill common related ports (livereload, etc.)
// Also kill common related ports (livereload uses 35729 by default)
// Some dev servers use fixed ports for HMR/livereload regardless of main port
for (const relatedPort of LIVERELOAD_PORTS) {
const commonRelatedPorts = [35729, 35730, 35731];
for (const relatedPort of commonRelatedPorts) {
this.killProcessOnPort(relatedPort);
}
@@ -357,14 +259,9 @@ 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, {
@@ -377,66 +274,32 @@ class DevServerService {
// Track if process failed early using object to work around TypeScript narrowing
const status = { error: null as string | null, exited: false };
// 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
// Log output for debugging
if (devProcess.stdout) {
devProcess.stdout.on('data', (data: Buffer) => {
this.handleProcessOutput(serverInfo, data);
logger.debug(`[Port${port}] ${data.toString().trim()}`);
});
}
// Capture stderr with buffer management and event emission
if (devProcess.stderr) {
devProcess.stderr.on('data', (data: Buffer) => {
this.handleProcessOutput(serverInfo, data);
const msg = data.toString().trim();
logger.debug(`[Port${port}] ${msg}`);
});
}
// 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;
cleanupAndEmitStop(null, error.message);
this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath);
});
devProcess.on('exit', (code) => {
logger.info(`Process for ${worktreePath} exited with code ${code}`);
status.exited = true;
cleanupAndEmitStop(code);
this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath);
});
// Wait a moment to see if the process fails immediately
@@ -456,18 +319,15 @@ class DevServerService {
};
}
// Server started successfully - add to running servers map
this.runningServers.set(worktreePath, serverInfo);
const serverInfo: DevServerInfo = {
worktreePath,
port,
url: `http://localhost:${port}`,
process: devProcess,
startedAt: new Date(),
};
// Emit started event for WebSocket subscribers
if (this.emitter) {
this.emitter.emit('dev-server:started', {
worktreePath,
port,
url: serverInfo.url,
timestamp: new Date().toISOString(),
});
}
this.runningServers.set(worktreePath, serverInfo);
return {
success: true,
@@ -505,28 +365,6 @@ 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');
@@ -584,41 +422,6 @@ 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

@@ -308,15 +308,13 @@ export class FeatureLoader {
* @param updates - Partial feature updates
* @param descriptionHistorySource - Source of description change ('enhance' or 'edit')
* @param enhancementMode - Enhancement mode if source is 'enhance'
* @param preEnhancementDescription - Description before enhancement (for restoring original)
*/
async update(
projectPath: string,
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
): Promise<Feature> {
const feature = await this.get(projectPath, featureId);
if (!feature) {
@@ -340,31 +338,9 @@ export class FeatureLoader {
updates.description !== feature.description &&
updates.description.trim()
) {
const timestamp = new Date().toISOString();
// If this is an enhancement and we have the pre-enhancement description,
// add the original text to history first (so user can restore to it)
if (
descriptionHistorySource === 'enhance' &&
preEnhancementDescription &&
preEnhancementDescription.trim()
) {
// Check if this pre-enhancement text is different from the last history entry
const lastEntry = updatedHistory[updatedHistory.length - 1];
if (!lastEntry || lastEntry.description !== preEnhancementDescription) {
const preEnhanceEntry: DescriptionHistoryEntry = {
description: preEnhancementDescription,
timestamp,
source: updatedHistory.length === 0 ? 'initial' : 'edit',
};
updatedHistory = [...updatedHistory, preEnhanceEntry];
}
}
// Add the new/enhanced description to history
const historyEntry: DescriptionHistoryEntry = {
description: updates.description,
timestamp,
timestamp: new Date().toISOString(),
source: descriptionHistorySource || 'edit',
...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}),
};

View File

@@ -1,360 +0,0 @@
/**
* Init Script Service - Executes worktree initialization scripts
*
* Runs the .automaker/worktree-init.sh script after worktree creation.
* Uses Git Bash on Windows for cross-platform shell script compatibility.
*/
import { spawn } from 'child_process';
import path from 'path';
import { createLogger } from '@automaker/utils';
import { systemPathExists, getShellPaths, findGitBashPath } from '@automaker/platform';
import { findCommand } from '../lib/cli-detection.js';
import type { EventEmitter } from '../lib/events.js';
import { readWorktreeMetadata, writeWorktreeMetadata } from '../lib/worktree-metadata.js';
import * as secureFs from '../lib/secure-fs.js';
const logger = createLogger('InitScript');
export interface InitScriptOptions {
/** Absolute path to the project root */
projectPath: string;
/** Absolute path to the worktree directory */
worktreePath: string;
/** Branch name for this worktree */
branch: string;
/** Event emitter for streaming output */
emitter: EventEmitter;
}
interface ShellCommand {
shell: string;
args: string[];
}
/**
* Init Script Service
*
* Handles execution of worktree initialization scripts with cross-platform
* shell detection and proper streaming of output via WebSocket events.
*/
export class InitScriptService {
private cachedShellCommand: ShellCommand | null | undefined = undefined;
/**
* Get the path to the init script for a project
*/
getInitScriptPath(projectPath: string): string {
return path.join(projectPath, '.automaker', 'worktree-init.sh');
}
/**
* Check if the init script has already been run for a worktree
*/
async hasInitScriptRun(projectPath: string, branch: string): Promise<boolean> {
const metadata = await readWorktreeMetadata(projectPath, branch);
return metadata?.initScriptRan === true;
}
/**
* Find the appropriate shell for running scripts
* Uses findGitBashPath() on Windows to avoid WSL bash, then falls back to PATH
*/
async findShellCommand(): Promise<ShellCommand | null> {
// Return cached result if available
if (this.cachedShellCommand !== undefined) {
return this.cachedShellCommand;
}
if (process.platform === 'win32') {
// On Windows, prioritize Git Bash over WSL bash (C:\Windows\System32\bash.exe)
// WSL bash may not be properly configured and causes ENOENT errors
// First try known Git Bash installation paths
const gitBashPath = await findGitBashPath();
if (gitBashPath) {
logger.debug(`Found Git Bash at: ${gitBashPath}`);
this.cachedShellCommand = { shell: gitBashPath, args: [] };
return this.cachedShellCommand;
}
// Fall back to finding bash in PATH, but skip WSL bash
const bashInPath = await findCommand(['bash']);
if (bashInPath && !bashInPath.toLowerCase().includes('system32')) {
logger.debug(`Found bash in PATH at: ${bashInPath}`);
this.cachedShellCommand = { shell: bashInPath, args: [] };
return this.cachedShellCommand;
}
logger.warn('Git Bash not found. WSL bash was skipped to avoid compatibility issues.');
this.cachedShellCommand = null;
return null;
}
// Unix-like systems: use getShellPaths() and check existence
const shellPaths = getShellPaths();
const posixShells = shellPaths.filter(
(p) => p.includes('bash') || p === '/bin/sh' || p === '/usr/bin/sh'
);
for (const shellPath of posixShells) {
try {
if (systemPathExists(shellPath)) {
this.cachedShellCommand = { shell: shellPath, args: [] };
return this.cachedShellCommand;
}
} catch {
// Path not allowed or doesn't exist, continue
}
}
// Ultimate fallback
if (systemPathExists('/bin/sh')) {
this.cachedShellCommand = { shell: '/bin/sh', args: [] };
return this.cachedShellCommand;
}
this.cachedShellCommand = null;
return null;
}
/**
* Run the worktree initialization script
* Non-blocking - returns immediately after spawning
*/
async runInitScript(options: InitScriptOptions): Promise<void> {
const { projectPath, worktreePath, branch, emitter } = options;
const scriptPath = this.getInitScriptPath(projectPath);
// Check if script exists using secureFs (respects ALLOWED_ROOT_DIRECTORY)
try {
await secureFs.access(scriptPath);
} catch {
logger.debug(`No init script found at ${scriptPath}`);
return;
}
// Check if already run
if (await this.hasInitScriptRun(projectPath, branch)) {
logger.info(`Init script already ran for branch "${branch}", skipping`);
return;
}
// Get shell command
const shellCmd = await this.findShellCommand();
if (!shellCmd) {
const error =
process.platform === 'win32'
? 'Git Bash not found. Please install Git for Windows to run init scripts.'
: 'No shell found (/bin/bash or /bin/sh)';
logger.error(error);
// Update metadata with error, preserving existing metadata
const existingMetadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: existingMetadata?.createdAt || new Date().toISOString(),
pr: existingMetadata?.pr,
initScriptRan: true,
initScriptStatus: 'failed',
initScriptError: error,
});
emitter.emit('worktree:init-completed', {
projectPath,
worktreePath,
branch,
success: false,
error,
});
return;
}
logger.info(`Running init script for branch "${branch}" in ${worktreePath}`);
logger.debug(`Using shell: ${shellCmd.shell}`);
// Update metadata to mark as running
const existingMetadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: existingMetadata?.createdAt || new Date().toISOString(),
pr: existingMetadata?.pr,
initScriptRan: false,
initScriptStatus: 'running',
});
// Emit started event
emitter.emit('worktree:init-started', {
projectPath,
worktreePath,
branch,
});
// Build safe environment - only pass necessary variables, not all of process.env
// This prevents exposure of sensitive credentials like ANTHROPIC_API_KEY
const safeEnv: Record<string, string> = {
// Automaker-specific variables
AUTOMAKER_PROJECT_PATH: projectPath,
AUTOMAKER_WORKTREE_PATH: worktreePath,
AUTOMAKER_BRANCH: branch,
// Essential system variables
PATH: process.env.PATH || '',
HOME: process.env.HOME || '',
USER: process.env.USER || '',
TMPDIR: process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp',
// Shell and locale
SHELL: process.env.SHELL || '',
LANG: process.env.LANG || 'en_US.UTF-8',
LC_ALL: process.env.LC_ALL || '',
// Force color output even though we're not a TTY
FORCE_COLOR: '1',
npm_config_color: 'always',
CLICOLOR_FORCE: '1',
// Git configuration
GIT_TERMINAL_PROMPT: '0',
};
// Platform-specific additions
if (process.platform === 'win32') {
safeEnv.USERPROFILE = process.env.USERPROFILE || '';
safeEnv.APPDATA = process.env.APPDATA || '';
safeEnv.LOCALAPPDATA = process.env.LOCALAPPDATA || '';
safeEnv.SystemRoot = process.env.SystemRoot || 'C:\\Windows';
safeEnv.TEMP = process.env.TEMP || '';
}
// Spawn the script with safe environment
const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], {
cwd: worktreePath,
env: safeEnv,
stdio: ['ignore', 'pipe', 'pipe'],
});
// Stream stdout
child.stdout?.on('data', (data: Buffer) => {
const content = data.toString();
emitter.emit('worktree:init-output', {
projectPath,
branch,
type: 'stdout',
content,
});
});
// Stream stderr
child.stderr?.on('data', (data: Buffer) => {
const content = data.toString();
emitter.emit('worktree:init-output', {
projectPath,
branch,
type: 'stderr',
content,
});
});
// Handle completion
child.on('exit', async (code) => {
const success = code === 0;
const status = success ? 'success' : 'failed';
logger.info(`Init script for branch "${branch}" ${status} with exit code ${code}`);
// Update metadata
const metadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: metadata?.createdAt || new Date().toISOString(),
pr: metadata?.pr,
initScriptRan: true,
initScriptStatus: status,
initScriptError: success ? undefined : `Exit code: ${code}`,
});
// Emit completion event
emitter.emit('worktree:init-completed', {
projectPath,
worktreePath,
branch,
success,
exitCode: code,
});
});
child.on('error', async (error) => {
logger.error(`Init script error for branch "${branch}":`, error);
// Update metadata
const metadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: metadata?.createdAt || new Date().toISOString(),
pr: metadata?.pr,
initScriptRan: true,
initScriptStatus: 'failed',
initScriptError: error.message,
});
// Emit completion with error
emitter.emit('worktree:init-completed', {
projectPath,
worktreePath,
branch,
success: false,
error: error.message,
});
});
}
/**
* Force re-run the worktree initialization script
* Ignores the initScriptRan flag - useful for testing or re-setup
*/
async forceRunInitScript(options: InitScriptOptions): Promise<void> {
const { projectPath, branch } = options;
// Reset the initScriptRan flag so the script will run
const metadata = await readWorktreeMetadata(projectPath, branch);
if (metadata) {
await writeWorktreeMetadata(projectPath, branch, {
...metadata,
initScriptRan: false,
initScriptStatus: undefined,
initScriptError: undefined,
});
}
// Now run the script
await this.runInitScript(options);
}
}
// Singleton instance for convenience
let initScriptService: InitScriptService | null = null;
/**
* Get the singleton InitScriptService instance
*/
export function getInitScriptService(): InitScriptService {
if (!initScriptService) {
initScriptService = new InitScriptService();
}
return initScriptService;
}
// Export convenience functions that use the singleton
export const getInitScriptPath = (projectPath: string) =>
getInitScriptService().getInitScriptPath(projectPath);
export const hasInitScriptRun = (projectPath: string, branch: string) =>
getInitScriptService().hasInitScriptRun(projectPath, branch);
export const runInitScript = (options: InitScriptOptions) =>
getInitScriptService().runInitScript(options);
export const forceRunInitScript = (options: InitScriptOptions) =>
getInitScriptService().forceRunInitScript(options);

View File

@@ -431,8 +431,6 @@ 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();
@@ -446,14 +444,6 @@ 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

@@ -17,14 +17,6 @@ import {
type EnhancementMode,
} from '@/lib/enhancement-prompts.js';
const ENHANCEMENT_MODES: EnhancementMode[] = [
'improve',
'technical',
'simplify',
'acceptance',
'ux-reviewer',
];
describe('enhancement-prompts.ts', () => {
describe('System Prompt Constants', () => {
it('should have non-empty improve system prompt', () => {
@@ -192,7 +184,8 @@ describe('enhancement-prompts.ts', () => {
});
it('should work with all enhancement modes', () => {
ENHANCEMENT_MODES.forEach((mode) => {
const modes: EnhancementMode[] = ['improve', 'technical', 'simplify', 'acceptance'];
modes.forEach((mode) => {
const prompt = buildUserPrompt(mode, testText);
expect(prompt).toContain(testText);
expect(prompt.length).toBeGreaterThan(100);
@@ -212,7 +205,6 @@ describe('enhancement-prompts.ts', () => {
expect(isValidEnhancementMode('technical')).toBe(true);
expect(isValidEnhancementMode('simplify')).toBe(true);
expect(isValidEnhancementMode('acceptance')).toBe(true);
expect(isValidEnhancementMode('ux-reviewer')).toBe(true);
});
it('should return false for invalid modes', () => {
@@ -224,12 +216,13 @@ describe('enhancement-prompts.ts', () => {
});
describe('getAvailableEnhancementModes', () => {
it('should return all enhancement modes', () => {
it('should return all four enhancement modes', () => {
const modes = getAvailableEnhancementModes();
expect(modes).toHaveLength(ENHANCEMENT_MODES.length);
ENHANCEMENT_MODES.forEach((mode) => {
expect(modes).toContain(mode);
});
expect(modes).toHaveLength(4);
expect(modes).toContain('improve');
expect(modes).toContain('technical');
expect(modes).toContain('simplify');
expect(modes).toContain('acceptance');
});
it('should return an array', () => {

View File

@@ -12,8 +12,6 @@ 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', () => {
@@ -269,93 +267,6 @@ 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 SDK when API key is present, even for tool requests (to avoid OAuth issues)', async () => {
it('uses the CLI when tools are requested even if an API key is present', 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).toHaveBeenCalled();
expect(spawnJSONLProcess).not.toHaveBeenCalled();
expect(codexRunMock).not.toHaveBeenCalled();
expect(spawnJSONLProcess).toHaveBeenCalled();
});
it('falls back to CLI when no tools are requested and no API key is available', async () => {

View File

@@ -3,7 +3,7 @@ import {
OpencodeProvider,
resetToolUseIdCounter,
} from '../../../src/providers/opencode-provider.js';
import type { ProviderMessage, ModelDefinition } from '@automaker/types';
import type { ProviderMessage } from '@automaker/types';
import { collectAsyncGenerator } from '../../utils/helpers.js';
import { spawnJSONLProcess, getOpenCodeAuthIndicators } from '@automaker/platform';
@@ -51,38 +51,63 @@ describe('opencode-provider.ts', () => {
});
describe('getAvailableModels', () => {
it('should return 5 models', () => {
it('should return 10 models', () => {
const models = provider.getAvailableModels();
expect(models).toHaveLength(5);
expect(models).toHaveLength(10);
});
it('should include Big Pickle as default', () => {
it('should include Claude Sonnet 4.5 (Bedrock) as default', () => {
const models = provider.getAvailableModels();
const sonnet = models.find(
(m) => m.id === 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0'
);
expect(sonnet).toBeDefined();
expect(sonnet?.name).toBe('Claude Sonnet 4.5 (Bedrock)');
expect(sonnet?.provider).toBe('opencode');
expect(sonnet?.default).toBe(true);
expect(sonnet?.modelString).toBe('amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0');
});
it('should include Claude Opus 4.5 (Bedrock)', () => {
const models = provider.getAvailableModels();
const opus = models.find(
(m) => m.id === 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0'
);
expect(opus).toBeDefined();
expect(opus?.name).toBe('Claude Opus 4.5 (Bedrock)');
expect(opus?.modelString).toBe('amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0');
});
it('should include Claude Haiku 4.5 (Bedrock)', () => {
const models = provider.getAvailableModels();
const haiku = models.find(
(m) => m.id === 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0'
);
expect(haiku).toBeDefined();
expect(haiku?.name).toBe('Claude Haiku 4.5 (Bedrock)');
expect(haiku?.tier).toBe('standard');
});
it('should include free tier Big Pickle model', () => {
const models = provider.getAvailableModels();
const bigPickle = models.find((m) => m.id === 'opencode/big-pickle');
expect(bigPickle).toBeDefined();
expect(bigPickle?.name).toBe('Big Pickle (Free)');
expect(bigPickle?.provider).toBe('opencode');
expect(bigPickle?.default).toBe(true);
expect(bigPickle?.modelString).toBe('opencode/big-pickle');
expect(bigPickle?.tier).toBe('basic');
});
it('should include free tier GLM model', () => {
it('should include DeepSeek R1 (Bedrock)', () => {
const models = provider.getAvailableModels();
const glm = models.find((m) => m.id === 'opencode/glm-4.7-free');
const deepseek = models.find((m) => m.id === 'amazon-bedrock/deepseek.r1-v1:0');
expect(glm).toBeDefined();
expect(glm?.name).toBe('GLM 4.7 Free');
expect(glm?.tier).toBe('basic');
});
it('should include free tier MiniMax model', () => {
const models = provider.getAvailableModels();
const minimax = models.find((m) => m.id === 'opencode/minimax-m2.1-free');
expect(minimax).toBeDefined();
expect(minimax?.name).toBe('MiniMax M2.1 Free');
expect(minimax?.tier).toBe('basic');
expect(deepseek).toBeDefined();
expect(deepseek?.name).toBe('DeepSeek R1 (Bedrock)');
expect(deepseek?.tier).toBe('premium');
});
it('should have all models support tools', () => {
@@ -103,24 +128,6 @@ describe('opencode-provider.ts', () => {
});
});
describe('parseModelsOutput', () => {
it('should parse nested provider model IDs', () => {
const output = ['openrouter/anthropic/claude-3.5-sonnet', 'openai/gpt-4o'].join('\n');
const parseModelsOutput = (
provider as unknown as { parseModelsOutput: (output: string) => ModelDefinition[] }
).parseModelsOutput.bind(provider);
const models = parseModelsOutput(output);
expect(models).toHaveLength(2);
const openrouterModel = models.find((model) => model.id.startsWith('openrouter/'));
expect(openrouterModel).toBeDefined();
expect(openrouterModel?.provider).toBe('openrouter');
expect(openrouterModel?.modelString).toBe('openrouter/anthropic/claude-3.5-sonnet');
});
});
describe('supportsFeature', () => {
it("should support 'tools' feature", () => {
expect(provider.supportsFeature('tools')).toBe(true);
@@ -1236,7 +1243,7 @@ describe('opencode-provider.ts', () => {
const defaultModels = models.filter((m) => m.default === true);
expect(defaultModels).toHaveLength(1);
expect(defaultModels[0].id).toBe('opencode/big-pickle');
expect(defaultModels[0].id).toBe('amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0');
});
it('should have valid tier values for all models', () => {

View File

@@ -5,61 +5,59 @@ import {
getSpecRegenerationStatus,
} from '@/routes/app-spec/common.js';
const TEST_PROJECT_PATH = '/tmp/automaker-test-project';
describe('app-spec/common.ts', () => {
beforeEach(() => {
// Reset state before each test
setRunningState(TEST_PROJECT_PATH, false, null);
setRunningState(false, null);
});
describe('setRunningState', () => {
it('should set isRunning to true when running is true', () => {
setRunningState(TEST_PROJECT_PATH, true);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true);
setRunningState(true);
expect(getSpecRegenerationStatus().isRunning).toBe(true);
});
it('should set isRunning to false when running is false', () => {
setRunningState(TEST_PROJECT_PATH, true);
setRunningState(TEST_PROJECT_PATH, false);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(false);
setRunningState(true);
setRunningState(false);
expect(getSpecRegenerationStatus().isRunning).toBe(false);
});
it('should set currentAbortController when provided', () => {
const controller = new AbortController();
setRunningState(TEST_PROJECT_PATH, true, controller);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller);
setRunningState(true, controller);
expect(getSpecRegenerationStatus().currentAbortController).toBe(controller);
});
it('should set currentAbortController to null when not provided', () => {
const controller = new AbortController();
setRunningState(TEST_PROJECT_PATH, true, controller);
setRunningState(TEST_PROJECT_PATH, false);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(null);
setRunningState(true, controller);
setRunningState(false);
expect(getSpecRegenerationStatus().currentAbortController).toBe(null);
});
it('should keep currentAbortController when explicitly passed null while running', () => {
it('should set currentAbortController to null when explicitly passed null', () => {
const controller = new AbortController();
setRunningState(TEST_PROJECT_PATH, true, controller);
setRunningState(TEST_PROJECT_PATH, true, null);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller);
setRunningState(true, controller);
setRunningState(true, null);
expect(getSpecRegenerationStatus().currentAbortController).toBe(null);
});
it('should update state multiple times correctly', () => {
const controller1 = new AbortController();
const controller2 = new AbortController();
setRunningState(TEST_PROJECT_PATH, true, controller1);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller1);
setRunningState(true, controller1);
expect(getSpecRegenerationStatus().isRunning).toBe(true);
expect(getSpecRegenerationStatus().currentAbortController).toBe(controller1);
setRunningState(TEST_PROJECT_PATH, true, controller2);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller2);
setRunningState(true, controller2);
expect(getSpecRegenerationStatus().isRunning).toBe(true);
expect(getSpecRegenerationStatus().currentAbortController).toBe(controller2);
setRunningState(TEST_PROJECT_PATH, false, null);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(false);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(null);
setRunningState(false, null);
expect(getSpecRegenerationStatus().isRunning).toBe(false);
expect(getSpecRegenerationStatus().currentAbortController).toBe(null);
});
});

View File

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

View File

@@ -8,7 +8,6 @@ import fs from 'fs/promises';
vi.mock('child_process', () => ({
spawn: vi.fn(),
execSync: vi.fn(),
execFile: vi.fn(),
}));
// Mock secure-fs

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/ui",
"version": "0.11.0",
"version": "0.9.0",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker",
"repository": {
@@ -42,8 +42,6 @@
"@automaker/dependency-resolver": "1.0.0",
"@automaker/types": "1.0.0",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/theme-one-dark": "6.1.3",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
@@ -56,7 +54,6 @@
"@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,7 +5,6 @@ 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';
@@ -25,11 +24,8 @@ export default function App() {
useEffect(() => {
if (import.meta.env.DEV) {
const clearPerfEntries = () => {
// Check if window.performance is available before calling its methods
if (window.performance) {
window.performance.clearMarks();
window.performance.clearMeasures();
}
performance.clearMarks();
performance.clearMeasures();
};
const interval = setInterval(clearPerfEntries, 5000);
return () => clearInterval(interval);
@@ -49,9 +45,6 @@ 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,7 +11,6 @@ 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;
@@ -56,12 +55,8 @@ 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: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR,
code: ERROR_CODES.AUTH_ERROR,
message: data.message || data.error,
});
return;
@@ -262,11 +257,6 @@ 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

@@ -1,220 +0,0 @@
import type { ComponentType, ComponentProps } from 'react';
import { FolderOpen } from 'lucide-react';
type IconProps = ComponentProps<'svg'>;
type IconComponent = ComponentType<IconProps>;
const ANTIGRAVITY_COMMANDS = ['antigravity', 'agy'] as const;
const [PRIMARY_ANTIGRAVITY_COMMAND, LEGACY_ANTIGRAVITY_COMMAND] = ANTIGRAVITY_COMMANDS;
/**
* Cursor editor logo icon - from LobeHub icons
*/
export function CursorIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z" />
</svg>
);
}
/**
* VS Code editor logo icon
*/
export function VSCodeIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z" />
</svg>
);
}
/**
* VS Code Insiders editor logo icon (same as VS Code)
*/
export function VSCodeInsidersIcon(props: IconProps) {
return <VSCodeIcon {...props} />;
}
/**
* Kiro editor logo icon (VS Code fork)
*/
export function KiroIcon(props: IconProps) {
return (
<svg viewBox="0 0 32 32" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M6.594.016A7.006 7.006 0 0 0 .742 3.875a6.996 6.996 0 0 0-.726 2.793C.004 6.878 0 9.93.004 16.227c.004 8.699.008 9.265.031 9.476.113.93.324 1.652.707 2.422a6.918 6.918 0 0 0 3.172 3.148c.75.372 1.508.59 2.398.692.227.027.77.027 9.688.027 8.945 0 9.457 0 9.688-.027.917-.106 1.66-.32 2.437-.707a6.918 6.918 0 0 0 3.148-3.172c.372-.75.59-1.508.692-2.398.027-.227.027-.77.027-9.665 0-9.976.004-9.53-.07-10.03a6.993 6.993 0 0 0-3.024-4.798 6.427 6.427 0 0 0-.757-.445 7.06 7.06 0 0 0-2.774-.734c-.328-.02-18.437-.02-18.773 0Zm10.789 5.406a7.556 7.556 0 0 1 6.008 3.805c.148.257.406.796.52 1.085.394 1 .632 2.157.769 3.75.035.38.05 1.965.023 2.407-.125 2.168-.625 4.183-1.515 6.078a9.77 9.77 0 0 1-.801 1.437c-.93 1.305-2.32 2.332-3.48 2.57-.895.184-1.602-.1-2.048-.827a3.42 3.42 0 0 1-.25-.528c-.035-.097-.062-.129-.086-.09-.003.008-.09.075-.191.153-.95.722-2.02 1.175-3.059 1.293-.273.03-.859.023-1.085-.016-.715-.121-1.286-.441-1.649-.93a2.563 2.563 0 0 1-.328-.632c-.117-.36-.156-.813-.117-1.227.054-.55.226-1.184.484-1.766a.48.48 0 0 0 .043-.117 2.11 2.11 0 0 0-.137.055c-.363.16-.898.305-1.308.351-.844.098-1.426-.14-1.715-.699-.106-.203-.149-.39-.16-.676-.008-.261.008-.43.066-.656.059-.23.121-.367.403-.89.382-.72.492-.946.636-1.348.328-.899.48-1.723.688-3.754.148-1.469.254-2.14.433-2.766.028-.09.078-.277.114-.414.796-3.074 3.113-5.183 6.148-5.601.129-.016.309-.04.399-.047.238-.016.96-.02 1.195 0Zm0 0" />
<path d="M16.754 11.336a.815.815 0 0 0-.375.219c-.176.18-.293.441-.356.804-.039.235-.058.602-.039.868.028.406.082.64.204.894.128.262.304.426.546.496.106.031.383.031.5 0 .422-.113.703-.531.801-1.191a4.822 4.822 0 0 0-.012-.95c-.062-.378-.183-.675-.359-.863a.808.808 0 0 0-.648-.293.804.804 0 0 0-.262.016ZM20.375 11.328a1.01 1.01 0 0 0-.363.188c-.164.144-.293.402-.364.718-.05.23-.07.426-.07.743 0 .32.02.511.07.742.11.496.352.808.688.898.121.031.379.031.5 0 .402-.105.68-.5.781-1.11.035-.198.047-.648.024-.87-.063-.63-.293-1.059-.649-1.23a1.513 1.513 0 0 0-.219-.079 1.362 1.362 0 0 0-.398 0Zm0 0" />
</svg>
);
}
/**
* Zed editor logo icon (from Simple Icons)
*/
export function ZedIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2.25 1.5a.75.75 0 0 0-.75.75v16.5H0V2.25A2.25 2.25 0 0 1 2.25 0h20.095c1.002 0 1.504 1.212.795 1.92L10.764 14.298h3.486V12.75h1.5v1.922a1.125 1.125 0 0 1-1.125 1.125H9.264l-2.578 2.578h11.689V9h1.5v9.375a1.5 1.5 0 0 1-1.5 1.5H5.185L2.562 22.5H21.75a.75.75 0 0 0 .75-.75V5.25H24v16.5A2.25 2.25 0 0 1 21.75 24H1.655C.653 24 .151 22.788.86 22.08L13.19 9.75H9.75v1.5h-1.5V9.375A1.125 1.125 0 0 1 9.375 8.25h5.314l2.625-2.625H5.625V15h-1.5V5.625a1.5 1.5 0 0 1 1.5-1.5h13.19L21.438 1.5z" />
</svg>
);
}
/**
* Sublime Text editor logo icon
*/
export function SublimeTextIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M20.953.004a.397.397 0 0 0-.18.045L3.473 8.63a.397.397 0 0 0-.033.69l4.873 3.33-5.26 2.882a.397.397 0 0 0-.006.692l17.3 9.73a.397.397 0 0 0 .593-.344V15.094a.397.397 0 0 0-.203-.346l-4.917-2.763 5.233-2.725a.397.397 0 0 0 .207-.348V.397a.397.397 0 0 0-.307-.393z" />
</svg>
);
}
/**
* macOS Finder icon
*/
export function FinderIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2.5 3A2.5 2.5 0 0 0 0 5.5v13A2.5 2.5 0 0 0 2.5 21h19a2.5 2.5 0 0 0 2.5-2.5v-13A2.5 2.5 0 0 0 21.5 3h-19zM7 8.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm10 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm-9 6c0-.276.336-.5.75-.5h6.5c.414 0 .75.224.75.5v1c0 .828-1.343 2.5-4 2.5s-4-1.672-4-2.5v-1z" />
</svg>
);
}
/**
* Windsurf editor logo icon (by Codeium) - from LobeHub icons
*/
export function WindsurfIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M23.78 5.004h-.228a2.187 2.187 0 00-2.18 2.196v4.912c0 .98-.804 1.775-1.76 1.775a1.818 1.818 0 01-1.472-.773L13.168 5.95a2.197 2.197 0 00-1.81-.95c-1.134 0-2.154.972-2.154 2.173v4.94c0 .98-.797 1.775-1.76 1.775-.57 0-1.136-.289-1.472-.773L.408 5.098C.282 4.918 0 5.007 0 5.228v4.284c0 .216.066.426.188.604l5.475 7.889c.324.466.8.812 1.351.938 1.377.316 2.645-.754 2.645-2.117V11.89c0-.98.787-1.775 1.76-1.775h.002c.586 0 1.135.288 1.472.773l4.972 7.163a2.15 2.15 0 001.81.95c1.158 0 2.151-.973 2.151-2.173v-4.939c0-.98.787-1.775 1.76-1.775h.194c.122 0 .22-.1.22-.222V5.225a.221.221 0 00-.22-.222z"
/>
</svg>
);
}
/**
* Trae editor logo icon (by ByteDance) - from LobeHub icons
*/
export function TraeIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196l-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001l-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" />
</svg>
);
}
/**
* JetBrains Rider logo icon
*/
export function RiderIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M0 0v24h24V0zm7.031 3.113A4.063 4.063 0 0 1 9.72 4.14a3.23 3.23 0 0 1 .84 2.28A3.16 3.16 0 0 1 8.4 9.54l2.46 3.6H8.28L6.12 9.9H4.38v3.24H2.16V3.12c1.61-.004 3.281.009 4.871-.007zm5.509.007h3.96c3.18 0 5.34 2.16 5.34 5.04 0 2.82-2.16 5.04-5.34 5.04h-3.96zm4.069 1.976c-.607.01-1.235.004-1.849.004v6.06h1.74a2.882 2.882 0 0 0 3.06-3 2.897 2.897 0 0 0-2.951-3.064zM4.319 5.1v2.88H6.6c1.08 0 1.68-.6 1.68-1.44 0-.96-.66-1.44-1.74-1.44zM2.16 19.5h9V21h-9Z" />
</svg>
);
}
/**
* JetBrains WebStorm logo icon
*/
export function WebStormIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M0 0v24h24V0H0zm17.889 2.889c1.444 0 2.667.444 3.667 1.278l-1.111 1.667c-.889-.611-1.722-1-2.556-1s-1.278.389-1.278.889v.056c0 .667.444.889 2.111 1.333 2 .556 3.111 1.278 3.111 3v.056c0 2-1.5 3.111-3.611 3.111-1.5-.056-3-.611-4.167-1.667l1.278-1.556c.889.722 1.833 1.222 2.944 1.222.889 0 1.389-.333 1.389-.944v-.056c0-.556-.333-.833-2-1.278-2-.5-3.222-1.056-3.222-3.056v-.056c0-1.833 1.444-3 3.444-3zm-16.111.222h2.278l1.5 5.778 1.722-5.778h1.667l1.667 5.778 1.5-5.778h2.333l-2.833 9.944H9.723L8.112 7.277l-1.667 5.778H4.612L1.779 3.111zm.5 16.389h9V21h-9v-1.5z" />
</svg>
);
}
/**
* Xcode logo icon
*/
export function XcodeIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M19.06 5.3327c.4517-.1936.7744-.2581 1.097-.1936.5163.1291.7744.5163.968.7098.1936.3872.9034.7744 1.2261.8389.2581.0645.7098-.6453 1.0325-1.2906.3227-.5808.5163-1.3552.4517-1.5488-.0645-.1936-.968-.5808-1.1616-.5808-.1291 0-.3872.1291-.8389.0645-.4517-.0645-.9034-.5808-1.1616-.968-.4517-.6453-1.097-1.0325-1.6778-1.3552-.6453-.3227-1.3552-.5163-2.065-.6453-1.0325-.2581-2.065-.4517-3.0975-.3227-.5808.0645-1.2906.1291-1.8069.3227-.0645 0-.1936.1936-.0645.1936s.5808.0645.5808.0645-.5807.1292-.5807.2583c0 .1291.0645.1291.1291.1291.0645 0 1.4842-.0645 2.065 0 .6453.1291 1.3552.4517 1.8069 1.2261.7744 1.4197.4517 2.7749.2581 3.2266-.968 2.1295-8.6472 15.2294-9.0344 16.1328-.3873.9034-.5163 1.4842.5807 2.065s1.6778.3227 2.0005-.0645c.3872-.5163 7.0339-17.1654 9.2925-18.2624zm-3.6138 8.7117h1.5488c1.0325 0 1.2261.5163 1.2261.7098.0645.5163-.1936 1.1616-1.2261 1.1616h-.968l.7744 1.2906c.4517.7744.2581 1.1616 0 1.4197-.3872.3872-1.2261.3872-1.6778-.4517l-.9034-1.5488c-.6453 1.4197-1.2906 2.9684-2.065 4.7753h4.0009c1.9359 0 3.5492-1.6133 3.5492-3.5492V6.5588c-.0645-.1291-.1936-.0645-.2581 0-.3872.4517-1.4842 2.0004-4.001 7.4856zm-9.8087 8.0019h-.3227c-2.3231 0-4.1945-1.8714-4.1945-4.1945V7.0105c0-2.3231 1.8714-4.1945 4.1945-4.1945h9.3571c-.1936-.1936-.968-.5163-1.7423-.4517-.3227 0-.968.1291-1.3552-.1291-.3872-.3227-.3227-.5163-.9034-.5163H4.9277c-2.6458 0-4.7753 2.1295-4.7753 4.7753v11.7447c0 2.6458 2.1295 4.7753 4.4527 4.7108.6452 0 .8388-.5162 1.0324-.9034zM20.4152 6.9459v10.9058c0 2.3231-1.8714 4.1945-4.1945 4.1945H11.897s-.3872 1.0325.8389 1.0325h3.8719c2.6458 0 4.7753-2.1295 4.7753-4.7753V8.8173c.0646-.9034-.7098-1.4842-.9679-1.8714zm-18.5851.0646v10.8413c0 1.9359 1.6133 3.5492 3.5492 3.5492h.5808c0-.0645.7744-1.4197 2.4522-4.2591.1936-.3872.4517-.7744.7098-1.2261H4.4114c-.5808 0-.9034-.3872-.968-.7098-.1291-.5163.1936-1.1616.9034-1.1616h2.3877l3.033-5.2916s-.7098-1.2906-.9034-1.6133c-.2582-.4517-.1291-.9034.129-1.1615.3872-.3872 1.0325-.5808 1.6778.4517l.2581.3872.2581-.3872c.5808-.8389.968-.7744 1.2906-.7098.5163.1291.8389.7098.3872 1.6133L8.864 14.0444h1.3552c.4517-.7744.9034-1.5488 1.3552-2.3877-.0645-.3227-.1291-.7098-.0645-1.0325.0645-.5163.3227-.968.6453-1.3552l.3872.6453c1.2261-2.1295 2.1295-3.9364 2.3877-4.6463.1291-.3872.3227-1.1616.1291-1.8069H5.3794c-2.0005.0001-3.5493 1.6134-3.5493 3.5494zM4.605 17.7872c0-.0645.7744-1.4197.7744-1.4197 1.2261-.3227 1.8069.4517 1.8714.5163 0 0-.8389 1.4842-1.097 1.7423s-.5808.3227-.9034.2581c-.5164-.129-.839-.6453-.6454-1.097z" />
</svg>
);
}
/**
* Android Studio logo icon
*/
export function AndroidStudioIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M19.2693 10.3368c-.3321 0-.6026.2705-.6026.6031v9.8324h-1.7379l-3.3355-6.9396c.476-.5387.6797-1.286.5243-2.0009a2.2862 2.2862 0 0 0-1.2893-1.6248v-.8124c.0121-.2871-.1426-.5787-.4043-.7407-.1391-.0825-.2884-.1234-.4402-.1234a.8478.8478 0 0 0-.4318.1182c-.2701.1671-.4248.4587-.4123.7662l-.0003.721c-1.0149.3668-1.6619 1.4153-1.4867 2.5197a2.282 2.282 0 0 0 .5916 1.2103l-3.2096 6.9064H4.0928c-1.0949-.007-1.9797-.8948-1.9832-1.9896V5.016c-.0055 1.1024.8836 2.0006 1.9859 2.0062a2.024 2.024 0 0 0 .1326-.0037h14.7453s2.5343-.2189 2.8619 1.5392c-.2491.0287-.4449.2321-.4449.4889 0 .7115-.5791 1.2901-1.3028 1.2901h-.8183zM17.222 22.5366c.2347.4837.0329 1.066-.4507 1.3007-.1296.0629-.2666.0895-.4018.0927a.9738.9738 0 0 1-.3194-.0455c-.024-.0078-.046-.0209-.0694-.0305a.9701.9701 0 0 1-.2277-.1321c-.0247-.0192-.0495-.038-.0724-.0598-.0825-.0783-.1574-.1672-.21-.2757l-1.2554-2.6143-1.5585-3.2452a.7725.7725 0 0 0-.6995-.4443h-.0024a.792.792 0 0 0-.7083.4443l-1.5109 3.2452-1.2321 2.6464a.9722.9722 0 0 1-.7985.5795c-.0626.0053-.1238-.0024-.185-.0087-.0344-.0036-.069-.0053-.1025-.0124-.0489-.0103-.0954-.0278-.142-.0452-.0301-.0113-.0613-.0197-.0901-.0339-.0496-.0244-.0948-.0565-.1397-.0889-.0217-.0156-.0457-.0275-.0662-.045a.9862.9862 0 0 1-.1695-.1844.9788.9788 0 0 1-.0708-.9852l.8469-1.8223 3.2676-7.0314a1.7964 1.7964 0 0 1-.7072-1.1637c-.1555-.9799.5129-1.9003 1.4928-2.0559V9.3946a.3542.3542 0 0 1 .1674-.3155.3468.3468 0 0 1 .3541 0 .354.354 0 0 1 .1674.3155v1.159l.0129.0064a1.8028 1.8028 0 0 1 1.2878 1.378 1.7835 1.7835 0 0 1-.6439 1.7836l3.3889 7.0507.8481 1.7643zM12.9841 12.306c.0042-.6081-.4854-1.1044-1.0935-1.1085a1.1204 1.1204 0 0 0-.7856.3219 1.101 1.101 0 0 0-.323.7716c-.0042.6081.4854 1.1044 1.0935 1.1085h.0077c.6046 0 1.0967-.488 1.1009-1.0935zm-1.027 5.2768c-.1119.0005-.2121.0632-.2571.1553l-1.4127 3.0342h3.3733l-1.4564-3.0328a.274.274 0 0 0-.2471-.1567zm8.1432-6.7459l-.0129-.0001h-.8177a.103.103 0 0 0-.103.103v12.9103a.103.103 0 0 0 .0966.103h.8435c.9861-.0035 1.7836-.804 1.7836-1.79V9.0468c0 .9887-.8014 1.7901-1.7901 1.7901zM2.6098 5.0161v.019c.0039.816.6719 1.483 1.4874 1.4869a12.061 12.061 0 0 1 .1309-.0034h1.1286c.1972-1.315.7607-2.525 1.638-3.4859H4.0993c-.9266.0031-1.6971.6401-1.9191 1.4975.2417.0355.4296.235.4296.4859zm6.3381-2.8977L7.9112.3284a.219.219 0 0 1 0-.2189A.2384.2384 0 0 1 8.098 0a.219.219 0 0 1 .1867.1094l1.0496 1.8158a6.4907 6.4907 0 0 1 5.3186 0L15.696.1094a.2189.2189 0 0 1 .3734.2189l-1.0302 1.79c1.6671.9125 2.7974 2.5439 3.0975 4.4018l-12.286-.0014c.3004-1.8572 1.4305-3.488 3.0972-4.4003zm5.3774 2.6202a.515.515 0 0 0 .5271.5028.515.515 0 0 0 .5151-.5151.5213.5213 0 0 0-.8885-.367.5151.5151 0 0 0-.1537.3793zm-5.7178-.0067a.5151.5151 0 0 0 .5207.5095.5086.5086 0 0 0 .367-.1481.5215.5215 0 1 0-.734-.7341.515.515 0 0 0-.1537.3727z" />
</svg>
);
}
/**
* Google Antigravity IDE logo icon - stylized "A" arch shape
*/
export function AntigravityIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 1C11 1 9.5 3 8 7c-1.5 4-3 8.5-4 11.5-.5 1.5-.3 2.8.5 3.3.8.5 2 .2 3-.8.8-.8 1.3-2 1.8-3.2.3-.8.8-1.3 1.5-1.3h2.4c.7 0 1.2.5 1.5 1.3.5 1.2 1 2.4 1.8 3.2 1 1 2.2 1.3 3 .8.8-.5 1-1.8.5-3.3-1-3-2.5-7.5-4-11.5C14.5 3 13 1 12 1zm0 5c.8 2 2 5.5 3 8.5H9c1-3 2.2-6.5 3-8.5z"
/>
</svg>
);
}
/**
* Get the appropriate icon component for an editor command
*/
export function getEditorIcon(command: string): IconComponent {
// Handle direct CLI commands
const cliIcons: Record<string, IconComponent> = {
cursor: CursorIcon,
code: VSCodeIcon,
'code-insiders': VSCodeInsidersIcon,
kido: KiroIcon,
zed: ZedIcon,
subl: SublimeTextIcon,
windsurf: WindsurfIcon,
trae: TraeIcon,
rider: RiderIcon,
webstorm: WebStormIcon,
xed: XcodeIcon,
studio: AndroidStudioIcon,
[PRIMARY_ANTIGRAVITY_COMMAND]: AntigravityIcon,
[LEGACY_ANTIGRAVITY_COMMAND]: AntigravityIcon,
open: FinderIcon,
explorer: FolderOpen,
'xdg-open': FolderOpen,
};
// Check direct match first
if (cliIcons[command]) {
return cliIcons[command];
}
// Handle 'open' commands (macOS) - both 'open -a AppName' and 'open "/path/to/App.app"'
if (command.startsWith('open')) {
const cmdLower = command.toLowerCase();
if (cmdLower.includes('cursor')) return CursorIcon;
if (cmdLower.includes('visual studio code - insiders')) return VSCodeInsidersIcon;
if (cmdLower.includes('visual studio code')) return VSCodeIcon;
if (cmdLower.includes('kiro')) return KiroIcon;
if (cmdLower.includes('zed')) return ZedIcon;
if (cmdLower.includes('sublime')) return SublimeTextIcon;
if (cmdLower.includes('windsurf')) return WindsurfIcon;
if (cmdLower.includes('trae')) return TraeIcon;
if (cmdLower.includes('rider')) return RiderIcon;
if (cmdLower.includes('webstorm')) return WebStormIcon;
if (cmdLower.includes('xcode')) return XcodeIcon;
if (cmdLower.includes('android studio')) return AndroidStudioIcon;
if (cmdLower.includes('antigravity')) return AntigravityIcon;
// If just 'open' without app name, it's Finder
if (command === 'open') return FinderIcon;
}
return FolderOpen;
}

View File

@@ -1,187 +0,0 @@
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

@@ -1,520 +0,0 @@
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

@@ -1,4 +0,0 @@
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

@@ -1,333 +0,0 @@
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

@@ -1,116 +0,0 @@
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

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

View File

@@ -1,486 +0,0 @@
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,6 +18,7 @@ import {
CollapseToggleButton,
SidebarHeader,
SidebarNavigation,
ProjectSelectorWithOptions,
SidebarFooter,
} from './sidebar/components';
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
@@ -63,6 +64,9 @@ 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);
@@ -236,6 +240,7 @@ export function Sidebar() {
navigate,
toggleSidebar,
handleOpenFolder,
setIsProjectPickerOpen,
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
@@ -252,114 +257,110 @@ export function Sidebar() {
};
return (
<>
{/* Mobile backdrop overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={toggleSidebar}
data-testid="sidebar-backdrop"
/>
<aside
className={cn(
'flex-shrink-0 flex flex-col z-30 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)]',
// Smooth width transition
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
sidebarOpen ? 'w-16 lg:w-72' : 'w-16'
)}
<aside
className={cn(
'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: 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"
>
<CollapseToggleButton
data-testid="sidebar"
>
<CollapseToggleButton
sidebarOpen={sidebarOpen}
toggleSidebar={toggleSidebar}
shortcut={shortcuts.toggleSidebar}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
<ProjectSelectorWithOptions
sidebarOpen={sidebarOpen}
toggleSidebar={toggleSidebar}
shortcut={shortcuts.toggleSidebar}
isProjectPickerOpen={isProjectPickerOpen}
setIsProjectPickerOpen={setIsProjectPickerOpen}
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} currentProject={currentProject} />
<SidebarNavigation
currentProject={currentProject}
sidebarOpen={sidebarOpen}
navSections={navSections}
isActiveRoute={isActiveRoute}
navigate={navigate}
/>
</div>
<SidebarFooter
<SidebarNavigation
currentProject={currentProject}
sidebarOpen={sidebarOpen}
navSections={navSections}
isActiveRoute={isActiveRoute}
navigate={navigate}
hideWiki={hideWiki}
hideRunningAgents={hideRunningAgents}
runningAgentsCount={runningAgentsCount}
shortcuts={{ settings: shortcuts.settings }}
/>
<TrashDialog
open={showTrashDialog}
onOpenChange={setShowTrashDialog}
trashedProjects={trashedProjects}
activeTrashId={activeTrashId}
handleRestoreProject={handleRestoreProject}
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
deleteTrashedProject={deleteTrashedProject}
handleEmptyTrash={handleEmptyTrash}
isEmptyingTrash={isEmptyingTrash}
/>
</div>
{/* New Project Setup Dialog */}
<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."
/>
<SidebarFooter
sidebarOpen={sidebarOpen}
isActiveRoute={isActiveRoute}
navigate={navigate}
hideWiki={hideWiki}
hideRunningAgents={hideRunningAgents}
runningAgentsCount={runningAgentsCount}
shortcuts={{ settings: shortcuts.settings }}
/>
<TrashDialog
open={showTrashDialog}
onOpenChange={setShowTrashDialog}
trashedProjects={trashedProjects}
activeTrashId={activeTrashId}
handleRestoreProject={handleRestoreProject}
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
deleteTrashedProject={deleteTrashedProject}
handleEmptyTrash={handleEmptyTrash}
isEmptyingTrash={isEmptyingTrash}
/>
<OnboardingDialog
open={showOnboardingDialog}
onOpenChange={setShowOnboardingDialog}
newProjectName={newProjectName}
onSkip={handleOnboardingSkip}
onGenerateSpec={handleOnboardingGenerateSpec}
/>
{/* New Project Setup Dialog */}
<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."
/>
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteProjectDialog}
onOpenChange={setShowDeleteProjectDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
<OnboardingDialog
open={showOnboardingDialog}
onOpenChange={setShowOnboardingDialog}
newProjectName={newProjectName}
onSkip={handleOnboardingSkip}
onGenerateSpec={handleOnboardingGenerateSpec}
/>
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreatingProject}
/>
</aside>
</>
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteProjectDialog}
onOpenChange={setShowDeleteProjectDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreatingProject}
/>
</aside>
);
}

View File

@@ -35,11 +35,11 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
onClick={() => navigate({ to: '/dashboard' })}
data-testid="logo-button"
>
{/* Collapsed logo - only shown when sidebar is closed */}
{/* Collapsed logo - shown when sidebar is closed OR on small screens when sidebar is open */}
<div
className={cn(
'relative flex flex-col items-center justify-center rounded-lg gap-0.5',
sidebarOpen ? 'hidden' : 'flex'
sidebarOpen ? 'flex lg:hidden' : 'flex'
)}
>
<svg
@@ -90,16 +90,16 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
</span>
</div>
{/* Expanded logo - shown when sidebar is open */}
{/* Expanded logo - only shown when sidebar is open on large screens */}
{sidebarOpen && (
<div className="flex flex-col">
<div className="hidden lg:flex flex-col">
<div className="flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="automaker"
className="h-8 w-8 lg:h-[36.8px] lg:w-[36.8px] shrink-0 group-hover:rotate-12 transition-transform duration-300 ease-out"
className="h-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient
@@ -137,11 +137,11 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
<span className="font-bold text-foreground text-xl lg:text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
<span className="font-bold text-foreground text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
automaker<span className="text-brand-500">.</span>
</span>
</div>
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium ml-9 lg:ml-[38.8px]">
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium ml-[38.8px]">
v{appVersion} {versionSuffix}
</span>
</div>

View File

@@ -17,7 +17,7 @@ export function CollapseToggleButton({
<button
onClick={toggleSidebar}
className={cn(
'flex absolute top-[68px] -right-3 z-9999',
'hidden lg: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 block whitespace-nowrap">New</span>
<span className="ml-2 text-sm font-medium hidden lg: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="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="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">
{formatShortcut(shortcuts.openProject, true)}
</span>
</button>

View File

@@ -117,7 +117,7 @@ export function ProjectSelectorWithOptions({
</div>
<div className="flex items-center gap-1.5">
<span
className="hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted text-muted-foreground"
className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted text-muted-foreground"
data-testid="project-picker-shortcut"
>
{formatShortcut(shortcuts.projectPicker, true)}
@@ -219,7 +219,7 @@ export function ProjectSelectorWithOptions({
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex items-center justify-center w-[42px] h-[42px] rounded-lg',
'hidden lg:flex items-center justify-center w-[42px] h-[42px] rounded-lg',
'text-muted-foreground hover:text-foreground',
'bg-transparent hover:bg-accent/60',
'border border-border/50 hover:border-border',

View File

@@ -72,7 +72,7 @@ export function SidebarFooter({
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'block' : 'hidden'
sidebarOpen ? 'hidden lg:block' : 'hidden'
)}
>
Wiki
@@ -148,7 +148,7 @@ export function SidebarFooter({
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'block' : 'hidden'
sidebarOpen ? 'hidden lg:block' : 'hidden'
)}
>
Running Agents
@@ -157,7 +157,7 @@ export function SidebarFooter({
{sidebarOpen && runningAgentsCount > 0 && (
<span
className={cn(
'flex items-center justify-center',
'hidden lg:flex items-center justify-center',
'min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full',
'bg-brand-500 text-white shadow-sm',
'animate-in fade-in zoom-in duration-200',
@@ -227,7 +227,7 @@ export function SidebarFooter({
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'block' : 'hidden'
sidebarOpen ? 'hidden lg:block' : 'hidden'
)}
>
Settings
@@ -235,7 +235,7 @@ export function SidebarFooter({
{sidebarOpen && (
<span
className={cn(
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
isActiveRoute('settings')
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'

View File

@@ -1,67 +1,43 @@
import { Folder, LucideIcon } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import type { NavigateOptions } from '@tanstack/react-router';
import { cn, isMac } from '@/lib/utils';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { isElectron, type Project } from '@/lib/electron';
import { AutomakerLogo } from './automaker-logo';
import { BugReportButton } from './bug-report-button';
interface SidebarHeaderProps {
sidebarOpen: boolean;
currentProject: Project | null;
navigate: (opts: NavigateOptions) => void;
}
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;
};
const IconComponent = getIconComponent();
const hasCustomIcon = !!currentProject?.customIconPath;
export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) {
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>
<>
{/* 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-3 lg:px-5 justify-start' : 'px-3 justify-center',
// Add left padding on macOS to avoid overlapping with traffic light buttons (only when expanded)
isMac && sidebarOpen && 'pt-4 pl-20',
// 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>
{/* 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>
)}
{/* Bug Report Button - Collapsed sidebar version */}
{!sidebarOpen && (
<div className="px-3 mt-1.5 flex justify-center">
<BugReportButton sidebarExpanded={false} />
</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-1' : 'mt-1')}>
<nav className={cn('flex-1 overflow-y-auto px-3 pb-2', sidebarOpen ? 'mt-5' : '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="block">Select or create a project above</span>
<span className="hidden lg:block">Select or create a project above</span>
</p>
</div>
) : currentProject ? (
@@ -35,7 +35,7 @@ export function SidebarNavigation({
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? 'mt-6' : ''}>
{/* Section Label */}
{section.label && sidebarOpen && (
<div className="px-3 mb-2">
<div className="hidden lg:block px-3 mb-2">
<span className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
{section.label}
</span>
@@ -115,7 +115,7 @@ export function SidebarNavigation({
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'block' : 'hidden'
sidebarOpen ? 'hidden lg:block' : 'hidden'
)}
>
{item.label}
@@ -124,7 +124,7 @@ export function SidebarNavigation({
{item.count !== undefined && item.count > 0 && sidebarOpen && (
<span
className={cn(
'flex items-center justify-center',
'hidden lg:flex items-center justify-center',
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
'bg-primary text-primary-foreground shadow-sm',
'animate-in fade-in zoom-in duration-200'
@@ -137,7 +137,7 @@ export function SidebarNavigation({
{item.shortcut && sidebarOpen && !item.count && (
<span
className={cn(
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
isActive
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'

View File

@@ -45,6 +45,7 @@ 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 */
@@ -64,6 +65,7 @@ export function useNavigation({
navigate,
toggleSidebar,
handleOpenFolder,
setIsProjectPickerOpen,
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
@@ -228,6 +230,15 @@ 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({
@@ -277,6 +288,7 @@ export function useNavigation({
cyclePrevProject,
cycleNextProject,
navSections,
setIsProjectPickerOpen,
]);
return {

View File

@@ -5,3 +5,17 @@ export {
type UseModelOverrideOptions,
type UseModelOverrideResult,
} from './use-model-override';
// Onboarding Wizard Components
export {
OnboardingWizard,
useOnboardingWizard,
ONBOARDING_STORAGE_PREFIX,
ONBOARDING_TARGET_ATTRIBUTE,
ONBOARDING_ANALYTICS,
type OnboardingStep,
type OnboardingState,
type OnboardingWizardProps,
type UseOnboardingWizardOptions,
type UseOnboardingWizardResult,
} from './onboarding';

View File

@@ -0,0 +1,55 @@
/**
* Shared Onboarding Wizard Constants
*
* Layout, positioning, and timing constants for the onboarding wizard.
*/
/** Storage key prefix for onboarding state */
export const ONBOARDING_STORAGE_PREFIX = 'automaker:onboarding';
/** Padding around spotlight highlight elements (px) */
export const SPOTLIGHT_PADDING = 8;
/** Padding between target element and tooltip (px) */
export const TOOLTIP_OFFSET = 16;
/** Vertical offset from top of target to tooltip (px) */
export const TOOLTIP_TOP_OFFSET = 40;
/** Maximum tooltip width (px) */
export const TOOLTIP_MAX_WIDTH = 400;
/** Minimum safe margin from viewport edges (px) */
export const VIEWPORT_SAFE_MARGIN = 16;
/** Threshold for placing tooltip to the right of target (30% of viewport) */
export const TOOLTIP_POSITION_RIGHT_THRESHOLD = 0.3;
/** Threshold for placing tooltip to the left of target (70% of viewport) */
export const TOOLTIP_POSITION_LEFT_THRESHOLD = 0.7;
/** Threshold from bottom of viewport to trigger alternate positioning (px) */
export const BOTTOM_THRESHOLD = 450;
/** Debounce delay for resize handler (ms) */
export const RESIZE_DEBOUNCE_MS = 100;
/** Animation duration for step transitions (ms) */
export const STEP_TRANSITION_DURATION = 200;
/** ID for the wizard description element (for aria-describedby) */
export const WIZARD_DESCRIPTION_ID = 'onboarding-wizard-description';
/** ID for the wizard title element (for aria-labelledby) */
export const WIZARD_TITLE_ID = 'onboarding-wizard-title';
/** Data attribute name for targeting elements */
export const ONBOARDING_TARGET_ATTRIBUTE = 'data-onboarding-target';
/** Analytics event names for onboarding tracking */
export const ONBOARDING_ANALYTICS = {
STARTED: 'onboarding_started',
COMPLETED: 'onboarding_completed',
SKIPPED: 'onboarding_skipped',
STEP_VIEWED: 'onboarding_step_viewed',
} as const;

View File

@@ -0,0 +1,21 @@
/**
* Shared Onboarding Components
*
* Generic onboarding wizard infrastructure for building
* interactive tutorials across different views.
*/
export { OnboardingWizard } from './onboarding-wizard';
export { useOnboardingWizard } from './use-onboarding-wizard';
export type {
OnboardingStep,
OnboardingState,
OnboardingWizardProps,
UseOnboardingWizardOptions,
UseOnboardingWizardResult,
} from './types';
export {
ONBOARDING_STORAGE_PREFIX,
ONBOARDING_TARGET_ATTRIBUTE,
ONBOARDING_ANALYTICS,
} from './constants';

View File

@@ -0,0 +1,545 @@
/**
* Generic Onboarding Wizard Component
*
* A multi-step wizard overlay that guides users through features
* with visual highlighting (spotlight effect) on target elements.
*
* Features:
* - Spotlight overlay targeting elements via data-onboarding-target
* - Responsive tooltip positioning (left/right/bottom)
* - Step navigation (keyboard & mouse)
* - Configurable children slot for view-specific content
* - Completion celebration animation
* - Full accessibility (ARIA, focus management)
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { createPortal } from 'react-dom';
import { X, ChevronLeft, ChevronRight, CheckCircle2, PartyPopper, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
SPOTLIGHT_PADDING,
TOOLTIP_OFFSET,
TOOLTIP_TOP_OFFSET,
TOOLTIP_MAX_WIDTH,
VIEWPORT_SAFE_MARGIN,
TOOLTIP_POSITION_RIGHT_THRESHOLD,
TOOLTIP_POSITION_LEFT_THRESHOLD,
BOTTOM_THRESHOLD,
RESIZE_DEBOUNCE_MS,
STEP_TRANSITION_DURATION,
WIZARD_DESCRIPTION_ID,
WIZARD_TITLE_ID,
ONBOARDING_TARGET_ATTRIBUTE,
} from './constants';
import type { OnboardingWizardProps, OnboardingStep } from './types';
interface HighlightRect {
top: number;
left: number;
right: number;
bottom: number;
width: number;
height: number;
}
export function OnboardingWizard({
isVisible,
currentStep,
currentStepData,
totalSteps,
onNext,
onPrevious,
onSkip,
onComplete,
steps,
children,
}: OnboardingWizardProps) {
const [highlightRect, setHighlightRect] = useState<HighlightRect | null>(null);
const [tooltipPosition, setTooltipPosition] = useState<'left' | 'right' | 'bottom'>('bottom');
const [isAnimating, setIsAnimating] = useState(false);
const [showCompletionCelebration, setShowCompletionCelebration] = useState(false);
// Refs for focus management
const dialogRef = useRef<HTMLDivElement>(null);
const nextButtonRef = useRef<HTMLButtonElement>(null);
// Detect if user is on a touch device
const [isTouchDevice, setIsTouchDevice] = useState(false);
useEffect(() => {
setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0);
}, []);
// Lock scroll when wizard is visible
useEffect(() => {
if (!isVisible) return;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}, [isVisible]);
// Focus management - move focus to dialog when opened
useEffect(() => {
if (!isVisible) return;
const timer = setTimeout(() => {
nextButtonRef.current?.focus();
}, STEP_TRANSITION_DURATION);
return () => clearTimeout(timer);
}, [isVisible]);
// Animate step transitions
useEffect(() => {
if (!isVisible) return;
setIsAnimating(true);
const timer = setTimeout(() => {
setIsAnimating(false);
}, STEP_TRANSITION_DURATION);
return () => clearTimeout(timer);
}, [currentStep, isVisible]);
// Find and highlight the target element
useEffect(() => {
if (!isVisible || !currentStepData) {
setHighlightRect(null);
return;
}
const updateHighlight = () => {
// Find target element by data-onboarding-target attribute
const targetEl = document.querySelector(
`[${ONBOARDING_TARGET_ATTRIBUTE}="${currentStepData.targetId}"]`
);
if (targetEl) {
const rect = targetEl.getBoundingClientRect();
setHighlightRect({
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
});
// Determine tooltip position based on target position and available space
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const targetCenter = rect.left + rect.width / 2;
const tooltipWidth = Math.min(TOOLTIP_MAX_WIDTH, viewportWidth - VIEWPORT_SAFE_MARGIN * 2);
const spaceAtBottom = viewportHeight - rect.bottom - TOOLTIP_OFFSET;
const spaceAtRight = viewportWidth - rect.right - TOOLTIP_OFFSET;
const spaceAtLeft = rect.left - TOOLTIP_OFFSET;
// For leftmost targets, prefer right position
if (
targetCenter < viewportWidth * TOOLTIP_POSITION_RIGHT_THRESHOLD &&
spaceAtRight >= tooltipWidth
) {
setTooltipPosition('right');
}
// For rightmost targets, prefer left position
else if (
targetCenter > viewportWidth * TOOLTIP_POSITION_LEFT_THRESHOLD &&
spaceAtLeft >= tooltipWidth
) {
setTooltipPosition('left');
}
// For middle targets, check if bottom position would work
else if (spaceAtBottom >= BOTTOM_THRESHOLD) {
setTooltipPosition('bottom');
}
// Fallback logic
else if (spaceAtRight > spaceAtLeft && spaceAtRight >= tooltipWidth * 0.6) {
setTooltipPosition('right');
} else if (spaceAtLeft >= tooltipWidth * 0.6) {
setTooltipPosition('left');
} else {
setTooltipPosition('bottom');
}
}
};
updateHighlight();
// Debounced resize handler
let resizeTimeout: ReturnType<typeof setTimeout>;
const handleResize = () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(updateHighlight, RESIZE_DEBOUNCE_MS);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
clearTimeout(resizeTimeout);
};
}, [isVisible, currentStepData]);
// Keyboard navigation
useEffect(() => {
if (!isVisible) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onSkip();
} else if (e.key === 'ArrowRight' || e.key === 'Enter') {
if (currentStep < totalSteps - 1) {
onNext();
} else {
handleComplete();
}
} else if (e.key === 'ArrowLeft') {
onPrevious();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isVisible, currentStep, totalSteps, onNext, onPrevious, onSkip]);
// Calculate tooltip styles based on position and highlight rect
const getTooltipStyles = useCallback((): React.CSSProperties => {
if (!highlightRect) return {};
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const tooltipWidth = Math.min(TOOLTIP_MAX_WIDTH, viewportWidth - VIEWPORT_SAFE_MARGIN * 2);
switch (tooltipPosition) {
case 'right': {
const topPos = Math.max(VIEWPORT_SAFE_MARGIN, highlightRect.top + TOOLTIP_TOP_OFFSET);
const availableHeight = viewportHeight - topPos - VIEWPORT_SAFE_MARGIN;
return {
position: 'fixed',
top: topPos,
left: highlightRect.right + TOOLTIP_OFFSET,
width: tooltipWidth,
maxWidth: `calc(100vw - ${highlightRect.right + TOOLTIP_OFFSET * 2}px)`,
maxHeight: Math.max(200, availableHeight),
};
}
case 'left': {
const topPos = Math.max(VIEWPORT_SAFE_MARGIN, highlightRect.top + TOOLTIP_TOP_OFFSET);
const availableHeight = viewportHeight - topPos - VIEWPORT_SAFE_MARGIN;
return {
position: 'fixed',
top: topPos,
right: viewportWidth - highlightRect.left + TOOLTIP_OFFSET,
width: tooltipWidth,
maxWidth: `calc(${highlightRect.left - TOOLTIP_OFFSET * 2}px)`,
maxHeight: Math.max(200, availableHeight),
};
}
case 'bottom':
default: {
const idealTop = highlightRect.bottom + TOOLTIP_OFFSET;
const availableHeight = viewportHeight - idealTop - VIEWPORT_SAFE_MARGIN;
const minTop = 100;
const topPos =
availableHeight < 250
? Math.max(
minTop,
viewportHeight - Math.max(300, availableHeight) - VIEWPORT_SAFE_MARGIN
)
: idealTop;
const idealLeft = highlightRect.left + highlightRect.width / 2 - tooltipWidth / 2;
const leftPos = Math.max(
VIEWPORT_SAFE_MARGIN,
Math.min(idealLeft, viewportWidth - tooltipWidth - VIEWPORT_SAFE_MARGIN)
);
return {
position: 'fixed',
top: topPos,
left: leftPos,
width: tooltipWidth,
maxHeight: Math.max(200, viewportHeight - topPos - VIEWPORT_SAFE_MARGIN),
};
}
}
}, [highlightRect, tooltipPosition]);
// Handle completion with celebration
const handleComplete = useCallback(() => {
setShowCompletionCelebration(true);
setTimeout(() => {
setShowCompletionCelebration(false);
onComplete();
}, 1200);
}, [onComplete]);
// Handle step indicator click for direct navigation
const handleStepClick = useCallback(
(stepIndex: number) => {
if (stepIndex === currentStep) return;
if (stepIndex > currentStep) {
for (let i = currentStep; i < stepIndex; i++) {
onNext();
}
} else {
for (let i = currentStep; i > stepIndex; i--) {
onPrevious();
}
}
},
[currentStep, onNext, onPrevious]
);
if (!isVisible || !currentStepData) return null;
const StepIcon = currentStepData.icon || Sparkles;
const isLastStep = currentStep === totalSteps - 1;
const isFirstStep = currentStep === 0;
const content = (
<div
ref={dialogRef}
className="fixed inset-0 z-[100]"
role="dialog"
aria-modal="true"
aria-labelledby={WIZARD_TITLE_ID}
aria-describedby={WIZARD_DESCRIPTION_ID}
>
{/* Completion celebration overlay */}
{showCompletionCelebration && (
<div className="absolute inset-0 z-[102] flex items-center justify-center pointer-events-none">
<div className="animate-in zoom-in-50 fade-in duration-300 flex flex-col items-center gap-4 text-white">
<PartyPopper className="w-16 h-16 text-yellow-400 animate-bounce" />
<p className="text-2xl font-bold">You're all set!</p>
</div>
</div>
)}
{/* Dark overlay with cutout for highlighted element */}
<svg
className="absolute inset-0 w-full h-full pointer-events-none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<mask id="spotlight-mask">
<rect x="0" y="0" width="100%" height="100%" fill="white" />
{highlightRect && (
<rect
x={highlightRect.left - SPOTLIGHT_PADDING}
y={highlightRect.top - SPOTLIGHT_PADDING}
width={highlightRect.width + SPOTLIGHT_PADDING * 2}
height={highlightRect.height + SPOTLIGHT_PADDING * 2}
rx="16"
fill="black"
/>
)}
</mask>
</defs>
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="rgba(0, 0, 0, 0.75)"
mask="url(#spotlight-mask)"
className="transition-all duration-300"
/>
</svg>
{/* Highlight border around the target element */}
{highlightRect && (
<div
className="absolute pointer-events-none transition-all duration-300 ease-out"
style={{
left: highlightRect.left - SPOTLIGHT_PADDING,
top: highlightRect.top - SPOTLIGHT_PADDING,
width: highlightRect.width + SPOTLIGHT_PADDING * 2,
height: highlightRect.height + SPOTLIGHT_PADDING * 2,
borderRadius: '16px',
border: '2px solid hsl(var(--primary))',
boxShadow:
'0 0 20px 4px hsl(var(--primary) / 0.3), inset 0 0 20px 4px hsl(var(--primary) / 0.1)',
}}
/>
)}
{/* Skip button - top right */}
<Button
variant="ghost"
size="sm"
className={cn(
'fixed top-4 right-4 z-[101]',
'text-white/70 hover:text-white hover:bg-white/10',
'focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-transparent',
'min-h-[44px] min-w-[44px] px-3'
)}
onClick={onSkip}
aria-label="Skip the onboarding tour"
>
<X className="w-4 h-4 mr-1.5" aria-hidden="true" />
<span>Skip Tour</span>
</Button>
{/* Tooltip card with step content */}
<div
className={cn(
'z-[101] bg-popover/95 backdrop-blur-xl rounded-xl shadow-2xl border border-border/50',
'p-6 animate-in fade-in-0 slide-in-from-bottom-4 duration-300',
'max-h-[calc(100vh-100px)] overflow-y-auto',
isAnimating && 'opacity-90 scale-[0.98]',
'transition-all duration-200 ease-out'
)}
style={getTooltipStyles()}
>
{/* Header */}
<div className="flex items-start gap-4 mb-4">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10 border border-primary/20 shrink-0">
<StepIcon className="w-6 h-6 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h3 id={WIZARD_TITLE_ID} className="text-lg font-semibold text-foreground truncate">
{currentStepData.title}
</h3>
<div className="flex items-center gap-2 mt-1.5">
<span className="text-xs text-muted-foreground" aria-live="polite">
Step {currentStep + 1} of {totalSteps}
</span>
{/* Step indicators - clickable for navigation */}
<nav aria-label="Wizard steps" className="flex items-center gap-1">
{Array.from({ length: totalSteps }).map((_, i) => (
<button
key={i}
type="button"
onClick={() => handleStepClick(i)}
className={cn(
'relative flex items-center justify-center',
'w-6 h-6',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:rounded-full',
'transition-transform duration-200 hover:scale-110'
)}
aria-label={`Go to step ${i + 1}: ${steps[i]?.title}`}
aria-current={i === currentStep ? 'step' : undefined}
>
<span
className={cn(
'block rounded-full transition-all duration-200',
i === currentStep
? 'w-2.5 h-2.5 bg-primary ring-2 ring-primary/30 ring-offset-1 ring-offset-popover'
: i < currentStep
? 'w-2 h-2 bg-primary/60'
: 'w-2 h-2 bg-muted-foreground/40'
)}
/>
</button>
))}
</nav>
</div>
</div>
</div>
{/* Description */}
<p
id={WIZARD_DESCRIPTION_ID}
className="text-sm text-muted-foreground leading-relaxed mb-4"
>
{currentStepData.description}
</p>
{/* Tip box */}
{currentStepData.tip && (
<div className="rounded-lg bg-primary/5 border border-primary/10 p-3 mb-4">
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">Tip: </span>
{currentStepData.tip}
</p>
</div>
)}
{/* Custom content slot (e.g., Quick Start section) */}
{children}
{/* Navigation buttons */}
<div className="flex items-center justify-between gap-3 pt-2">
<Button
variant="ghost"
size="sm"
onClick={onPrevious}
disabled={isFirstStep}
className={cn(
'text-muted-foreground min-h-[44px]',
'focus-visible:ring-2 focus-visible:ring-primary',
isFirstStep && 'invisible'
)}
aria-label="Go to previous step"
>
<ChevronLeft className="w-4 h-4 mr-1" aria-hidden="true" />
<span>Previous</span>
</Button>
<Button
ref={nextButtonRef}
size="sm"
onClick={isLastStep ? handleComplete : onNext}
disabled={showCompletionCelebration}
className={cn(
'bg-primary hover:bg-primary/90 text-primary-foreground',
'min-w-[120px] min-h-[44px]',
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
'transition-all duration-200'
)}
aria-label={isLastStep ? 'Complete the tour and get started' : 'Go to next step'}
>
{isLastStep ? (
<>
<span>Get Started</span>
<CheckCircle2 className="w-4 h-4 ml-1.5" aria-hidden="true" />
</>
) : (
<>
<span>Next</span>
<ChevronRight className="w-4 h-4 ml-1" aria-hidden="true" />
</>
)}
</Button>
</div>
{/* Keyboard hints - hidden on touch devices */}
{!isTouchDevice && (
<div
className="mt-4 pt-3 border-t border-border/50 flex items-center justify-center gap-4 text-xs text-muted-foreground/70"
aria-hidden="true"
>
<span className="flex items-center gap-1.5">
<kbd className="px-2 py-1 rounded bg-muted text-muted-foreground font-mono text-[11px] shadow-sm">
ESC
</kbd>
<span>to skip</span>
</span>
<span className="flex items-center gap-1.5">
<kbd className="px-2 py-1 rounded bg-muted text-muted-foreground font-mono text-[11px] shadow-sm">
</kbd>
<kbd className="px-2 py-1 rounded bg-muted text-muted-foreground font-mono text-[11px] shadow-sm">
</kbd>
<span>to navigate</span>
</span>
</div>
)}
</div>
</div>
);
// Render in a portal to ensure it's above everything
return createPortal(content, document.body);
}

View File

@@ -0,0 +1,109 @@
/**
* Shared Onboarding Wizard Types
*
* Generic types for building onboarding wizards across different views.
*/
import type { ComponentType } from 'react';
/**
* Represents a single step in the onboarding wizard
*/
export interface OnboardingStep {
/** Unique identifier for this step */
id: string;
/** Target element ID - matches data-onboarding-target attribute */
targetId: string;
/** Step title displayed in the wizard */
title: string;
/** Main description explaining this step */
description: string;
/** Optional tip shown in a highlighted box */
tip?: string;
/** Optional icon component for visual identification */
icon?: ComponentType<{ className?: string }>;
}
/**
* Persisted onboarding state structure
*/
export interface OnboardingState {
/** Whether the wizard has been completed */
completed: boolean;
/** ISO timestamp when completed */
completedAt?: string;
/** Whether the wizard has been skipped */
skipped: boolean;
/** ISO timestamp when skipped */
skippedAt?: string;
}
/**
* Options for the useOnboardingWizard hook
*/
export interface UseOnboardingWizardOptions {
/** Unique storage key for localStorage persistence */
storageKey: string;
/** Array of wizard steps to display */
steps: OnboardingStep[];
/** Optional callback when wizard is completed */
onComplete?: () => void;
/** Optional callback when wizard is skipped */
onSkip?: () => void;
}
/**
* Return type for the useOnboardingWizard hook
*/
export interface UseOnboardingWizardResult {
/** Whether the wizard is currently visible */
isVisible: boolean;
/** Current step index (0-based) */
currentStep: number;
/** Current step data or null if not available */
currentStepData: OnboardingStep | null;
/** Total number of steps */
totalSteps: number;
/** Navigate to the next step */
goToNextStep: () => void;
/** Navigate to the previous step */
goToPreviousStep: () => void;
/** Navigate to a specific step by index */
goToStep: (step: number) => void;
/** Start/show the wizard from the beginning */
startWizard: () => void;
/** Complete the wizard and hide it */
completeWizard: () => void;
/** Skip the wizard and hide it */
skipWizard: () => void;
/** Whether the wizard has been completed */
isCompleted: boolean;
/** Whether the wizard has been skipped */
isSkipped: boolean;
}
/**
* Props for the OnboardingWizard component
*/
export interface OnboardingWizardProps {
/** Whether the wizard is visible */
isVisible: boolean;
/** Current step index */
currentStep: number;
/** Current step data */
currentStepData: OnboardingStep | null;
/** Total number of steps */
totalSteps: number;
/** Handler for next step navigation */
onNext: () => void;
/** Handler for previous step navigation */
onPrevious: () => void;
/** Handler for skipping the wizard */
onSkip: () => void;
/** Handler for completing the wizard */
onComplete: () => void;
/** Array of all steps (for step indicator navigation) */
steps: OnboardingStep[];
/** Optional content to render before navigation buttons (e.g., Quick Start) */
children?: React.ReactNode;
}

View File

@@ -0,0 +1,216 @@
/**
* Generic Onboarding Wizard Hook
*
* Manages the state and logic for interactive onboarding wizards.
* Can be used to create onboarding experiences for any view.
*
* Features:
* - Persists completion status to localStorage
* - Step navigation (next, previous, jump to step)
* - Analytics tracking hooks
* - No auto-show logic - wizard only shows via startWizard()
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getItem, setItem } from '@/lib/storage';
import { ONBOARDING_STORAGE_PREFIX, ONBOARDING_ANALYTICS } from './constants';
import type {
OnboardingState,
OnboardingStep,
UseOnboardingWizardOptions,
UseOnboardingWizardResult,
} from './types';
const logger = createLogger('OnboardingWizard');
/** Default state for new wizards */
const DEFAULT_ONBOARDING_STATE: OnboardingState = {
completed: false,
skipped: false,
};
/**
* Load onboarding state from localStorage
*/
function loadOnboardingState(storageKey: string): OnboardingState {
try {
const fullKey = `${ONBOARDING_STORAGE_PREFIX}:${storageKey}`;
const stored = getItem(fullKey);
if (stored) {
return JSON.parse(stored) as OnboardingState;
}
} catch (error) {
logger.error('Failed to load onboarding state:', error);
}
return { ...DEFAULT_ONBOARDING_STATE };
}
/**
* Save onboarding state to localStorage
*/
function saveOnboardingState(storageKey: string, state: OnboardingState): void {
try {
const fullKey = `${ONBOARDING_STORAGE_PREFIX}:${storageKey}`;
setItem(fullKey, JSON.stringify(state));
} catch (error) {
logger.error('Failed to save onboarding state:', error);
}
}
/**
* Track analytics event (placeholder - integrate with actual analytics service)
*/
function trackAnalytics(event: string, data?: Record<string, unknown>): void {
logger.debug(`[Analytics] ${event}`, data);
}
/**
* Generic hook for managing onboarding wizard state.
*
* @example
* ```tsx
* const wizard = useOnboardingWizard({
* storageKey: 'my-view-onboarding',
* steps: MY_WIZARD_STEPS,
* onComplete: () => console.log('Done!'),
* });
*
* // Start the wizard when user clicks help button
* <button onClick={wizard.startWizard}>Help</button>
*
* // Render the wizard
* <OnboardingWizard
* isVisible={wizard.isVisible}
* currentStep={wizard.currentStep}
* currentStepData={wizard.currentStepData}
* totalSteps={wizard.totalSteps}
* onNext={wizard.goToNextStep}
* onPrevious={wizard.goToPreviousStep}
* onSkip={wizard.skipWizard}
* onComplete={wizard.completeWizard}
* steps={MY_WIZARD_STEPS}
* />
* ```
*/
export function useOnboardingWizard({
storageKey,
steps,
onComplete,
onSkip,
}: UseOnboardingWizardOptions): UseOnboardingWizardResult {
const [currentStep, setCurrentStep] = useState(0);
const [isWizardVisible, setIsWizardVisible] = useState(false);
const [onboardingState, setOnboardingState] = useState<OnboardingState>(DEFAULT_ONBOARDING_STATE);
// Load persisted state on mount
useEffect(() => {
const state = loadOnboardingState(storageKey);
setOnboardingState(state);
}, [storageKey]);
// Update persisted state helper
const updateState = useCallback(
(updates: Partial<OnboardingState>) => {
setOnboardingState((prev) => {
const newState = { ...prev, ...updates };
saveOnboardingState(storageKey, newState);
return newState;
});
},
[storageKey]
);
// Current step data
const currentStepData = useMemo(() => steps[currentStep] || null, [steps, currentStep]);
const totalSteps = steps.length;
// Navigation handlers
const goToNextStep = useCallback(() => {
if (currentStep < totalSteps - 1) {
const nextStep = currentStep + 1;
setCurrentStep(nextStep);
trackAnalytics(ONBOARDING_ANALYTICS.STEP_VIEWED, {
storageKey,
step: nextStep,
stepId: steps[nextStep]?.id,
});
}
}, [currentStep, totalSteps, storageKey, steps]);
const goToPreviousStep = useCallback(() => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
}, [currentStep]);
const goToStep = useCallback(
(step: number) => {
if (step >= 0 && step < totalSteps) {
setCurrentStep(step);
trackAnalytics(ONBOARDING_ANALYTICS.STEP_VIEWED, {
storageKey,
step,
stepId: steps[step]?.id,
});
}
},
[totalSteps, storageKey, steps]
);
// Wizard lifecycle handlers
const startWizard = useCallback(() => {
setCurrentStep(0);
setIsWizardVisible(true);
trackAnalytics(ONBOARDING_ANALYTICS.STARTED, { storageKey });
}, [storageKey]);
const completeWizard = useCallback(() => {
setIsWizardVisible(false);
setCurrentStep(0);
updateState({
completed: true,
completedAt: new Date().toISOString(),
});
trackAnalytics(ONBOARDING_ANALYTICS.COMPLETED, { storageKey });
onComplete?.();
}, [storageKey, updateState, onComplete]);
const skipWizard = useCallback(() => {
setIsWizardVisible(false);
setCurrentStep(0);
updateState({
skipped: true,
skippedAt: new Date().toISOString(),
});
trackAnalytics(ONBOARDING_ANALYTICS.SKIPPED, {
storageKey,
skippedAtStep: currentStep,
});
onSkip?.();
}, [storageKey, currentStep, updateState, onSkip]);
return {
// Visibility
isVisible: isWizardVisible,
// Steps
currentStep,
currentStepData,
totalSteps,
// Navigation
goToNextStep,
goToPreviousStep,
goToStep,
// Actions
startWizard,
completeWizard,
skipWizard,
// State
isCompleted: onboardingState.completed,
isSkipped: onboardingState.skipped,
};
}

View File

@@ -1,6 +1,6 @@
import { useState, useCallback, useMemo } from 'react';
import { useAppStore } from '@/store/app-store';
import type { ModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
import type { ModelAlias, CursorModelId, 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: ModelId;
effectiveModel: ModelAlias | CursorModelId;
/** 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 ModelId };
return { model: entry as ModelAlias | CursorModelId };
}
return entry;
}
@@ -40,9 +40,9 @@ function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
/**
* Extract model string from PhaseModelEntry or string
*/
function extractModel(entry: PhaseModelEntry | string): ModelId {
function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModelId {
if (typeof entry === 'string') {
return entry as ModelId;
return entry as ModelAlias | CursorModelId;
}
return entry.model;
}

View File

@@ -1,276 +0,0 @@
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
interface AnsiOutputProps {
text: string;
className?: string;
}
// ANSI color codes to CSS color mappings
const ANSI_COLORS: Record<number, string> = {
// Standard colors
30: '#6b7280', // Black (use gray for visibility on dark bg)
31: '#ef4444', // Red
32: '#22c55e', // Green
33: '#eab308', // Yellow
34: '#3b82f6', // Blue
35: '#a855f7', // Magenta
36: '#06b6d4', // Cyan
37: '#d1d5db', // White
// Bright colors
90: '#9ca3af', // Bright Black (Gray)
91: '#f87171', // Bright Red
92: '#4ade80', // Bright Green
93: '#facc15', // Bright Yellow
94: '#60a5fa', // Bright Blue
95: '#c084fc', // Bright Magenta
96: '#22d3ee', // Bright Cyan
97: '#ffffff', // Bright White
};
const ANSI_BG_COLORS: Record<number, string> = {
40: 'transparent',
41: '#ef4444',
42: '#22c55e',
43: '#eab308',
44: '#3b82f6',
45: '#a855f7',
46: '#06b6d4',
47: '#f3f4f6',
// Bright backgrounds
100: '#374151',
101: '#f87171',
102: '#4ade80',
103: '#facc15',
104: '#60a5fa',
105: '#c084fc',
106: '#22d3ee',
107: '#ffffff',
};
interface TextSegment {
text: string;
style: {
color?: string;
backgroundColor?: string;
fontWeight?: string;
fontStyle?: string;
textDecoration?: string;
};
}
/**
* Strip hyperlink escape sequences (OSC 8)
* Format: ESC]8;;url ESC\ text ESC]8;; ESC\
*/
function stripHyperlinks(text: string): string {
// Remove OSC 8 hyperlink sequences
// eslint-disable-next-line no-control-regex
return text.replace(/\x1b\]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
}
/**
* Strip other OSC sequences (title, etc.)
*/
function stripOtherOSC(text: string): string {
// Remove OSC sequences (ESC ] ... BEL or ESC ] ... ST)
// eslint-disable-next-line no-control-regex
return text.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
}
function parseAnsi(text: string): TextSegment[] {
// Pre-process: strip hyperlinks and other OSC sequences
let processedText = stripHyperlinks(text);
processedText = stripOtherOSC(processedText);
const segments: TextSegment[] = [];
// Match ANSI escape sequences: ESC[...m (SGR - Select Graphic Rendition)
// Also handle ESC[K (erase line) and other CSI sequences by stripping them
// The ESC character can be \x1b, \033, \u001b
// eslint-disable-next-line no-control-regex
const ansiRegex = /\x1b\[([0-9;]*)([a-zA-Z])/g;
let currentStyle: TextSegment['style'] = {};
let lastIndex = 0;
let match;
while ((match = ansiRegex.exec(processedText)) !== null) {
// Add text before this escape sequence
if (match.index > lastIndex) {
const content = processedText.slice(lastIndex, match.index);
if (content) {
segments.push({ text: content, style: { ...currentStyle } });
}
}
const params = match[1];
const command = match[2];
// Only process 'm' command (SGR - graphics/color)
// Ignore other commands like K (erase), H (cursor), J (clear), etc.
if (command === 'm') {
// Parse the escape sequence codes
const codes = params ? params.split(';').map((c) => parseInt(c, 10) || 0) : [0];
for (let i = 0; i < codes.length; i++) {
const code = codes[i];
if (code === 0) {
// Reset all attributes
currentStyle = {};
} else if (code === 1) {
// Bold
currentStyle.fontWeight = 'bold';
} else if (code === 2) {
// Dim/faint
currentStyle.color = 'var(--muted-foreground)';
} else if (code === 3) {
// Italic
currentStyle.fontStyle = 'italic';
} else if (code === 4) {
// Underline
currentStyle.textDecoration = 'underline';
} else if (code === 22) {
// Normal intensity (not bold, not dim)
currentStyle.fontWeight = undefined;
} else if (code === 23) {
// Not italic
currentStyle.fontStyle = undefined;
} else if (code === 24) {
// Not underlined
currentStyle.textDecoration = undefined;
} else if (code === 38) {
// Extended foreground color
if (codes[i + 1] === 5 && codes[i + 2] !== undefined) {
// 256 color mode: 38;5;n
const colorIndex = codes[i + 2];
currentStyle.color = get256Color(colorIndex);
i += 2;
} else if (codes[i + 1] === 2 && codes[i + 4] !== undefined) {
// RGB mode: 38;2;r;g;b
const r = codes[i + 2];
const g = codes[i + 3];
const b = codes[i + 4];
currentStyle.color = `rgb(${r}, ${g}, ${b})`;
i += 4;
}
} else if (code === 48) {
// Extended background color
if (codes[i + 1] === 5 && codes[i + 2] !== undefined) {
// 256 color mode: 48;5;n
const colorIndex = codes[i + 2];
currentStyle.backgroundColor = get256Color(colorIndex);
i += 2;
} else if (codes[i + 1] === 2 && codes[i + 4] !== undefined) {
// RGB mode: 48;2;r;g;b
const r = codes[i + 2];
const g = codes[i + 3];
const b = codes[i + 4];
currentStyle.backgroundColor = `rgb(${r}, ${g}, ${b})`;
i += 4;
}
} else if (ANSI_COLORS[code]) {
// Standard foreground color (30-37, 90-97)
currentStyle.color = ANSI_COLORS[code];
} else if (ANSI_BG_COLORS[code]) {
// Standard background color (40-47, 100-107)
currentStyle.backgroundColor = ANSI_BG_COLORS[code];
} else if (code === 39) {
// Default foreground
currentStyle.color = undefined;
} else if (code === 49) {
// Default background
currentStyle.backgroundColor = undefined;
}
}
}
lastIndex = match.index + match[0].length;
}
// Add remaining text after last escape sequence
if (lastIndex < processedText.length) {
const content = processedText.slice(lastIndex);
if (content) {
segments.push({ text: content, style: { ...currentStyle } });
}
}
// If no segments were created (no ANSI codes), return the whole text
if (segments.length === 0 && processedText) {
segments.push({ text: processedText, style: {} });
}
return segments;
}
/**
* Convert 256-color palette index to CSS color
*/
function get256Color(index: number): string {
// 0-15: Standard colors
if (index < 16) {
const standardColors = [
'#000000',
'#cd0000',
'#00cd00',
'#cdcd00',
'#0000ee',
'#cd00cd',
'#00cdcd',
'#e5e5e5',
'#7f7f7f',
'#ff0000',
'#00ff00',
'#ffff00',
'#5c5cff',
'#ff00ff',
'#00ffff',
'#ffffff',
];
return standardColors[index];
}
// 16-231: 6x6x6 color cube
if (index < 232) {
const n = index - 16;
const b = n % 6;
const g = Math.floor(n / 6) % 6;
const r = Math.floor(n / 36);
const toHex = (v: number) => (v === 0 ? 0 : 55 + v * 40);
return `rgb(${toHex(r)}, ${toHex(g)}, ${toHex(b)})`;
}
// 232-255: Grayscale
const gray = 8 + (index - 232) * 10;
return `rgb(${gray}, ${gray}, ${gray})`;
}
export function AnsiOutput({ text, className }: AnsiOutputProps) {
const segments = useMemo(() => parseAnsi(text), [text]);
return (
<pre
className={cn(
'font-mono text-xs whitespace-pre-wrap break-words text-muted-foreground',
className
)}
>
{segments.map((segment, index) => (
<span
key={index}
style={{
color: segment.style.color,
backgroundColor: segment.style.backgroundColor,
fontWeight: segment.style.fontWeight,
fontStyle: segment.style.fontStyle,
textDecoration: segment.style.textDecoration,
}}
>
{segment.text}
</span>
))}
</pre>
);
}

View File

@@ -137,8 +137,6 @@ 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,14 +78,7 @@ 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',
// Mobile touch scrolling support
'touch-pan-y overscroll-contain',
// iOS Safari momentum scrolling
'[&]:[-webkit-overflow-scrolling:touch]',
className
)}
className={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}
{...props}
/>
);

View File

@@ -6,7 +6,6 @@ import { getProviderFromModel } from '@/lib/utils';
const PROVIDER_ICON_KEYS = {
anthropic: 'anthropic',
openai: 'openai',
openrouter: 'openrouter',
cursor: 'cursor',
gemini: 'gemini',
grok: 'grok',
@@ -42,12 +41,6 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
path: 'M60.8734,57.2556v-14.9432c0-1.2586.4722-2.2029,1.5728-2.8314l30.0443-17.3023c4.0899-2.3593,8.9662-3.4599,13.9988-3.4599,18.8759,0,30.8307,14.6289,30.8307,30.2006,0,1.1007,0,2.3593-.158,3.6178l-31.1446-18.2467c-1.8872-1.1006-3.7754-1.1006-5.6629,0l-39.4812,22.9651ZM131.0276,115.4561v-35.7074c0-2.2028-.9446-3.7756-2.8318-4.8763l-39.481-22.9651,12.8982-7.3934c1.1007-.6285,2.0453-.6285,3.1458,0l30.0441,17.3024c8.6523,5.0341,14.4708,15.7296,14.4708,26.1107,0,11.9539-7.0769,22.965-18.2461,27.527v.0021ZM51.593,83.9964l-12.8982-7.5497c-1.1007-.6285-1.5728-1.5728-1.5728-2.8314v-34.6048c0-16.8303,12.8982-29.5722,30.3585-29.5722,6.607,0,12.7403,2.2029,17.9324,6.1349l-30.987,17.9324c-1.8871,1.1007-2.8314,2.6735-2.8314,4.8764v45.6159l-.0014-.0015ZM79.3562,100.0403l-18.4829-10.3811v-22.0209l18.4829-10.3811,18.4812,10.3811v22.0209l-18.4812,10.3811ZM91.2319,147.8591c-6.607,0-12.7403-2.2031-17.9324-6.1344l30.9866-17.9333c1.8872-1.1005,2.8318-2.6728,2.8318-4.8759v-45.616l13.0564,7.5498c1.1005.6285,1.5723,1.5728,1.5723,2.8314v34.6051c0,16.8297-13.0564,29.5723-30.5147,29.5723v.001ZM53.9522,112.7822l-30.0443-17.3024c-8.652-5.0343-14.471-15.7296-14.471-26.1107,0-12.1119,7.2356-22.9652,18.403-27.5272v35.8634c0,2.2028.9443,3.7756,2.8314,4.8763l39.3248,22.8068-12.8982,7.3938c-1.1007.6287-2.045.6287-3.1456,0ZM52.2229,138.5791c-17.7745,0-30.8306-13.3713-30.8306-29.8871,0-1.2585.1578-2.5169.3143-3.7754l30.987,17.9323c1.8871,1.1005,3.7757,1.1005,5.6628,0l39.4811-22.807v14.9435c0,1.2585-.4721,2.2021-1.5728,2.8308l-30.0443,17.3025c-4.0898,2.359-8.9662,3.4605-13.9989,3.4605h.0014ZM91.2319,157.296c19.0327,0,34.9188-13.5272,38.5383-31.4594,17.6164-4.562,28.9425-21.0779,28.9425-37.908,0-11.0112-4.719-21.7066-13.2133-29.4143.7867-3.3035,1.2595-6.607,1.2595-9.909,0-22.4929-18.2471-39.3247-39.3251-39.3247-4.2461,0-8.3363.6285-12.4262,2.045-7.0792-6.9213-16.8318-11.3254-27.5271-11.3254-19.0331,0-34.9191,13.5268-38.5384,31.4591C11.3255,36.0212,0,52.5373,0,69.3675c0,11.0112,4.7184,21.7065,13.2125,29.4142-.7865,3.3035-1.2586,6.6067-1.2586,9.9092,0,22.4923,18.2466,39.3241,39.3248,39.3241,4.2462,0,8.3362-.6277,12.426-2.0441,7.0776,6.921,16.8302,11.3251,27.5271,11.3251Z',
fill: '#74aa9c',
},
openrouter: {
viewBox: '0 0 24 24',
// OpenRouter logo from Simple Icons
path: 'M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z',
fill: '#94A3B8',
},
cursor: {
viewBox: '0 0 512 512',
// Official Cursor logo - hexagonal shape with triangular wedge
@@ -158,10 +151,6 @@ export function OpenAIIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.openai} {...props} />;
}
export function OpenRouterIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.openrouter} {...props} />;
}
export function CursorIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.cursor} {...props} />;
}
@@ -406,11 +395,6 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
const modelStr = typeof model === 'string' ? model.toLowerCase() : model;
// Check for Amazon Bedrock models first (amazon-bedrock/...)
if (modelStr.startsWith('openrouter/')) {
return 'openrouter';
}
// Check for Amazon Bedrock models first (amazon-bedrock/...)
if (modelStr.startsWith('amazon-bedrock/')) {
// Bedrock-hosted models - detect the specific provider
@@ -458,71 +442,6 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
return 'opencode';
}
// Check for dynamic OpenCode provider models (provider/model format)
// e.g., zai-coding-plan/glm-4.5, github-copilot/gpt-4o, google/gemini-2.5-pro
// Only handle strings with exactly one slash (not URLs or paths)
if (!modelStr.includes('://')) {
const slashIndex = modelStr.indexOf('/');
if (slashIndex !== -1 && slashIndex === modelStr.lastIndexOf('/')) {
const providerName = modelStr.slice(0, slashIndex);
const modelName = modelStr.slice(slashIndex + 1);
// Skip if either part is empty
if (providerName && modelName) {
// Check model name for known patterns
if (modelName.includes('glm')) {
return 'glm';
}
if (
modelName.includes('claude') ||
modelName.includes('sonnet') ||
modelName.includes('opus')
) {
return 'anthropic';
}
if (modelName.includes('gpt') || modelName.includes('o1') || modelName.includes('o3')) {
return 'openai';
}
if (modelName.includes('gemini')) {
return 'gemini';
}
if (modelName.includes('grok')) {
return 'grok';
}
if (modelName.includes('deepseek')) {
return 'deepseek';
}
if (modelName.includes('llama')) {
return 'meta';
}
if (modelName.includes('qwen')) {
return 'qwen';
}
if (modelName.includes('mistral')) {
return 'mistral';
}
// Check provider name for hints
if (providerName.includes('google')) {
return 'gemini';
}
if (providerName.includes('anthropic')) {
return 'anthropic';
}
if (providerName.includes('openai')) {
return 'openai';
}
if (providerName.includes('openrouter')) {
return 'openrouter';
}
if (providerName.includes('xai')) {
return 'grok';
}
// Default for unknown dynamic models
return 'opencode';
}
}
}
// Check for Cursor-specific models with underlying providers
if (modelStr.includes('sonnet') || modelStr.includes('opus') || modelStr.includes('claude')) {
return 'anthropic';
@@ -556,7 +475,6 @@ export function getProviderIconForModel(
const iconMap: Record<ProviderIconKey, ComponentType<{ className?: string }>> = {
anthropic: AnthropicIcon,
openai: OpenAIIcon,
openrouter: OpenRouterIcon,
cursor: CursorIcon,
gemini: GeminiIcon,
grok: GrokIcon,

View File

@@ -1,73 +0,0 @@
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

@@ -1,142 +0,0 @@
import CodeMirror from '@uiw/react-codemirror';
import { StreamLanguage } from '@codemirror/language';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { EditorView } from '@codemirror/view';
import { Extension } from '@codemirror/state';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import { cn } from '@/lib/utils';
interface ShellSyntaxEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
minHeight?: string;
maxHeight?: string;
'data-testid'?: string;
}
// Syntax highlighting using CSS variables for theme compatibility
const syntaxColors = HighlightStyle.define([
// Keywords (if, then, else, fi, for, while, do, done, case, esac, etc.)
{ tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
// Strings (single and double quoted)
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
// Comments
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
// Variables ($VAR, ${VAR})
{ tag: t.variableName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
// Operators
{ tag: t.operator, color: 'var(--muted-foreground)' },
// Numbers
{ tag: t.number, color: 'var(--chart-3, oklch(0.7 0.15 150))' },
// Function names / commands
{ tag: t.function(t.variableName), color: 'var(--primary)' },
{ tag: t.attributeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
// Default text
{ tag: t.content, color: 'var(--foreground)' },
]);
// Editor theme using CSS variables
const editorTheme = EditorView.theme({
'&': {
height: '100%',
fontSize: '0.875rem',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
backgroundColor: 'transparent',
color: 'var(--foreground)',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
},
'.cm-content': {
padding: '0.75rem',
minHeight: '100%',
caretColor: 'var(--primary)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--primary)',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
},
'.cm-activeLine': {
backgroundColor: 'var(--accent)',
opacity: '0.3',
},
'.cm-line': {
padding: '0 0.25rem',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-gutters': {
backgroundColor: 'transparent',
color: 'var(--muted-foreground)',
border: 'none',
paddingRight: '0.5rem',
},
'.cm-lineNumbers .cm-gutterElement': {
minWidth: '2rem',
textAlign: 'right',
paddingRight: '0.5rem',
},
'.cm-placeholder': {
color: 'var(--muted-foreground)',
fontStyle: 'italic',
},
});
// Combine all extensions
const extensions: Extension[] = [
StreamLanguage.define(shell),
syntaxHighlighting(syntaxColors),
editorTheme,
];
export function ShellSyntaxEditor({
value,
onChange,
placeholder,
className,
minHeight = '200px',
maxHeight,
'data-testid': testId,
}: ShellSyntaxEditorProps) {
return (
<div
className={cn('w-full rounded-lg border border-border bg-muted/30', className)}
style={{ minHeight }}
data-testid={testId}
>
<CodeMirror
value={value}
onChange={onChange}
extensions={extensions}
theme="none"
placeholder={placeholder}
height={maxHeight}
minHeight={minHeight}
className="[&_.cm-editor]:min-h-[inherit]"
basicSetup={{
lineNumbers: true,
foldGutter: false,
highlightActiveLine: true,
highlightSelectionMatches: true,
autocompletion: false,
bracketMatching: true,
indentOnInput: true,
}}
/>
</div>
);
}

View File

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

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