Merge upstream/v0.12.0rc into feature/fedora-rpm-support

Resolved conflict in backlog-plan/common.ts:
- Kept local (stricter) validation: Array.isArray(parsed?.result?.changes)
- This ensures type safety for the changes array
This commit is contained in:
DhanushSantosh
2026-01-17 14:44:37 +05:30
134 changed files with 12254 additions and 2716 deletions

View File

@@ -166,7 +166,10 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali
## Environment Variables ## Environment Variables
- `ANTHROPIC_API_KEY` - Anthropic API key (or use Claude Code CLI auth) - `ANTHROPIC_API_KEY` - Anthropic API key (or use Claude Code CLI auth)
- `HOST` - Host to bind server to (default: 0.0.0.0)
- `HOSTNAME` - Hostname for user-facing URLs (default: localhost)
- `PORT` - Server port (default: 3008) - `PORT` - Server port (default: 3008)
- `DATA_DIR` - Data storage directory (default: ./data) - `DATA_DIR` - Data storage directory (default: ./data)
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory - `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing - `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing
- `VITE_HOSTNAME` - Hostname for frontend API URLs (default: localhost)

137
README.md
View File

@@ -28,6 +28,7 @@
- [Quick Start](#quick-start) - [Quick Start](#quick-start)
- [How to Run](#how-to-run) - [How to Run](#how-to-run)
- [Development Mode](#development-mode) - [Development Mode](#development-mode)
- [Interactive TUI Launcher](#interactive-tui-launcher-recommended-for-new-users)
- [Building for Production](#building-for-production) - [Building for Production](#building-for-production)
- [Testing](#testing) - [Testing](#testing)
- [Linting](#linting) - [Linting](#linting)
@@ -101,11 +102,9 @@ In the Discord, you can:
### Prerequisites ### Prerequisites
- **Node.js 18+** (tested with Node.js 22) - **Node.js 22+** (required: >=22.0.0 <23.0.0)
- **npm** (comes with Node.js) - **npm** (comes with Node.js)
- **Authentication** (choose one): - **[Claude Code CLI](https://code.claude.com/docs/en/overview)** - Install and authenticate with your Anthropic subscription. Automaker integrates with your authenticated Claude Code CLI to access Claude models.
- **[Claude Code CLI](https://code.claude.com/docs/en/overview)** (recommended) - Install and authenticate, credentials used automatically
- **Anthropic API Key** - Direct API key for Claude Agent SDK ([get one here](https://console.anthropic.com/))
### Quick Start ### Quick Start
@@ -117,30 +116,14 @@ cd automaker
# 2. Install dependencies # 2. Install dependencies
npm install npm install
# 3. Build shared packages (can be skipped - npm run dev does it automatically) # 3. Start Automaker
npm run build:packages
# 4. Start Automaker
npm run dev npm run dev
# Choose between: # Choose between:
# 1. Web Application (browser at localhost:3007) # 1. Web Application (browser at localhost:3007)
# 2. Desktop Application (Electron - recommended) # 2. Desktop Application (Electron - recommended)
``` ```
**Authentication Setup:** On first run, Automaker will automatically show a setup wizard where you can configure authentication. You can choose to: **Authentication:** Automaker integrates with your authenticated Claude Code CLI. Make sure you have [installed and authenticated](https://code.claude.com/docs/en/quickstart) the Claude Code CLI before running Automaker. Your CLI credentials will be detected automatically.
- Use **Claude Code CLI** (recommended) - Automaker will detect your CLI credentials automatically
- Enter an **API key** directly in the wizard
If you prefer to set up authentication before running (e.g., for headless deployments or CI/CD), you can set it manually:
```bash
# Option A: Environment variable
export ANTHROPIC_API_KEY="sk-ant-..."
# Option B: Create .env file in project root
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
```
**For Development:** `npm run dev` starts the development server with Vite live reload and hot module replacement for fast refresh and instant updates as you make changes. **For Development:** `npm run dev` starts the development server with Vite live reload and hot module replacement for fast refresh and instant updates as you make changes.
@@ -179,6 +162,40 @@ npm run dev:electron:wsl:gpu
npm run dev:web npm run dev:web
``` ```
### Interactive TUI Launcher (Recommended for New Users)
For a user-friendly interactive menu, use the built-in TUI launcher script:
```bash
# Show interactive menu with all launch options
./start-automaker.sh
# Or launch directly without menu
./start-automaker.sh web # Web browser
./start-automaker.sh electron # Desktop app
./start-automaker.sh electron-debug # Desktop + DevTools
# Additional options
./start-automaker.sh --help # Show all available options
./start-automaker.sh --version # Show version information
./start-automaker.sh --check-deps # Verify project dependencies
./start-automaker.sh --no-colors # Disable colored output
./start-automaker.sh --no-history # Don't remember last choice
```
**Features:**
- 🎨 Beautiful terminal UI with gradient colors and ASCII art
- Interactive menu (press 1-3 to select, Q to exit)
- 💾 Remembers your last choice
- Pre-flight checks (validates Node.js, npm, dependencies)
- 📏 Responsive layout (adapts to terminal size)
- 30-second timeout for hands-free selection
- 🌐 Cross-shell compatible (bash/zsh)
**History File:**
Your last selected mode is saved in `~/.automaker_launcher_history` for quick re-runs.
### Building for Production ### Building for Production
#### Web Application #### Web Application
@@ -239,16 +256,9 @@ docker-compose logs -f
docker-compose down docker-compose down
``` ```
##### Configuration ##### Authentication
Create a `.env` file in the project root if using API key authentication: Automaker integrates with your authenticated Claude Code CLI. To use CLI authentication in Docker, mount your Claude CLI config directory (see [Claude CLI Authentication](#claude-cli-authentication) below).
```bash
# Optional: Anthropic API key (not needed if using Claude CLI authentication)
ANTHROPIC_API_KEY=sk-ant-...
```
**Note:** Most users authenticate via Claude CLI instead of API keys. See [Claude CLI Authentication](#claude-cli-authentication-optional) below.
##### Working with Projects (Host Directory Access) ##### Working with Projects (Host Directory Access)
@@ -262,9 +272,9 @@ services:
- /path/to/your/project:/projects/your-project - /path/to/your/project:/projects/your-project
``` ```
##### Claude CLI Authentication (Optional) ##### Claude CLI Authentication
To use Claude Code CLI authentication instead of an API key, mount your Claude CLI config directory: Mount your Claude CLI config directory to use your authenticated CLI credentials:
```yaml ```yaml
services: services:
@@ -362,10 +372,6 @@ npm run lint
### Environment Configuration ### Environment Configuration
#### Authentication (if not using Claude Code CLI)
- `ANTHROPIC_API_KEY` - Your Anthropic API key for Claude Agent SDK (not needed if using Claude Code CLI)
#### Optional - Server #### Optional - Server
- `PORT` - Server port (default: 3008) - `PORT` - Server port (default: 3008)
@@ -376,7 +382,7 @@ npm run lint
- `AUTOMAKER_API_KEY` - Optional API authentication for the server - `AUTOMAKER_API_KEY` - Optional API authentication for the server
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory - `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
- `CORS_ORIGIN` - CORS policy (default: \*) - `CORS_ORIGIN` - CORS allowed origins (comma-separated list; defaults to localhost only)
#### Optional - Development #### Optional - Development
@@ -385,40 +391,12 @@ npm run lint
### Authentication Setup ### Authentication Setup
#### Option 1: Claude Code CLI (Recommended) Automaker integrates with your authenticated Claude Code CLI and uses your Anthropic subscription.
Install and authenticate the Claude Code CLI following the [official quickstart guide](https://code.claude.com/docs/en/quickstart). Install and authenticate the Claude Code CLI following the [official quickstart guide](https://code.claude.com/docs/en/quickstart).
Once authenticated, Automaker will automatically detect and use your CLI credentials. No additional configuration needed! Once authenticated, Automaker will automatically detect and use your CLI credentials. No additional configuration needed!
#### Option 2: Direct API Key
If you prefer not to use the CLI, you can provide an Anthropic API key directly using one of these methods:
##### 2a. Shell Configuration
Add to your `~/.bashrc` or `~/.zshrc`:
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
```
Then restart your terminal or run `source ~/.bashrc` (or `source ~/.zshrc`).
##### 2b. .env File
Create a `.env` file in the project root (gitignored):
```bash
ANTHROPIC_API_KEY=sk-ant-...
PORT=3008
DATA_DIR=./data
```
##### 2c. In-App Storage
The application can store your API key securely in the settings UI. The key is persisted in the `DATA_DIR` directory.
## Features ## Features
### Core Workflow ### Core Workflow
@@ -527,20 +505,24 @@ Automaker provides several specialized views accessible via the sidebar or keybo
| **Agent** | `A` | Interactive chat sessions with AI agents for exploratory work and questions | | **Agent** | `A` | Interactive chat sessions with AI agents for exploratory work and questions |
| **Spec** | `D` | Project specification editor with AI-powered generation and feature suggestions | | **Spec** | `D` | Project specification editor with AI-powered generation and feature suggestions |
| **Context** | `C` | Manage context files (markdown, images) that AI agents automatically reference | | **Context** | `C` | Manage context files (markdown, images) that AI agents automatically reference |
| **Profiles** | `M` | Create and manage AI agent profiles with custom prompts and configurations |
| **Settings** | `S` | Configure themes, shortcuts, defaults, authentication, and more | | **Settings** | `S` | Configure themes, shortcuts, defaults, authentication, and more |
| **Terminal** | `T` | Integrated terminal with tabs, splits, and persistent sessions | | **Terminal** | `T` | Integrated terminal with tabs, splits, and persistent sessions |
| **GitHub Issues** | - | Import and validate GitHub issues, convert to tasks | | **Graph** | `H` | Visualize feature dependencies with interactive graph visualization |
| **Ideation** | `I` | Brainstorm and generate ideas with AI assistance |
| **Memory** | `Y` | View and manage agent memory and conversation history |
| **GitHub Issues** | `G` | Import and validate GitHub issues, convert to tasks |
| **GitHub PRs** | `R` | View and manage GitHub pull requests |
| **Running Agents** | - | View all active agents across projects with status and progress | | **Running Agents** | - | View all active agents across projects with status and progress |
### Keyboard Navigation ### Keyboard Navigation
All shortcuts are customizable in Settings. Default shortcuts: All shortcuts are customizable in Settings. Default shortcuts:
- **Navigation:** `K` (Board), `A` (Agent), `D` (Spec), `C` (Context), `S` (Settings), `M` (Profiles), `T` (Terminal) - **Navigation:** `K` (Board), `A` (Agent), `D` (Spec), `C` (Context), `S` (Settings), `T` (Terminal), `H` (Graph), `I` (Ideation), `Y` (Memory), `G` (GitHub Issues), `R` (GitHub PRs)
- **UI:** `` ` `` (Toggle sidebar) - **UI:** `` ` `` (Toggle sidebar)
- **Actions:** `N` (New item in current view), `G` (Start next features), `O` (Open project), `P` (Project picker) - **Actions:** `N` (New item in current view), `O` (Open project), `P` (Project picker)
- **Projects:** `Q`/`E` (Cycle previous/next project) - **Projects:** `Q`/`E` (Cycle previous/next project)
- **Terminal:** `Alt+D` (Split right), `Alt+S` (Split down), `Alt+W` (Close), `Alt+T` (New tab)
## Architecture ## Architecture
@@ -605,10 +587,16 @@ Stored in `{projectPath}/.automaker/`:
│ ├── agent-output.md # AI agent output log │ ├── agent-output.md # AI agent output log
│ └── images/ # Attached images │ └── images/ # Attached images
├── context/ # Context files for AI agents ├── context/ # Context files for AI agents
├── worktrees/ # Git worktree metadata
├── validations/ # GitHub issue validation results
├── ideation/ # Brainstorming and analysis data
│ └── analysis.json # Project structure analysis
├── board/ # Board-related data
├── images/ # Project-level images
├── settings.json # Project-specific settings ├── settings.json # Project-specific settings
├── spec.md # Project specification ├── app_spec.txt # Project specification (XML format)
├── analysis.json # Project structure analysis ├── active-branches.json # Active git branches tracking
└── feature-suggestions.json # AI-generated suggestions └── execution-state.json # Auto-mode execution state
``` ```
#### Global Data #### Global Data
@@ -646,7 +634,6 @@ data/
- [Contributing Guide](./CONTRIBUTING.md) - How to contribute to Automaker - [Contributing Guide](./CONTRIBUTING.md) - How to contribute to Automaker
- [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs - [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs
- [Docker Isolation Guide](./docs/docker-isolation.md) - Security-focused Docker deployment
- [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages - [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages
### Community ### Community

View File

@@ -44,6 +44,11 @@ CORS_ORIGIN=http://localhost:3007
# OPTIONAL - Server # OPTIONAL - Server
# ============================================ # ============================================
# Host to bind the server to (default: 0.0.0.0)
# Use 0.0.0.0 to listen on all interfaces (recommended for Docker/remote access)
# Use 127.0.0.1 or localhost to restrict to local connections only
HOST=0.0.0.0
# Port to run the server on # Port to run the server on
PORT=3008 PORT=3008

View File

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

View File

@@ -79,11 +79,17 @@ import { createIdeationRoutes } from './routes/ideation/index.js';
import { IdeationService } from './services/ideation-service.js'; import { IdeationService } from './services/ideation-service.js';
import { getDevServerService } from './services/dev-server-service.js'; import { getDevServerService } from './services/dev-server-service.js';
import { eventHookService } from './services/event-hook-service.js'; import { eventHookService } from './services/event-hook-service.js';
import { createNotificationsRoutes } from './routes/notifications/index.js';
import { getNotificationService } from './services/notification-service.js';
import { createEventHistoryRoutes } from './routes/event-history/index.js';
import { getEventHistoryService } from './services/event-history-service.js';
// Load environment variables // Load environment variables
dotenv.config(); dotenv.config();
const PORT = parseInt(process.env.PORT || '3008', 10); const PORT = parseInt(process.env.PORT || '3008', 10);
const HOST = process.env.HOST || '0.0.0.0';
const HOSTNAME = process.env.HOSTNAME || 'localhost';
const DATA_DIR = process.env.DATA_DIR || './data'; const DATA_DIR = process.env.DATA_DIR || './data';
const ENABLE_REQUEST_LOGGING_DEFAULT = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true const ENABLE_REQUEST_LOGGING_DEFAULT = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
@@ -208,8 +214,15 @@ const ideationService = new IdeationService(events, settingsService, featureLoad
const devServerService = getDevServerService(); const devServerService = getDevServerService();
devServerService.setEventEmitter(events); devServerService.setEventEmitter(events);
// Initialize Event Hook Service for custom event triggers // Initialize Notification Service with event emitter for real-time updates
eventHookService.initialize(events, settingsService); const notificationService = getNotificationService();
notificationService.setEventEmitter(events);
// Initialize Event History Service
const eventHistoryService = getEventHistoryService();
// Initialize Event Hook Service for custom event triggers (with history storage)
eventHookService.initialize(events, settingsService, eventHistoryService);
// Initialize services // Initialize services
(async () => { (async () => {
@@ -264,7 +277,7 @@ app.get('/api/health/detailed', createDetailedHandler());
app.use('/api/fs', createFsRoutes(events)); app.use('/api/fs', createFsRoutes(events));
app.use('/api/agent', createAgentRoutes(agentService, events)); app.use('/api/agent', createAgentRoutes(agentService, events));
app.use('/api/sessions', createSessionsRoutes(agentService)); app.use('/api/sessions', createSessionsRoutes(agentService));
app.use('/api/features', createFeaturesRoutes(featureLoader, settingsService)); app.use('/api/features', createFeaturesRoutes(featureLoader, settingsService, events));
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService)); app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService)); app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
@@ -285,6 +298,8 @@ app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
app.use('/api/mcp', createMCPRoutes(mcpTestService)); app.use('/api/mcp', createMCPRoutes(mcpTestService));
app.use('/api/pipeline', createPipelineRoutes(pipelineService)); app.use('/api/pipeline', createPipelineRoutes(pipelineService));
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader)); app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
app.use('/api/notifications', createNotificationsRoutes(notificationService));
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
// Create HTTP server // Create HTTP server
const server = createServer(app); const server = createServer(app);
@@ -596,8 +611,8 @@ terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage
}); });
// Start server with error handling for port conflicts // Start server with error handling for port conflicts
const startServer = (port: number) => { const startServer = (port: number, host: string) => {
server.listen(port, () => { server.listen(port, host, () => {
const terminalStatus = isTerminalEnabled() const terminalStatus = isTerminalEnabled()
? isTerminalPasswordRequired() ? isTerminalPasswordRequired()
? 'enabled (password protected)' ? 'enabled (password protected)'
@@ -608,10 +623,11 @@ const startServer = (port: number) => {
╔═══════════════════════════════════════════════════════╗ ╔═══════════════════════════════════════════════════════╗
║ Automaker Backend Server ║ ║ Automaker Backend Server ║
╠═══════════════════════════════════════════════════════╣ ╠═══════════════════════════════════════════════════════╣
HTTP API: http://localhost:${portStr} Listening: ${host}:${port}${' '.repeat(Math.max(0, 34 - host.length - port.toString().length))}
WebSocket: ws://localhost:${portStr}/api/events HTTP API: http://${HOSTNAME}:${portStr}
Terminal: ws://localhost:${portStr}/api/terminal/ws WebSocket: ws://${HOSTNAME}:${portStr}/api/events
Health: http://localhost:${portStr}/api/health Terminal: ws://${HOSTNAME}:${portStr}/api/terminal/ws
║ Health: http://${HOSTNAME}:${portStr}/api/health ║
║ Terminal: ${terminalStatus.padEnd(37)} ║ Terminal: ${terminalStatus.padEnd(37)}
╚═══════════════════════════════════════════════════════╝ ╚═══════════════════════════════════════════════════════╝
`); `);
@@ -645,7 +661,7 @@ const startServer = (port: number) => {
}); });
}; };
startServer(PORT); startServer(PORT, HOST);
// Global error handlers to prevent crashes from uncaught errors // Global error handlers to prevent crashes from uncaught errors
process.on('unhandledRejection', (reason: unknown, _promise: Promise<unknown>) => { process.on('unhandledRejection', (reason: unknown, _promise: Promise<unknown>) => {

View File

@@ -11,8 +11,12 @@ export { specOutputSchema } from '@automaker/types';
/** /**
* Escape special XML characters * Escape special XML characters
* Handles undefined/null values by converting them to empty strings
*/ */
function escapeXml(str: string): string { export function escapeXml(str: string | undefined | null): string {
if (str == null) {
return '';
}
return str return str
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')

View File

@@ -0,0 +1,611 @@
/**
* XML Extraction Utilities
*
* Robust XML parsing utilities for extracting and updating sections
* from app_spec.txt XML content. Uses regex-based parsing which is
* sufficient for our controlled XML structure.
*
* Note: If more complex XML parsing is needed in the future, consider
* using a library like 'fast-xml-parser' or 'xml2js'.
*/
import { createLogger } from '@automaker/utils';
import type { SpecOutput } from '@automaker/types';
const logger = createLogger('XmlExtractor');
/**
* Represents an implemented feature extracted from XML
*/
export interface ImplementedFeature {
name: string;
description: string;
file_locations?: string[];
}
/**
* Logger interface for optional custom logging
*/
export interface XmlExtractorLogger {
debug: (message: string, ...args: unknown[]) => void;
warn?: (message: string, ...args: unknown[]) => void;
}
/**
* Options for XML extraction operations
*/
export interface ExtractXmlOptions {
/** Custom logger (defaults to internal logger) */
logger?: XmlExtractorLogger;
}
/**
* Escape special XML characters
* Handles undefined/null values by converting them to empty strings
*/
export function escapeXml(str: string | undefined | null): string {
if (str == null) {
return '';
}
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Unescape XML entities back to regular characters
*/
export function unescapeXml(str: string): string {
return str
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&');
}
/**
* Extract the content of a specific XML section
*
* @param xmlContent - The full XML content
* @param tagName - The tag name to extract (e.g., 'implemented_features')
* @param options - Optional extraction options
* @returns The content between the tags, or null if not found
*/
export function extractXmlSection(
xmlContent: string,
tagName: string,
options: ExtractXmlOptions = {}
): string | null {
const log = options.logger || logger;
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'i');
const match = xmlContent.match(regex);
if (match) {
log.debug(`Extracted <${tagName}> section`);
return match[1];
}
log.debug(`Section <${tagName}> not found`);
return null;
}
/**
* Extract all values from repeated XML elements
*
* @param xmlContent - The XML content to search
* @param tagName - The tag name to extract values from
* @param options - Optional extraction options
* @returns Array of extracted values (unescaped)
*/
export function extractXmlElements(
xmlContent: string,
tagName: string,
options: ExtractXmlOptions = {}
): string[] {
const log = options.logger || logger;
const values: string[] = [];
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'g');
const matches = xmlContent.matchAll(regex);
for (const match of matches) {
values.push(unescapeXml(match[1].trim()));
}
log.debug(`Extracted ${values.length} <${tagName}> elements`);
return values;
}
/**
* Extract implemented features from app_spec.txt XML content
*
* @param specContent - The full XML content of app_spec.txt
* @param options - Optional extraction options
* @returns Array of implemented features with name, description, and optional file_locations
*/
export function extractImplementedFeatures(
specContent: string,
options: ExtractXmlOptions = {}
): ImplementedFeature[] {
const log = options.logger || logger;
const features: ImplementedFeature[] = [];
// Match <implemented_features>...</implemented_features> section
const implementedSection = extractXmlSection(specContent, 'implemented_features', options);
if (!implementedSection) {
log.debug('No implemented_features section found');
return features;
}
// Extract individual feature blocks
const featureRegex = /<feature>([\s\S]*?)<\/feature>/g;
const featureMatches = implementedSection.matchAll(featureRegex);
for (const featureMatch of featureMatches) {
const featureContent = featureMatch[1];
// Extract name
const nameMatch = featureContent.match(/<name>([\s\S]*?)<\/name>/);
const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : '';
// Extract description
const descMatch = featureContent.match(/<description>([\s\S]*?)<\/description>/);
const description = descMatch ? unescapeXml(descMatch[1].trim()) : '';
// Extract file_locations if present
const locationsSection = extractXmlSection(featureContent, 'file_locations', options);
const file_locations = locationsSection
? extractXmlElements(locationsSection, 'location', options)
: undefined;
if (name) {
features.push({
name,
description,
...(file_locations && file_locations.length > 0 ? { file_locations } : {}),
});
}
}
log.debug(`Extracted ${features.length} implemented features`);
return features;
}
/**
* Extract only the feature names from implemented_features section
*
* @param specContent - The full XML content of app_spec.txt
* @param options - Optional extraction options
* @returns Array of feature names
*/
export function extractImplementedFeatureNames(
specContent: string,
options: ExtractXmlOptions = {}
): string[] {
const features = extractImplementedFeatures(specContent, options);
return features.map((f) => f.name);
}
/**
* Generate XML for a single implemented feature
*
* @param feature - The feature to convert to XML
* @param indent - The base indentation level (default: 2 spaces)
* @returns XML string for the feature
*/
export function featureToXml(feature: ImplementedFeature, indent: string = ' '): string {
const i2 = indent.repeat(2);
const i3 = indent.repeat(3);
const i4 = indent.repeat(4);
let xml = `${i2}<feature>
${i3}<name>${escapeXml(feature.name)}</name>
${i3}<description>${escapeXml(feature.description)}</description>`;
if (feature.file_locations && feature.file_locations.length > 0) {
xml += `
${i3}<file_locations>
${feature.file_locations.map((loc) => `${i4}<location>${escapeXml(loc)}</location>`).join('\n')}
${i3}</file_locations>`;
}
xml += `
${i2}</feature>`;
return xml;
}
/**
* Generate XML for an array of implemented features
*
* @param features - Array of features to convert to XML
* @param indent - The base indentation level (default: 2 spaces)
* @returns XML string for the implemented_features section content
*/
export function featuresToXml(features: ImplementedFeature[], indent: string = ' '): string {
return features.map((f) => featureToXml(f, indent)).join('\n');
}
/**
* Update the implemented_features section in XML content
*
* @param specContent - The full XML content
* @param newFeatures - The new features to set
* @param options - Optional extraction options
* @returns Updated XML content with the new implemented_features section
*/
export function updateImplementedFeaturesSection(
specContent: string,
newFeatures: ImplementedFeature[],
options: ExtractXmlOptions = {}
): string {
const log = options.logger || logger;
const indent = ' ';
// Generate new section content
const newSectionContent = featuresToXml(newFeatures, indent);
// Build the new section
const newSection = `<implemented_features>
${newSectionContent}
${indent}</implemented_features>`;
// Check if section exists
const sectionRegex = /<implemented_features>[\s\S]*?<\/implemented_features>/;
if (sectionRegex.test(specContent)) {
log.debug('Replacing existing implemented_features section');
return specContent.replace(sectionRegex, newSection);
}
// If section doesn't exist, try to insert after core_capabilities
const coreCapabilitiesEnd = '</core_capabilities>';
const insertIndex = specContent.indexOf(coreCapabilitiesEnd);
if (insertIndex !== -1) {
const insertPosition = insertIndex + coreCapabilitiesEnd.length;
log.debug('Inserting implemented_features after core_capabilities');
return (
specContent.slice(0, insertPosition) +
'\n\n' +
indent +
newSection +
specContent.slice(insertPosition)
);
}
// As a fallback, insert before </project_specification>
const projectSpecEnd = '</project_specification>';
const fallbackIndex = specContent.indexOf(projectSpecEnd);
if (fallbackIndex !== -1) {
log.debug('Inserting implemented_features before </project_specification>');
return (
specContent.slice(0, fallbackIndex) +
indent +
newSection +
'\n' +
specContent.slice(fallbackIndex)
);
}
log.warn?.('Could not find appropriate insertion point for implemented_features');
log.debug('Could not find appropriate insertion point for implemented_features');
return specContent;
}
/**
* Add a new feature to the implemented_features section
*
* @param specContent - The full XML content
* @param newFeature - The feature to add
* @param options - Optional extraction options
* @returns Updated XML content with the new feature added
*/
export function addImplementedFeature(
specContent: string,
newFeature: ImplementedFeature,
options: ExtractXmlOptions = {}
): string {
const log = options.logger || logger;
// Extract existing features
const existingFeatures = extractImplementedFeatures(specContent, options);
// Check for duplicates by name
const isDuplicate = existingFeatures.some(
(f) => f.name.toLowerCase() === newFeature.name.toLowerCase()
);
if (isDuplicate) {
log.debug(`Feature "${newFeature.name}" already exists, skipping`);
return specContent;
}
// Add the new feature
const updatedFeatures = [...existingFeatures, newFeature];
log.debug(`Adding feature "${newFeature.name}"`);
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
}
/**
* Remove a feature from the implemented_features section by name
*
* @param specContent - The full XML content
* @param featureName - The name of the feature to remove
* @param options - Optional extraction options
* @returns Updated XML content with the feature removed
*/
export function removeImplementedFeature(
specContent: string,
featureName: string,
options: ExtractXmlOptions = {}
): string {
const log = options.logger || logger;
// Extract existing features
const existingFeatures = extractImplementedFeatures(specContent, options);
// Filter out the feature to remove
const updatedFeatures = existingFeatures.filter(
(f) => f.name.toLowerCase() !== featureName.toLowerCase()
);
if (updatedFeatures.length === existingFeatures.length) {
log.debug(`Feature "${featureName}" not found, no changes made`);
return specContent;
}
log.debug(`Removing feature "${featureName}"`);
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
}
/**
* Update an existing feature in the implemented_features section
*
* @param specContent - The full XML content
* @param featureName - The name of the feature to update
* @param updates - Partial updates to apply to the feature
* @param options - Optional extraction options
* @returns Updated XML content with the feature modified
*/
export function updateImplementedFeature(
specContent: string,
featureName: string,
updates: Partial<ImplementedFeature>,
options: ExtractXmlOptions = {}
): string {
const log = options.logger || logger;
// Extract existing features
const existingFeatures = extractImplementedFeatures(specContent, options);
// Find and update the feature
let found = false;
const updatedFeatures = existingFeatures.map((f) => {
if (f.name.toLowerCase() === featureName.toLowerCase()) {
found = true;
return {
...f,
...updates,
// Preserve the original name if not explicitly updated
name: updates.name ?? f.name,
};
}
return f;
});
if (!found) {
log.debug(`Feature "${featureName}" not found, no changes made`);
return specContent;
}
log.debug(`Updating feature "${featureName}"`);
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
}
/**
* Check if a feature exists in the implemented_features section
*
* @param specContent - The full XML content
* @param featureName - The name of the feature to check
* @param options - Optional extraction options
* @returns True if the feature exists
*/
export function hasImplementedFeature(
specContent: string,
featureName: string,
options: ExtractXmlOptions = {}
): boolean {
const features = extractImplementedFeatures(specContent, options);
return features.some((f) => f.name.toLowerCase() === featureName.toLowerCase());
}
/**
* Convert extracted features to SpecOutput.implemented_features format
*
* @param features - Array of extracted features
* @returns Features in SpecOutput format
*/
export function toSpecOutputFeatures(
features: ImplementedFeature[]
): SpecOutput['implemented_features'] {
return features.map((f) => ({
name: f.name,
description: f.description,
...(f.file_locations && f.file_locations.length > 0
? { file_locations: f.file_locations }
: {}),
}));
}
/**
* Convert SpecOutput.implemented_features to ImplementedFeature format
*
* @param specFeatures - Features from SpecOutput
* @returns Features in ImplementedFeature format
*/
export function fromSpecOutputFeatures(
specFeatures: SpecOutput['implemented_features']
): ImplementedFeature[] {
return specFeatures.map((f) => ({
name: f.name,
description: f.description,
...(f.file_locations && f.file_locations.length > 0
? { file_locations: f.file_locations }
: {}),
}));
}
/**
* Represents a roadmap phase extracted from XML
*/
export interface RoadmapPhase {
name: string;
status: string;
description?: string;
}
/**
* Extract the technology stack from app_spec.txt XML content
*
* @param specContent - The full XML content
* @param options - Optional extraction options
* @returns Array of technology names
*/
export function extractTechnologyStack(
specContent: string,
options: ExtractXmlOptions = {}
): string[] {
const log = options.logger || logger;
const techSection = extractXmlSection(specContent, 'technology_stack', options);
if (!techSection) {
log.debug('No technology_stack section found');
return [];
}
const technologies = extractXmlElements(techSection, 'technology', options);
log.debug(`Extracted ${technologies.length} technologies`);
return technologies;
}
/**
* Update the technology_stack section in XML content
*
* @param specContent - The full XML content
* @param technologies - The new technology list
* @param options - Optional extraction options
* @returns Updated XML content
*/
export function updateTechnologyStack(
specContent: string,
technologies: string[],
options: ExtractXmlOptions = {}
): string {
const log = options.logger || logger;
const indent = ' ';
const i2 = indent.repeat(2);
// Generate new section content
const techXml = technologies
.map((t) => `${i2}<technology>${escapeXml(t)}</technology>`)
.join('\n');
const newSection = `<technology_stack>\n${techXml}\n${indent}</technology_stack>`;
// Check if section exists
const sectionRegex = /<technology_stack>[\s\S]*?<\/technology_stack>/;
if (sectionRegex.test(specContent)) {
log.debug('Replacing existing technology_stack section');
return specContent.replace(sectionRegex, newSection);
}
log.debug('No technology_stack section found to update');
return specContent;
}
/**
* Extract roadmap phases from app_spec.txt XML content
*
* @param specContent - The full XML content
* @param options - Optional extraction options
* @returns Array of roadmap phases
*/
export function extractRoadmapPhases(
specContent: string,
options: ExtractXmlOptions = {}
): RoadmapPhase[] {
const log = options.logger || logger;
const phases: RoadmapPhase[] = [];
const roadmapSection = extractXmlSection(specContent, 'implementation_roadmap', options);
if (!roadmapSection) {
log.debug('No implementation_roadmap section found');
return phases;
}
// Extract individual phase blocks
const phaseRegex = /<phase>([\s\S]*?)<\/phase>/g;
const phaseMatches = roadmapSection.matchAll(phaseRegex);
for (const phaseMatch of phaseMatches) {
const phaseContent = phaseMatch[1];
const nameMatch = phaseContent.match(/<name>([\s\S]*?)<\/name>/);
const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : '';
const statusMatch = phaseContent.match(/<status>([\s\S]*?)<\/status>/);
const status = statusMatch ? unescapeXml(statusMatch[1].trim()) : 'pending';
const descMatch = phaseContent.match(/<description>([\s\S]*?)<\/description>/);
const description = descMatch ? unescapeXml(descMatch[1].trim()) : undefined;
if (name) {
phases.push({ name, status, description });
}
}
log.debug(`Extracted ${phases.length} roadmap phases`);
return phases;
}
/**
* Update a roadmap phase status in XML content
*
* @param specContent - The full XML content
* @param phaseName - The name of the phase to update
* @param newStatus - The new status value
* @param options - Optional extraction options
* @returns Updated XML content
*/
export function updateRoadmapPhaseStatus(
specContent: string,
phaseName: string,
newStatus: string,
options: ExtractXmlOptions = {}
): string {
const log = options.logger || logger;
// Find the phase and update its status
// Match the phase block containing the specific name
const phaseRegex = new RegExp(
`(<phase>\\s*<name>\\s*${escapeXml(phaseName)}\\s*<\\/name>\\s*<status>)[\\s\\S]*?(<\\/status>)`,
'i'
);
if (phaseRegex.test(specContent)) {
log.debug(`Updating phase "${phaseName}" status to "${newStatus}"`);
return specContent.replace(phaseRegex, `$1${escapeXml(newStatus)}$2`);
}
log.debug(`Phase "${phaseName}" not found`);
return specContent;
}

View File

@@ -35,6 +35,7 @@ import {
type SubprocessOptions, type SubprocessOptions,
type WslCliResult, type WslCliResult,
} from '@automaker/platform'; } from '@automaker/platform';
import { calculateReasoningTimeout } from '@automaker/types';
import { createLogger, isAbortError } from '@automaker/utils'; import { createLogger, isAbortError } from '@automaker/utils';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import * as fs from 'fs'; import * as fs from 'fs';
@@ -107,6 +108,15 @@ export interface CliDetectionResult {
// Create logger for CLI operations // Create logger for CLI operations
const cliLogger = createLogger('CliProvider'); const cliLogger = createLogger('CliProvider');
/**
* Base timeout for CLI operations in milliseconds.
* CLI tools have longer startup and processing times compared to direct API calls,
* so we use a higher base timeout (120s) than the default provider timeout (30s).
* This is multiplied by reasoning effort multipliers when applicable.
* @see calculateReasoningTimeout from @automaker/types
*/
const CLI_BASE_TIMEOUT_MS = 120000;
/** /**
* Abstract base class for CLI-based providers * Abstract base class for CLI-based providers
* *
@@ -450,6 +460,10 @@ export abstract class CliProvider extends BaseProvider {
} }
} }
// Calculate dynamic timeout based on reasoning effort.
// This addresses GitHub issue #530 where reasoning models with 'xhigh' effort would timeout.
const timeout = calculateReasoningTimeout(options.reasoningEffort, CLI_BASE_TIMEOUT_MS);
// WSL strategy // WSL strategy
if (this.useWsl && this.wslCliPath) { if (this.useWsl && this.wslCliPath) {
const wslCwd = windowsToWslPath(cwd); const wslCwd = windowsToWslPath(cwd);
@@ -473,7 +487,7 @@ export abstract class CliProvider extends BaseProvider {
cwd, // Windows cwd for spawn cwd, // Windows cwd for spawn
env: filteredEnv, env: filteredEnv,
abortController: options.abortController, abortController: options.abortController,
timeout: 120000, // CLI operations may take longer timeout,
}; };
} }
@@ -488,7 +502,7 @@ export abstract class CliProvider extends BaseProvider {
cwd, cwd,
env: filteredEnv, env: filteredEnv,
abortController: options.abortController, abortController: options.abortController,
timeout: 120000, timeout,
}; };
} }
@@ -501,7 +515,7 @@ export abstract class CliProvider extends BaseProvider {
cwd, cwd,
env: filteredEnv, env: filteredEnv,
abortController: options.abortController, abortController: options.abortController,
timeout: 120000, timeout,
}; };
} }

View File

@@ -33,6 +33,8 @@ import {
CODEX_MODEL_MAP, CODEX_MODEL_MAP,
supportsReasoningEffort, supportsReasoningEffort,
validateBareModelId, validateBareModelId,
calculateReasoningTimeout,
DEFAULT_TIMEOUT_MS,
type CodexApprovalPolicy, type CodexApprovalPolicy,
type CodexSandboxMode, type CodexSandboxMode,
type CodexAuthStatus, type CodexAuthStatus,
@@ -91,7 +93,14 @@ const CODEX_ITEM_TYPES = {
const SYSTEM_PROMPT_LABEL = 'System instructions'; const SYSTEM_PROMPT_LABEL = 'System instructions';
const HISTORY_HEADER = 'Current request:\n'; const HISTORY_HEADER = 'Current request:\n';
const TEXT_ENCODING = 'utf-8'; const TEXT_ENCODING = 'utf-8';
const DEFAULT_TIMEOUT_MS = 30000; /**
* Default timeout for Codex CLI operations in milliseconds.
* This is the "no output" timeout - if the CLI doesn't produce any JSONL output
* for this duration, the process is killed. For reasoning models with high
* reasoning effort, this timeout is dynamically extended via calculateReasoningTimeout().
* @see calculateReasoningTimeout from @automaker/types
*/
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
const CONTEXT_WINDOW_256K = 256000; const CONTEXT_WINDOW_256K = 256000;
const MAX_OUTPUT_32K = 32000; const MAX_OUTPUT_32K = 32000;
const MAX_OUTPUT_16K = 16000; const MAX_OUTPUT_16K = 16000;
@@ -814,13 +823,19 @@ export class CodexProvider extends BaseProvider {
envOverrides[OPENAI_API_KEY_ENV] = executionPlan.openAiApiKey; envOverrides[OPENAI_API_KEY_ENV] = executionPlan.openAiApiKey;
} }
// Calculate dynamic timeout based on reasoning effort.
// Higher reasoning effort (e.g., 'xhigh' for "xtra thinking" mode) requires more time
// for the model to generate reasoning tokens before producing output.
// This fixes GitHub issue #530 where features would get stuck with reasoning models.
const timeout = calculateReasoningTimeout(options.reasoningEffort, CODEX_CLI_TIMEOUT_MS);
const stream = spawnJSONLProcess({ const stream = spawnJSONLProcess({
command: commandPath, command: commandPath,
args, args,
cwd: options.cwd, cwd: options.cwd,
env: envOverrides, env: envOverrides,
abortController: options.abortController, abortController: options.abortController,
timeout: DEFAULT_TIMEOUT_MS, timeout,
stdinData: promptText, // Pass prompt via stdin stdinData: promptText, // Pass prompt via stdin
}); });

View File

@@ -6,8 +6,17 @@ import { createLogger } from '@automaker/utils';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
// Types for running generation
export type GenerationType = 'spec_regeneration' | 'feature_generation' | 'sync';
interface RunningGeneration {
isRunning: boolean;
type: GenerationType;
startedAt: string;
}
// Shared state for tracking generation status - scoped by project path // Shared state for tracking generation status - scoped by project path
const runningProjects = new Map<string, boolean>(); const runningProjects = new Map<string, RunningGeneration>();
const abortControllers = new Map<string, AbortController>(); const abortControllers = new Map<string, AbortController>();
/** /**
@@ -17,16 +26,21 @@ export function getSpecRegenerationStatus(projectPath?: string): {
isRunning: boolean; isRunning: boolean;
currentAbortController: AbortController | null; currentAbortController: AbortController | null;
projectPath?: string; projectPath?: string;
type?: GenerationType;
startedAt?: string;
} { } {
if (projectPath) { if (projectPath) {
const generation = runningProjects.get(projectPath);
return { return {
isRunning: runningProjects.get(projectPath) || false, isRunning: generation?.isRunning || false,
currentAbortController: abortControllers.get(projectPath) || null, currentAbortController: abortControllers.get(projectPath) || null,
projectPath, projectPath,
type: generation?.type,
startedAt: generation?.startedAt,
}; };
} }
// Fallback: check if any project is running (for backward compatibility) // Fallback: check if any project is running (for backward compatibility)
const isAnyRunning = Array.from(runningProjects.values()).some((running) => running); const isAnyRunning = Array.from(runningProjects.values()).some((g) => g.isRunning);
return { isRunning: isAnyRunning, currentAbortController: null }; return { isRunning: isAnyRunning, currentAbortController: null };
} }
@@ -46,10 +60,15 @@ export function getRunningProjectPath(): string | null {
export function setRunningState( export function setRunningState(
projectPath: string, projectPath: string,
running: boolean, running: boolean,
controller: AbortController | null = null controller: AbortController | null = null,
type: GenerationType = 'spec_regeneration'
): void { ): void {
if (running) { if (running) {
runningProjects.set(projectPath, true); runningProjects.set(projectPath, {
isRunning: true,
type,
startedAt: new Date().toISOString(),
});
if (controller) { if (controller) {
abortControllers.set(projectPath, controller); abortControllers.set(projectPath, controller);
} }
@@ -59,6 +78,33 @@ export function setRunningState(
} }
} }
/**
* Get all running spec/feature generations for the running agents view
*/
export function getAllRunningGenerations(): Array<{
projectPath: string;
type: GenerationType;
startedAt: string;
}> {
const results: Array<{
projectPath: string;
type: GenerationType;
startedAt: string;
}> = [];
for (const [projectPath, generation] of runningProjects.entries()) {
if (generation.isRunning) {
results.push({
projectPath,
type: generation.type,
startedAt: generation.startedAt,
});
}
}
return results;
}
/** /**
* Helper to log authentication status * Helper to log authentication status
*/ */

View File

@@ -15,6 +15,7 @@ import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { getAppSpecPath } from '@automaker/platform'; import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js'; import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
import { FeatureLoader } from '../../services/feature-loader.js';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
@@ -56,13 +57,45 @@ export async function generateFeaturesFromSpec(
// Get customized prompts from settings // Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[FeatureGeneration]'); const prompts = await getPromptCustomization(settingsService, '[FeatureGeneration]');
// Load existing features to prevent duplicates
const featureLoader = new FeatureLoader();
const existingFeatures = await featureLoader.getAll(projectPath);
logger.info(`Found ${existingFeatures.length} existing features to exclude from generation`);
// Build existing features context for the prompt
let existingFeaturesContext = '';
if (existingFeatures.length > 0) {
const featuresList = existingFeatures
.map(
(f) =>
`- "${f.title}" (ID: ${f.id}): ${f.description?.substring(0, 100) || 'No description'}`
)
.join('\n');
existingFeaturesContext = `
## EXISTING FEATURES (DO NOT REGENERATE THESE)
The following ${existingFeatures.length} features already exist in the project. You MUST NOT generate features that duplicate or overlap with these:
${featuresList}
CRITICAL INSTRUCTIONS:
- DO NOT generate any features with the same or similar titles as the existing features listed above
- DO NOT generate features that cover the same functionality as existing features
- ONLY generate NEW features that are not yet in the system
- If a feature from the roadmap already exists, skip it entirely
- Generate unique feature IDs that do not conflict with existing IDs: ${existingFeatures.map((f) => f.id).join(', ')}
`;
}
const prompt = `Based on this project specification: const prompt = `Based on this project specification:
${spec} ${spec}
${existingFeaturesContext}
${prompts.appSpec.generateFeaturesFromSpecPrompt} ${prompts.appSpec.generateFeaturesFromSpecPrompt}
Generate ${featureCount} features that build on each other logically.`; Generate ${featureCount} NEW features that build on each other logically. Remember: ONLY generate features that DO NOT already exist.`;
logger.info('========== PROMPT BEING SENT =========='); logger.info('========== PROMPT BEING SENT ==========');
logger.info(`Prompt length: ${prompt.length} chars`); logger.info(`Prompt length: ${prompt.length} chars`);

View File

@@ -201,19 +201,33 @@ Your entire response should be valid JSON starting with { and ending with }. No
xmlContent = responseText.substring(xmlStart, xmlEnd + '</project_specification>'.length); xmlContent = responseText.substring(xmlStart, xmlEnd + '</project_specification>'.length);
logger.info(`Extracted XML content: ${xmlContent.length} chars (from position ${xmlStart})`); logger.info(`Extracted XML content: ${xmlContent.length} chars (from position ${xmlStart})`);
} else { } else {
// No valid XML structure found in the response text // No XML found, try JSON extraction
// This happens when structured output was expected but not received, and the agent logger.warn('⚠️ No XML tags found, attempting JSON extraction...');
// output conversational text instead of XML (e.g., "The project directory appears to be empty...") const extractedJson = extractJson<SpecOutput>(responseText, { logger });
// We should NOT save this conversational text as it's not a valid spec
logger.error('❌ Response does not contain valid <project_specification> XML structure'); if (
logger.error( extractedJson &&
'This typically happens when structured output failed and the agent produced conversational text instead of XML' typeof extractedJson.project_name === 'string' &&
); typeof extractedJson.overview === 'string' &&
throw new Error( Array.isArray(extractedJson.technology_stack) &&
'Failed to generate spec: No valid XML structure found in response. ' + Array.isArray(extractedJson.core_capabilities) &&
'The response contained conversational text but no <project_specification> tags. ' + Array.isArray(extractedJson.implemented_features)
'Please try again.' ) {
); logger.info('✅ Successfully extracted JSON from response text');
xmlContent = specToXml(extractedJson);
logger.info(`✅ Converted extracted JSON to XML: ${xmlContent.length} chars`);
} else {
// Neither XML nor valid JSON found
logger.error('❌ Response does not contain valid XML or JSON structure');
logger.error(
'This typically happens when structured output failed and the agent produced conversational text instead of structured output'
);
throw new Error(
'Failed to generate spec: No valid XML or JSON structure found in response. ' +
'The response contained conversational text but no <project_specification> tags or valid JSON. ' +
'Please try again.'
);
}
} }
} }

View File

@@ -7,6 +7,7 @@ import type { EventEmitter } from '../../lib/events.js';
import { createCreateHandler } from './routes/create.js'; import { createCreateHandler } from './routes/create.js';
import { createGenerateHandler } from './routes/generate.js'; import { createGenerateHandler } from './routes/generate.js';
import { createGenerateFeaturesHandler } from './routes/generate-features.js'; import { createGenerateFeaturesHandler } from './routes/generate-features.js';
import { createSyncHandler } from './routes/sync.js';
import { createStopHandler } from './routes/stop.js'; import { createStopHandler } from './routes/stop.js';
import { createStatusHandler } from './routes/status.js'; import { createStatusHandler } from './routes/status.js';
import type { SettingsService } from '../../services/settings-service.js'; import type { SettingsService } from '../../services/settings-service.js';
@@ -20,6 +21,7 @@ export function createSpecRegenerationRoutes(
router.post('/create', createCreateHandler(events)); router.post('/create', createCreateHandler(events));
router.post('/generate', createGenerateHandler(events, settingsService)); router.post('/generate', createGenerateHandler(events, settingsService));
router.post('/generate-features', createGenerateFeaturesHandler(events, settingsService)); router.post('/generate-features', createGenerateFeaturesHandler(events, settingsService));
router.post('/sync', createSyncHandler(events, settingsService));
router.post('/stop', createStopHandler()); router.post('/stop', createStopHandler());
router.get('/status', createStatusHandler()); router.get('/status', createStatusHandler());

View File

@@ -5,9 +5,10 @@
import path from 'path'; import path from 'path';
import * as secureFs from '../../lib/secure-fs.js'; import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js'; import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils'; import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/utils';
import { getFeaturesDir } from '@automaker/platform'; import { getFeaturesDir } from '@automaker/platform';
import { extractJsonWithArray } from '../../lib/json-extractor.js'; import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { getNotificationService } from '../../services/notification-service.js';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
@@ -73,10 +74,10 @@ export async function parseAndCreateFeatures(
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
await secureFs.writeFile( // Use atomic write with backup support for crash protection
path.join(featureDir, 'feature.json'), await atomicWriteJson(path.join(featureDir, 'feature.json'), featureData, {
JSON.stringify(featureData, null, 2) backupCount: DEFAULT_BACKUP_COUNT,
); });
createdFeatures.push({ id: feature.id, title: feature.title }); createdFeatures.push({ id: feature.id, title: feature.title });
} }
@@ -88,6 +89,15 @@ export async function parseAndCreateFeatures(
message: `Spec regeneration complete! Created ${createdFeatures.length} features.`, message: `Spec regeneration complete! Created ${createdFeatures.length} features.`,
projectPath: projectPath, projectPath: projectPath,
}); });
// Create notification for spec generation completion
const notificationService = getNotificationService();
await notificationService.createNotification({
type: 'spec_regeneration_complete',
title: 'Spec Generation Complete',
message: `Created ${createdFeatures.length} features from the project specification.`,
projectPath: projectPath,
});
} catch (error) { } catch (error) {
logger.error('❌ parseAndCreateFeatures() failed:'); logger.error('❌ parseAndCreateFeatures() failed:');
logger.error('Error:', error); logger.error('Error:', error);

View File

@@ -50,7 +50,7 @@ export function createGenerateFeaturesHandler(
logAuthStatus('Before starting feature generation'); logAuthStatus('Before starting feature generation');
const abortController = new AbortController(); const abortController = new AbortController();
setRunningState(projectPath, true, abortController); setRunningState(projectPath, true, abortController, 'feature_generation');
logger.info('Starting background feature generation task...'); logger.info('Starting background feature generation task...');
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService) generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)

View File

@@ -0,0 +1,76 @@
/**
* POST /sync endpoint - Sync spec with codebase and features
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { createLogger } from '@automaker/utils';
import {
getSpecRegenerationStatus,
setRunningState,
logAuthStatus,
logError,
getErrorMessage,
} from '../common.js';
import { syncSpec } from '../sync-spec.js';
import type { SettingsService } from '../../../services/settings-service.js';
const logger = createLogger('SpecSync');
export function createSyncHandler(events: EventEmitter, settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
logger.info('========== /sync endpoint called ==========');
logger.debug('Request body:', JSON.stringify(req.body, null, 2));
try {
const { projectPath } = req.body as {
projectPath: string;
};
logger.debug('projectPath:', projectPath);
if (!projectPath) {
logger.error('Missing projectPath parameter');
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
const { isRunning } = getSpecRegenerationStatus(projectPath);
if (isRunning) {
logger.warn('Generation/sync already running for project:', projectPath);
res.json({ success: false, error: 'Operation already running for this project' });
return;
}
logAuthStatus('Before starting spec sync');
const abortController = new AbortController();
setRunningState(projectPath, true, abortController, 'sync');
logger.info('Starting background spec sync task...');
syncSpec(projectPath, events, abortController, settingsService)
.then((result) => {
logger.info('Spec sync completed successfully');
logger.info('Result:', JSON.stringify(result, null, 2));
})
.catch((error) => {
logError(error, 'Spec sync failed with error');
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
error: getErrorMessage(error),
projectPath,
});
})
.finally(() => {
logger.info('Spec sync task finished (success or error)');
setRunningState(projectPath, false, null);
});
logger.info('Returning success response (sync running in background)');
res.json({ success: true });
} catch (error) {
logError(error, 'Sync route handler failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,307 @@
/**
* Sync spec with current codebase and feature state
*
* Updates the spec file based on:
* - Completed Automaker features
* - Code analysis for tech stack and implementations
* - Roadmap phase status updates
*/
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 { resolvePhaseModel } from '@automaker/model-resolver';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
import { FeatureLoader } from '../../services/feature-loader.js';
import {
extractImplementedFeatures,
extractTechnologyStack,
extractRoadmapPhases,
updateImplementedFeaturesSection,
updateTechnologyStack,
updateRoadmapPhaseStatus,
type ImplementedFeature,
type RoadmapPhase,
} from '../../lib/xml-extractor.js';
import { getNotificationService } from '../../services/notification-service.js';
const logger = createLogger('SpecSync');
/**
* Result of a sync operation
*/
export interface SyncResult {
techStackUpdates: {
added: string[];
removed: string[];
};
implementedFeaturesUpdates: {
addedFromFeatures: string[];
removed: string[];
};
roadmapUpdates: Array<{ phaseName: string; newStatus: string }>;
summary: string;
}
/**
* Sync the spec with current codebase and feature state
*/
export async function syncSpec(
projectPath: string,
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService
): Promise<SyncResult> {
logger.info('========== syncSpec() started ==========');
logger.info('projectPath:', projectPath);
const result: SyncResult = {
techStackUpdates: { added: [], removed: [] },
implementedFeaturesUpdates: { addedFromFeatures: [], removed: [] },
roadmapUpdates: [],
summary: '',
};
// Read existing spec
const specPath = getAppSpecPath(projectPath);
let specContent: string;
try {
specContent = (await secureFs.readFile(specPath, 'utf-8')) as string;
logger.info(`Spec loaded successfully (${specContent.length} chars)`);
} catch (readError) {
logger.error('Failed to read spec file:', readError);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
error: 'No project spec found. Create or regenerate spec first.',
projectPath,
});
throw new Error('No project spec found');
}
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: '[Phase: sync] Starting spec sync...\n',
projectPath,
});
// Extract current state from spec
const currentImplementedFeatures = extractImplementedFeatures(specContent);
const currentTechStack = extractTechnologyStack(specContent);
const currentRoadmapPhases = extractRoadmapPhases(specContent);
logger.info(`Current spec has ${currentImplementedFeatures.length} implemented features`);
logger.info(`Current spec has ${currentTechStack.length} technologies`);
logger.info(`Current spec has ${currentRoadmapPhases.length} roadmap phases`);
// Load completed Automaker features
const featureLoader = new FeatureLoader();
const allFeatures = await featureLoader.getAll(projectPath);
const completedFeatures = allFeatures.filter(
(f) => f.status === 'completed' || f.status === 'verified'
);
logger.info(`Found ${completedFeatures.length} completed/verified features in Automaker`);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: `Found ${completedFeatures.length} completed features to sync...\n`,
projectPath,
});
// Build new implemented features list from completed Automaker features
const newImplementedFeatures: ImplementedFeature[] = [];
const existingNames = new Set(currentImplementedFeatures.map((f) => f.name.toLowerCase()));
for (const feature of completedFeatures) {
const name = feature.title || `Feature: ${feature.id}`;
if (!existingNames.has(name.toLowerCase())) {
newImplementedFeatures.push({
name,
description: feature.description || '',
});
result.implementedFeaturesUpdates.addedFromFeatures.push(name);
}
}
// Merge: keep existing + add new from completed features
const mergedFeatures = [...currentImplementedFeatures, ...newImplementedFeatures];
// Update spec with merged features
if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) {
specContent = updateImplementedFeaturesSection(specContent, mergedFeatures);
logger.info(
`Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} features to spec`
);
}
// Analyze codebase for tech stack updates using AI
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: 'Analyzing codebase for technology updates...\n',
projectPath,
});
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[SpecSync]'
);
const settings = await settingsService?.getGlobalSettings();
const phaseModelEntry =
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
// Use AI to analyze tech stack
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
Current known technologies: ${currentTechStack.join(', ')}
Look at package.json, config files, and source code to identify:
- Frameworks (React, Vue, Express, etc.)
- Languages (TypeScript, JavaScript, Python, etc.)
- Build tools (Vite, Webpack, etc.)
- Databases (PostgreSQL, MongoDB, etc.)
- Key libraries and tools
Return ONLY this JSON format, no other text:
{
"technologies": ["Technology 1", "Technology 2", ...]
}`;
try {
const techResult = await streamingQuery({
prompt: techAnalysisPrompt,
model,
cwd: projectPath,
maxTurns: 10,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
thinkingLevel,
readOnly: true,
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
onText: (text) => {
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
},
});
// Parse tech stack from response
const jsonMatch = techResult.text.match(/\{[\s\S]*"technologies"[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
if (Array.isArray(parsed.technologies)) {
const newTechStack = parsed.technologies as string[];
// Calculate differences
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
for (const tech of newTechStack) {
if (!currentSet.has(tech.toLowerCase())) {
result.techStackUpdates.added.push(tech);
}
}
for (const tech of currentTechStack) {
if (!newSet.has(tech.toLowerCase())) {
result.techStackUpdates.removed.push(tech);
}
}
// Update spec with new tech stack if there are changes
if (
result.techStackUpdates.added.length > 0 ||
result.techStackUpdates.removed.length > 0
) {
specContent = updateTechnologyStack(specContent, newTechStack);
logger.info(
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
);
}
}
}
} catch (error) {
logger.warn('Failed to analyze tech stack:', error);
// Continue with other sync operations
}
// Update roadmap phase statuses based on completed features
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: 'Checking roadmap phase statuses...\n',
projectPath,
});
// For each phase, check if all its features are completed
// This is a heuristic - we check if the phase name appears in any feature titles/descriptions
for (const phase of currentRoadmapPhases) {
if (phase.status === 'completed') continue; // Already completed
// Check if this phase should be marked as completed
// A phase is considered complete if we have completed features that mention it
const phaseNameLower = phase.name.toLowerCase();
const relatedCompletedFeatures = completedFeatures.filter(
(f) =>
f.title?.toLowerCase().includes(phaseNameLower) ||
f.description?.toLowerCase().includes(phaseNameLower) ||
f.category?.toLowerCase().includes(phaseNameLower)
);
// If we have related completed features and the phase is still pending/in_progress,
// update it to in_progress or completed based on feature count
if (relatedCompletedFeatures.length > 0 && phase.status !== 'completed') {
const newStatus = 'in_progress';
specContent = updateRoadmapPhaseStatus(specContent, phase.name, newStatus);
result.roadmapUpdates.push({ phaseName: phase.name, newStatus });
logger.info(`Updated phase "${phase.name}" to ${newStatus}`);
}
}
// Save updated spec
await secureFs.writeFile(specPath, specContent, 'utf-8');
logger.info('Spec saved successfully');
// Build summary
const summaryParts: string[] = [];
if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) {
summaryParts.push(
`Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} implemented features`
);
}
if (result.techStackUpdates.added.length > 0) {
summaryParts.push(`Added ${result.techStackUpdates.added.length} technologies`);
}
if (result.techStackUpdates.removed.length > 0) {
summaryParts.push(`Removed ${result.techStackUpdates.removed.length} technologies`);
}
if (result.roadmapUpdates.length > 0) {
summaryParts.push(`Updated ${result.roadmapUpdates.length} roadmap phases`);
}
result.summary = summaryParts.length > 0 ? summaryParts.join(', ') : 'Spec is already up to date';
// Create notification
const notificationService = getNotificationService();
await notificationService.createNotification({
type: 'spec_regeneration_complete',
title: 'Spec Sync Complete',
message: result.summary,
projectPath,
});
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_complete',
message: `Spec sync complete! ${result.summary}`,
projectPath,
});
logger.info('========== syncSpec() completed ==========');
logger.info('Summary:', result.summary);
return result;
}

View File

@@ -0,0 +1,19 @@
/**
* Common utilities for event history routes
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
/** Logger instance for event history operations */
export const logger = createLogger('EventHistory');
/**
* Extract user-friendly error message from error objects
*/
export { getErrorMessageShared as getErrorMessage };
/**
* Log error with automatic logger binding
*/
export const logError = createLogError(logger);

View File

@@ -0,0 +1,68 @@
/**
* Event History routes - HTTP API for event history management
*
* Provides endpoints for:
* - Listing events with filtering
* - Getting individual event details
* - Deleting events
* - Clearing all events
* - Replaying events to test hooks
*
* Mounted at /api/event-history in the main server.
*/
import { Router } from 'express';
import type { EventHistoryService } from '../../services/event-history-service.js';
import type { SettingsService } from '../../services/settings-service.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js';
import { createGetHandler } from './routes/get.js';
import { createDeleteHandler } from './routes/delete.js';
import { createClearHandler } from './routes/clear.js';
import { createReplayHandler } from './routes/replay.js';
/**
* Create event history router with all endpoints
*
* Endpoints:
* - POST /list - List events with optional filtering
* - POST /get - Get a single event by ID
* - POST /delete - Delete an event by ID
* - POST /clear - Clear all events for a project
* - POST /replay - Replay an event to trigger hooks
*
* @param eventHistoryService - Instance of EventHistoryService
* @param settingsService - Instance of SettingsService (for replay)
* @returns Express Router configured with all event history endpoints
*/
export function createEventHistoryRoutes(
eventHistoryService: EventHistoryService,
settingsService: SettingsService
): Router {
const router = Router();
// List events with filtering
router.post('/list', validatePathParams('projectPath'), createListHandler(eventHistoryService));
// Get single event
router.post('/get', validatePathParams('projectPath'), createGetHandler(eventHistoryService));
// Delete event
router.post(
'/delete',
validatePathParams('projectPath'),
createDeleteHandler(eventHistoryService)
);
// Clear all events
router.post('/clear', validatePathParams('projectPath'), createClearHandler(eventHistoryService));
// Replay event
router.post(
'/replay',
validatePathParams('projectPath'),
createReplayHandler(eventHistoryService, settingsService)
);
return router;
}

View File

@@ -0,0 +1,33 @@
/**
* POST /api/event-history/clear - Clear all events for a project
*
* Request body: { projectPath: string }
* Response: { success: true, cleared: number }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createClearHandler(eventHistoryService: EventHistoryService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const cleared = await eventHistoryService.clearEvents(projectPath);
res.json({
success: true,
cleared,
});
} catch (error) {
logError(error, 'Clear events failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,43 @@
/**
* POST /api/event-history/delete - Delete an event by ID
*
* Request body: { projectPath: string, eventId: string }
* Response: { success: true } or { success: false, error: string }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createDeleteHandler(eventHistoryService: EventHistoryService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, eventId } = req.body as {
projectPath: string;
eventId: string;
};
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!eventId || typeof eventId !== 'string') {
res.status(400).json({ success: false, error: 'eventId is required' });
return;
}
const deleted = await eventHistoryService.deleteEvent(projectPath, eventId);
if (!deleted) {
res.status(404).json({ success: false, error: 'Event not found' });
return;
}
res.json({ success: true });
} catch (error) {
logError(error, 'Delete event failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,46 @@
/**
* POST /api/event-history/get - Get a single event by ID
*
* Request body: { projectPath: string, eventId: string }
* Response: { success: true, event: StoredEvent } or { success: false, error: string }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createGetHandler(eventHistoryService: EventHistoryService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, eventId } = req.body as {
projectPath: string;
eventId: string;
};
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!eventId || typeof eventId !== 'string') {
res.status(400).json({ success: false, error: 'eventId is required' });
return;
}
const event = await eventHistoryService.getEvent(projectPath, eventId);
if (!event) {
res.status(404).json({ success: false, error: 'Event not found' });
return;
}
res.json({
success: true,
event,
});
} catch (error) {
logError(error, 'Get event failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,53 @@
/**
* POST /api/event-history/list - List events for a project
*
* Request body: {
* projectPath: string,
* filter?: {
* trigger?: EventHookTrigger,
* featureId?: string,
* since?: string,
* until?: string,
* limit?: number,
* offset?: number
* }
* }
* Response: { success: true, events: StoredEventSummary[], total: number }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import type { EventHistoryFilter } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
export function createListHandler(eventHistoryService: EventHistoryService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, filter } = req.body as {
projectPath: string;
filter?: EventHistoryFilter;
};
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const events = await eventHistoryService.getEvents(projectPath, filter);
const total = await eventHistoryService.getEventCount(projectPath, {
...filter,
limit: undefined,
offset: undefined,
});
res.json({
success: true,
events,
total,
});
} catch (error) {
logError(error, 'List events failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,234 @@
/**
* POST /api/event-history/replay - Replay an event to trigger hooks
*
* Request body: {
* projectPath: string,
* eventId: string,
* hookIds?: string[] // Optional: specific hooks to run (if not provided, runs all enabled matching hooks)
* }
* Response: { success: true, result: EventReplayResult }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import type { EventReplayResult, EventReplayHookResult, EventHook } from '@automaker/types';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError, logger } from '../common.js';
const execAsync = promisify(exec);
/** Default timeout for shell commands (30 seconds) */
const DEFAULT_SHELL_TIMEOUT = 30000;
/** Default timeout for HTTP requests (10 seconds) */
const DEFAULT_HTTP_TIMEOUT = 10000;
interface HookContext {
featureId?: string;
featureName?: string;
projectPath?: string;
projectName?: string;
error?: string;
errorType?: string;
timestamp: string;
eventType: string;
}
/**
* Substitute {{variable}} placeholders in a string
*/
function substituteVariables(template: string, context: HookContext): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
const value = context[variable as keyof HookContext];
if (value === undefined || value === null) {
return '';
}
return String(value);
});
}
/**
* Execute a single hook and return the result
*/
async function executeHook(hook: EventHook, context: HookContext): Promise<EventReplayHookResult> {
const hookName = hook.name || hook.id;
const startTime = Date.now();
try {
if (hook.action.type === 'shell') {
const command = substituteVariables(hook.action.command, context);
const timeout = hook.action.timeout || DEFAULT_SHELL_TIMEOUT;
logger.info(`Replaying shell hook "${hookName}": ${command}`);
await execAsync(command, {
timeout,
maxBuffer: 1024 * 1024,
});
return {
hookId: hook.id,
hookName: hook.name,
success: true,
durationMs: Date.now() - startTime,
};
} else if (hook.action.type === 'http') {
const url = substituteVariables(hook.action.url, context);
const method = hook.action.method || 'POST';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (hook.action.headers) {
for (const [key, value] of Object.entries(hook.action.headers)) {
headers[key] = substituteVariables(value, context);
}
}
let body: string | undefined;
if (hook.action.body) {
body = substituteVariables(hook.action.body, context);
} else if (method !== 'GET') {
body = JSON.stringify({
eventType: context.eventType,
timestamp: context.timestamp,
featureId: context.featureId,
projectPath: context.projectPath,
projectName: context.projectName,
error: context.error,
});
}
logger.info(`Replaying HTTP hook "${hookName}": ${method} ${url}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_HTTP_TIMEOUT);
const response = await fetch(url, {
method,
headers,
body: method !== 'GET' ? body : undefined,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return {
hookId: hook.id,
hookName: hook.name,
success: false,
error: `HTTP ${response.status}: ${response.statusText}`,
durationMs: Date.now() - startTime,
};
}
return {
hookId: hook.id,
hookName: hook.name,
success: true,
durationMs: Date.now() - startTime,
};
}
return {
hookId: hook.id,
hookName: hook.name,
success: false,
error: 'Unknown hook action type',
durationMs: Date.now() - startTime,
};
} catch (error) {
const errorMessage =
error instanceof Error
? error.name === 'AbortError'
? 'Request timed out'
: error.message
: String(error);
return {
hookId: hook.id,
hookName: hook.name,
success: false,
error: errorMessage,
durationMs: Date.now() - startTime,
};
}
}
export function createReplayHandler(
eventHistoryService: EventHistoryService,
settingsService: SettingsService
) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, eventId, hookIds } = req.body as {
projectPath: string;
eventId: string;
hookIds?: string[];
};
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!eventId || typeof eventId !== 'string') {
res.status(400).json({ success: false, error: 'eventId is required' });
return;
}
// Get the event
const event = await eventHistoryService.getEvent(projectPath, eventId);
if (!event) {
res.status(404).json({ success: false, error: 'Event not found' });
return;
}
// Get hooks from settings
const settings = await settingsService.getGlobalSettings();
let hooks = settings.eventHooks || [];
// Filter to matching trigger and enabled hooks
hooks = hooks.filter((h) => h.enabled && h.trigger === event.trigger);
// If specific hook IDs requested, filter to those
if (hookIds && hookIds.length > 0) {
hooks = hooks.filter((h) => hookIds.includes(h.id));
}
// Build context for variable substitution
const context: HookContext = {
featureId: event.featureId,
featureName: event.featureName,
projectPath: event.projectPath,
projectName: event.projectName,
error: event.error,
errorType: event.errorType,
timestamp: event.timestamp,
eventType: event.trigger,
};
// Execute all hooks in parallel
const hookResults = await Promise.all(hooks.map((hook) => executeHook(hook, context)));
const result: EventReplayResult = {
eventId,
hooksTriggered: hooks.length,
hookResults,
};
logger.info(`Replayed event ${eventId}: ${hooks.length} hooks triggered`);
res.json({
success: true,
result,
});
} catch (error) {
logError(error, 'Replay event failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -5,6 +5,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { FeatureLoader } from '../../services/feature-loader.js'; import { FeatureLoader } from '../../services/feature-loader.js';
import type { SettingsService } from '../../services/settings-service.js'; import type { SettingsService } from '../../services/settings-service.js';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js'; import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js'; import { createListHandler } from './routes/list.js';
import { createGetHandler } from './routes/get.js'; import { createGetHandler } from './routes/get.js';
@@ -18,13 +19,18 @@ import { createGenerateTitleHandler } from './routes/generate-title.js';
export function createFeaturesRoutes( export function createFeaturesRoutes(
featureLoader: FeatureLoader, featureLoader: FeatureLoader,
settingsService?: SettingsService settingsService?: SettingsService,
events?: EventEmitter
): Router { ): Router {
const router = Router(); const router = Router();
router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader)); router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader));
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader)); router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
router.post('/create', validatePathParams('projectPath'), createCreateHandler(featureLoader)); router.post(
'/create',
validatePathParams('projectPath'),
createCreateHandler(featureLoader, events)
);
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader)); router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
router.post( router.post(
'/bulk-update', '/bulk-update',

View File

@@ -4,10 +4,11 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js'; import { FeatureLoader } from '../../../services/feature-loader.js';
import type { EventEmitter } from '../../../lib/events.js';
import type { Feature } from '@automaker/types'; import type { Feature } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js'; import { getErrorMessage, logError } from '../common.js';
export function createCreateHandler(featureLoader: FeatureLoader) { export function createCreateHandler(featureLoader: FeatureLoader, events?: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath, feature } = req.body as { const { projectPath, feature } = req.body as {
@@ -23,7 +24,30 @@ export function createCreateHandler(featureLoader: FeatureLoader) {
return; return;
} }
// Check for duplicate title if title is provided
if (feature.title && feature.title.trim()) {
const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title);
if (duplicate) {
res.status(409).json({
success: false,
error: `A feature with title "${feature.title}" already exists`,
duplicateFeatureId: duplicate.id,
});
return;
}
}
const created = await featureLoader.create(projectPath, feature); const created = await featureLoader.create(projectPath, feature);
// Emit feature_created event for hooks
if (events) {
events.emit('feature:created', {
featureId: created.id,
featureName: created.name,
projectPath,
});
}
res.json({ success: true, feature: created }); res.json({ success: true, feature: created });
} catch (error) { } catch (error) {
logError(error, 'Create feature failed'); logError(error, 'Create feature failed');

View File

@@ -4,8 +4,14 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js'; import { FeatureLoader } from '../../../services/feature-loader.js';
import type { Feature } from '@automaker/types'; import type { Feature, FeatureStatus } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js'; import { getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('features/update');
// Statuses that should trigger syncing to app_spec.txt
const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed'];
export function createUpdateHandler(featureLoader: FeatureLoader) { export function createUpdateHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -34,6 +40,28 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
return; return;
} }
// Check for duplicate title if title is being updated
if (updates.title && updates.title.trim()) {
const duplicate = await featureLoader.findDuplicateTitle(
projectPath,
updates.title,
featureId // Exclude the current feature from duplicate check
);
if (duplicate) {
res.status(409).json({
success: false,
error: `A feature with title "${updates.title}" already exists`,
duplicateFeatureId: duplicate.id,
});
return;
}
}
// Get the current feature to detect status changes
const currentFeature = await featureLoader.get(projectPath, featureId);
const previousStatus = currentFeature?.status as FeatureStatus | undefined;
const newStatus = updates.status as FeatureStatus | undefined;
const updated = await featureLoader.update( const updated = await featureLoader.update(
projectPath, projectPath,
featureId, featureId,
@@ -42,6 +70,22 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
enhancementMode, enhancementMode,
preEnhancementDescription preEnhancementDescription
); );
// Trigger sync to app_spec.txt when status changes to verified or completed
if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) {
try {
const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated);
if (synced) {
logger.info(
`Synced feature "${updated.title || updated.id}" to app_spec.txt on status change to ${newStatus}`
);
}
} catch (syncError) {
// Log the sync error but don't fail the update operation
logger.error(`Failed to sync feature to app_spec.txt:`, syncError);
}
}
res.json({ success: true, feature: updated }); res.json({ success: true, feature: updated });
} catch (error) { } catch (error) {
logError(error, 'Update feature failed'); logError(error, 'Update feature failed');

View File

@@ -0,0 +1,21 @@
/**
* Common utilities for notification routes
*
* Provides logger and error handling utilities shared across all notification endpoints.
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
/** Logger instance for notification-related operations */
export const logger = createLogger('Notifications');
/**
* Extract user-friendly error message from error objects
*/
export { getErrorMessageShared as getErrorMessage };
/**
* Log error with automatic logger binding
*/
export const logError = createLogError(logger);

View File

@@ -0,0 +1,62 @@
/**
* Notifications routes - HTTP API for project-level notifications
*
* Provides endpoints for:
* - Listing notifications
* - Getting unread count
* - Marking notifications as read
* - Dismissing notifications
*
* All endpoints use handler factories that receive the NotificationService instance.
* Mounted at /api/notifications in the main server.
*/
import { Router } from 'express';
import type { NotificationService } from '../../services/notification-service.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js';
import { createUnreadCountHandler } from './routes/unread-count.js';
import { createMarkReadHandler } from './routes/mark-read.js';
import { createDismissHandler } from './routes/dismiss.js';
/**
* Create notifications router with all endpoints
*
* Endpoints:
* - POST /list - List all notifications for a project
* - POST /unread-count - Get unread notification count
* - POST /mark-read - Mark notification(s) as read
* - POST /dismiss - Dismiss notification(s)
*
* @param notificationService - Instance of NotificationService
* @returns Express Router configured with all notification endpoints
*/
export function createNotificationsRoutes(notificationService: NotificationService): Router {
const router = Router();
// List notifications
router.post('/list', validatePathParams('projectPath'), createListHandler(notificationService));
// Get unread count
router.post(
'/unread-count',
validatePathParams('projectPath'),
createUnreadCountHandler(notificationService)
);
// Mark as read (single or all)
router.post(
'/mark-read',
validatePathParams('projectPath'),
createMarkReadHandler(notificationService)
);
// Dismiss (single or all)
router.post(
'/dismiss',
validatePathParams('projectPath'),
createDismissHandler(notificationService)
);
return router;
}

View File

@@ -0,0 +1,53 @@
/**
* POST /api/notifications/dismiss - Dismiss notification(s)
*
* Request body: { projectPath: string, notificationId?: string }
* - If notificationId provided: dismisses that notification
* - If notificationId not provided: dismisses all notifications
*
* Response: { success: true, dismissed: boolean | count: number }
*/
import type { Request, Response } from 'express';
import type { NotificationService } from '../../../services/notification-service.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create handler for POST /api/notifications/dismiss
*
* @param notificationService - Instance of NotificationService
* @returns Express request handler
*/
export function createDismissHandler(notificationService: NotificationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, notificationId } = req.body;
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
// If notificationId provided, dismiss single notification
if (notificationId) {
const dismissed = await notificationService.dismissNotification(
projectPath,
notificationId
);
if (!dismissed) {
res.status(404).json({ success: false, error: 'Notification not found' });
return;
}
res.json({ success: true, dismissed: true });
return;
}
// Otherwise dismiss all
const count = await notificationService.dismissAll(projectPath);
res.json({ success: true, count });
} catch (error) {
logError(error, 'Dismiss failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,39 @@
/**
* POST /api/notifications/list - List all notifications for a project
*
* Request body: { projectPath: string }
* Response: { success: true, notifications: Notification[] }
*/
import type { Request, Response } from 'express';
import type { NotificationService } from '../../../services/notification-service.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create handler for POST /api/notifications/list
*
* @param notificationService - Instance of NotificationService
* @returns Express request handler
*/
export function createListHandler(notificationService: NotificationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body;
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const notifications = await notificationService.getNotifications(projectPath);
res.json({
success: true,
notifications,
});
} catch (error) {
logError(error, 'List notifications failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,50 @@
/**
* POST /api/notifications/mark-read - Mark notification(s) as read
*
* Request body: { projectPath: string, notificationId?: string }
* - If notificationId provided: marks that notification as read
* - If notificationId not provided: marks all notifications as read
*
* Response: { success: true, count?: number, notification?: Notification }
*/
import type { Request, Response } from 'express';
import type { NotificationService } from '../../../services/notification-service.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create handler for POST /api/notifications/mark-read
*
* @param notificationService - Instance of NotificationService
* @returns Express request handler
*/
export function createMarkReadHandler(notificationService: NotificationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, notificationId } = req.body;
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
// If notificationId provided, mark single notification
if (notificationId) {
const notification = await notificationService.markAsRead(projectPath, notificationId);
if (!notification) {
res.status(404).json({ success: false, error: 'Notification not found' });
return;
}
res.json({ success: true, notification });
return;
}
// Otherwise mark all as read
const count = await notificationService.markAllAsRead(projectPath);
res.json({ success: true, count });
} catch (error) {
logError(error, 'Mark read failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,39 @@
/**
* POST /api/notifications/unread-count - Get unread notification count
*
* Request body: { projectPath: string }
* Response: { success: true, count: number }
*/
import type { Request, Response } from 'express';
import type { NotificationService } from '../../../services/notification-service.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create handler for POST /api/notifications/unread-count
*
* @param notificationService - Instance of NotificationService
* @returns Express request handler
*/
export function createUnreadCountHandler(notificationService: NotificationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body;
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const count = await notificationService.getUnreadCount(projectPath);
res.json({
success: true,
count,
});
} catch (error) {
logError(error, 'Get unread count failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -5,6 +5,7 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js'; import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getBacklogPlanStatus, getRunningDetails } from '../../backlog-plan/common.js'; import { getBacklogPlanStatus, getRunningDetails } from '../../backlog-plan/common.js';
import { getAllRunningGenerations } from '../../app-spec/common.js';
import path from 'path'; import path from 'path';
import { getErrorMessage, logError } from '../common.js'; import { getErrorMessage, logError } from '../common.js';
@@ -26,6 +27,36 @@ export function createIndexHandler(autoModeService: AutoModeService) {
}); });
} }
// Add spec/feature generation tasks
const specGenerations = getAllRunningGenerations();
for (const generation of specGenerations) {
let title: string;
let description: string;
switch (generation.type) {
case 'feature_generation':
title = 'Generating features from spec';
description = 'Creating features from the project specification';
break;
case 'sync':
title = 'Syncing spec with code';
description = 'Updating spec from codebase and completed features';
break;
default:
title = 'Regenerating spec';
description = 'Analyzing project and generating specification';
}
runningAgents.push({
featureId: `spec-generation:${generation.projectPath}`,
projectPath: generation.projectPath,
projectName: path.basename(generation.projectPath),
isAutoMode: false,
title,
description,
});
}
res.json({ res.json({
success: true, success: true,
runningAgents, runningAgents,

View File

@@ -34,6 +34,7 @@ export function createGetDevServerLogsHandler() {
result: { result: {
worktreePath: result.result.worktreePath, worktreePath: result.result.worktreePath,
port: result.result.port, port: result.result.port,
url: result.result.url,
logs: result.result.logs, logs: result.result.logs,
startedAt: result.result.startedAt, startedAt: result.result.startedAt,
}, },

View File

@@ -29,6 +29,10 @@ import {
appendLearning, appendLearning,
recordMemoryUsage, recordMemoryUsage,
createLogger, createLogger,
atomicWriteJson,
readJsonWithRecovery,
logRecoveryWarning,
DEFAULT_BACKUP_COUNT,
} from '@automaker/utils'; } from '@automaker/utils';
const logger = createLogger('AutoMode'); const logger = createLogger('AutoMode');
@@ -60,6 +64,7 @@ import {
getMCPServersFromSettings, getMCPServersFromSettings,
getPromptCustomization, getPromptCustomization,
} from '../lib/settings-helpers.js'; } from '../lib/settings-helpers.js';
import { getNotificationService } from './notification-service.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -386,6 +391,7 @@ export class AutoModeService {
this.emitAutoModeEvent('auto_mode_error', { this.emitAutoModeEvent('auto_mode_error', {
error: errorInfo.message, error: errorInfo.message,
errorType: errorInfo.type, errorType: errorInfo.type,
projectPath,
}); });
}); });
} }
@@ -1414,13 +1420,13 @@ Address the follow-up instructions above. Review the previous work and make the
allImagePaths.push(...allPaths); allImagePaths.push(...allPaths);
} }
// Save updated feature.json with new images // Save updated feature.json with new images (atomic write with backup)
if (copiedImagePaths.length > 0 && feature) { if (copiedImagePaths.length > 0 && feature) {
const featureDirForSave = getFeatureDir(projectPath, featureId); const featureDirForSave = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDirForSave, 'feature.json'); const featurePath = path.join(featureDirForSave, 'feature.json');
try { try {
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2)); await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
} catch (error) { } catch (error) {
logger.error(`Failed to save feature.json:`, error); logger.error(`Failed to save feature.json:`, error);
} }
@@ -1547,6 +1553,7 @@ Address the follow-up instructions above. Review the previous work and make the
message: allPassed message: allPassed
? 'All verification checks passed' ? 'All verification checks passed'
: `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`, : `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`,
projectPath,
}); });
return allPassed; return allPassed;
@@ -1620,6 +1627,7 @@ Address the follow-up instructions above. Review the previous work and make the
featureId, featureId,
passes: true, passes: true,
message: `Changes committed: ${hash.trim().substring(0, 8)}`, message: `Changes committed: ${hash.trim().substring(0, 8)}`,
projectPath,
}); });
return hash.trim(); return hash.trim();
@@ -2088,8 +2096,20 @@ Format your response as a structured markdown document.`;
const featurePath = path.join(featureDir, 'feature.json'); const featurePath = path.join(featureDir, 'feature.json');
try { try {
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; // Use recovery-enabled read for corrupted file handling
const feature = JSON.parse(data); const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
maxBackups: DEFAULT_BACKUP_COUNT,
autoRestore: true,
});
logRecoveryWarning(result, `Feature ${featureId}`, logger);
const feature = result.data;
if (!feature) {
logger.warn(`Feature ${featureId} not found or could not be recovered`);
return;
}
feature.status = status; feature.status = status;
feature.updatedAt = new Date().toISOString(); feature.updatedAt = new Date().toISOString();
// Set justFinishedAt timestamp when moving to waiting_approval (agent just completed) // Set justFinishedAt timestamp when moving to waiting_approval (agent just completed)
@@ -2100,9 +2120,41 @@ Format your response as a structured markdown document.`;
// Clear the timestamp when moving to other statuses // Clear the timestamp when moving to other statuses
feature.justFinishedAt = undefined; feature.justFinishedAt = undefined;
} }
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2));
} catch { // Use atomic write with backup support
// Feature file may not exist await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
// Create notifications for important status changes
const notificationService = getNotificationService();
if (status === 'waiting_approval') {
await notificationService.createNotification({
type: 'feature_waiting_approval',
title: 'Feature Ready for Review',
message: `"${feature.name || featureId}" is ready for your review and approval.`,
featureId,
projectPath,
});
} else if (status === 'verified') {
await notificationService.createNotification({
type: 'feature_verified',
title: 'Feature Verified',
message: `"${feature.name || featureId}" has been verified and is complete.`,
featureId,
projectPath,
});
}
// Sync completed/verified features to app_spec.txt
if (status === 'verified' || status === 'completed') {
try {
await this.featureLoader.syncFeatureToAppSpec(projectPath, feature);
} catch (syncError) {
// Log but don't fail the status update if sync fails
logger.warn(`Failed to sync feature ${featureId} to app_spec.txt:`, syncError);
}
}
} catch (error) {
logger.error(`Failed to update feature status for ${featureId}:`, error);
} }
} }
@@ -2114,11 +2166,24 @@ Format your response as a structured markdown document.`;
featureId: string, featureId: string,
updates: Partial<PlanSpec> updates: Partial<PlanSpec>
): Promise<void> { ): Promise<void> {
const featurePath = path.join(projectPath, '.automaker', 'features', featureId, 'feature.json'); // Use getFeatureDir helper for consistent path resolution
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, 'feature.json');
try { try {
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; // Use recovery-enabled read for corrupted file handling
const feature = JSON.parse(data); const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
maxBackups: DEFAULT_BACKUP_COUNT,
autoRestore: true,
});
logRecoveryWarning(result, `Feature ${featureId}`, logger);
const feature = result.data;
if (!feature) {
logger.warn(`Feature ${featureId} not found or could not be recovered`);
return;
}
// Initialize planSpec if it doesn't exist // Initialize planSpec if it doesn't exist
if (!feature.planSpec) { if (!feature.planSpec) {
@@ -2138,7 +2203,9 @@ Format your response as a structured markdown document.`;
} }
feature.updatedAt = new Date().toISOString(); feature.updatedAt = new Date().toISOString();
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2));
// Use atomic write with backup support
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
} catch (error) { } catch (error) {
logger.error(`Failed to update planSpec for ${featureId}:`, error); logger.error(`Failed to update planSpec for ${featureId}:`, error);
} }
@@ -2155,25 +2222,34 @@ Format your response as a structured markdown document.`;
const allFeatures: Feature[] = []; const allFeatures: Feature[] = [];
const pendingFeatures: Feature[] = []; const pendingFeatures: Feature[] = [];
// Load all features (for dependency checking) // Load all features (for dependency checking) with recovery support
for (const entry of entries) { for (const entry of entries) {
if (entry.isDirectory()) { if (entry.isDirectory()) {
const featurePath = path.join(featuresDir, entry.name, 'feature.json'); const featurePath = path.join(featuresDir, entry.name, 'feature.json');
try {
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string;
const feature = JSON.parse(data);
allFeatures.push(feature);
// Track pending features separately // Use recovery-enabled read for corrupted file handling
if ( const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
feature.status === 'pending' || maxBackups: DEFAULT_BACKUP_COUNT,
feature.status === 'ready' || autoRestore: true,
feature.status === 'backlog' });
) {
pendingFeatures.push(feature); logRecoveryWarning(result, `Feature ${entry.name}`, logger);
}
} catch { const feature = result.data;
// Skip invalid features if (!feature) {
// Skip features that couldn't be loaded or recovered
continue;
}
allFeatures.push(feature);
// Track pending features separately
if (
feature.status === 'pending' ||
feature.status === 'ready' ||
feature.status === 'backlog'
) {
pendingFeatures.push(feature);
} }
} }
} }
@@ -3405,31 +3481,39 @@ After generating the revised spec, output:
for (const entry of entries) { for (const entry of entries) {
if (entry.isDirectory()) { if (entry.isDirectory()) {
const featurePath = path.join(featuresDir, entry.name, 'feature.json'); const featurePath = path.join(featuresDir, entry.name, 'feature.json');
try {
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string;
const feature = JSON.parse(data) as Feature;
// Check if feature was interrupted (in_progress or pipeline_*) // Use recovery-enabled read for corrupted file handling
if ( const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
feature.status === 'in_progress' || maxBackups: DEFAULT_BACKUP_COUNT,
(feature.status && feature.status.startsWith('pipeline_')) autoRestore: true,
) { });
// Verify it has existing context (agent-output.md)
const featureDir = getFeatureDir(projectPath, feature.id); logRecoveryWarning(result, `Feature ${entry.name}`, logger);
const contextPath = path.join(featureDir, 'agent-output.md');
try { const feature = result.data;
await secureFs.access(contextPath); if (!feature) {
interruptedFeatures.push(feature); // Skip features that couldn't be loaded or recovered
logger.info( continue;
`Found interrupted feature: ${feature.id} (${feature.title}) - status: ${feature.status}` }
);
} catch { // Check if feature was interrupted (in_progress or pipeline_*)
// No context file, skip this feature - it will be restarted fresh if (
logger.info(`Interrupted feature ${feature.id} has no context, will restart fresh`); feature.status === 'in_progress' ||
} (feature.status && feature.status.startsWith('pipeline_'))
) {
// Verify it has existing context (agent-output.md)
const featureDir = getFeatureDir(projectPath, feature.id);
const contextPath = path.join(featureDir, 'agent-output.md');
try {
await secureFs.access(contextPath);
interruptedFeatures.push(feature);
logger.info(
`Found interrupted feature: ${feature.id} (${feature.title}) - status: ${feature.status}`
);
} catch {
// No context file, skip this feature - it will be restarted fresh
logger.info(`Interrupted feature ${feature.id} has no context, will restart fresh`);
} }
} catch {
// Skip invalid features
} }
} }
} }

View File

@@ -22,6 +22,29 @@ export class ClaudeUsageService {
private timeout = 30000; // 30 second timeout private timeout = 30000; // 30 second timeout
private isWindows = os.platform() === 'win32'; private isWindows = os.platform() === 'win32';
private isLinux = os.platform() === 'linux'; private isLinux = os.platform() === 'linux';
// On Windows, ConPTY requires AttachConsole which fails in Electron/service mode
// Detect Electron by checking for electron-specific env vars or process properties
// When in Electron, always use winpty to avoid ConPTY's AttachConsole errors
private isElectron =
!!(process.versions && (process.versions as Record<string, string>).electron) ||
!!process.env.ELECTRON_RUN_AS_NODE;
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
/**
* Kill a PTY process with platform-specific handling.
* Windows doesn't support Unix signals like SIGTERM, so we call kill() without arguments.
* On Unix-like systems (macOS, Linux), we can specify the signal.
*
* @param ptyProcess - The PTY process to kill
* @param signal - The signal to send on Unix-like systems (default: 'SIGTERM')
*/
private killPtyProcess(ptyProcess: pty.IPty, signal: string = 'SIGTERM'): void {
if (this.isWindows) {
ptyProcess.kill();
} else {
ptyProcess.kill(signal);
}
}
/** /**
* Check if Claude CLI is available on the system * Check if Claude CLI is available on the system
@@ -181,37 +204,94 @@ export class ClaudeUsageService {
? ['/c', 'claude', '--add-dir', workingDirectory] ? ['/c', 'claude', '--add-dir', workingDirectory]
: ['-c', `claude --add-dir "${workingDirectory}"`]; : ['-c', `claude --add-dir "${workingDirectory}"`];
// Using 'any' for ptyProcess because node-pty types don't include 'killed' property
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let ptyProcess: any = null; let ptyProcess: any = null;
// Build PTY spawn options
const ptyOptions: pty.IPtyForkOptions = {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: workingDirectory,
env: {
...process.env,
TERM: 'xterm-256color',
} as Record<string, string>,
};
// On Windows, always use winpty instead of ConPTY
// ConPTY requires AttachConsole which fails in many contexts:
// - Electron apps without a console
// - VS Code integrated terminal
// - Spawned from other applications
// The error happens in a subprocess so we can't catch it - must proactively disable
if (this.isWindows) {
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
logger.info(
'[executeClaudeUsageCommandPty] Using winpty on Windows (ConPTY disabled for compatibility)'
);
}
try { try {
ptyProcess = pty.spawn(shell, args, { ptyProcess = pty.spawn(shell, args, ptyOptions);
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: workingDirectory,
env: {
...process.env,
TERM: 'xterm-256color',
} as Record<string, string>,
});
} catch (spawnError) { } catch (spawnError) {
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
// Return a user-friendly error instead of crashing // Check for Windows ConPTY-specific errors
reject( if (this.isWindows && errorMessage.includes('AttachConsole failed')) {
new Error( // ConPTY failed - try winpty fallback
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.` if (!this.useConptyFallback) {
) logger.warn(
); '[executeClaudeUsageCommandPty] ConPTY AttachConsole failed, retrying with winpty fallback'
return; );
this.useConptyFallback = true;
try {
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
ptyProcess = pty.spawn(shell, args, ptyOptions);
logger.info(
'[executeClaudeUsageCommandPty] Successfully spawned with winpty fallback'
);
} catch (fallbackError) {
const fallbackMessage =
fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
logger.error(
'[executeClaudeUsageCommandPty] Winpty fallback also failed:',
fallbackMessage
);
reject(
new Error(
`Windows PTY unavailable: Both ConPTY and winpty failed. This typically happens when running in Electron without a console. ConPTY error: ${errorMessage}. Winpty error: ${fallbackMessage}`
)
);
return;
}
} else {
logger.error('[executeClaudeUsageCommandPty] Winpty fallback failed:', errorMessage);
reject(
new Error(
`Windows PTY unavailable: ${errorMessage}. The application is running without console access (common in Electron). Try running from a terminal window.`
)
);
return;
}
} else {
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
reject(
new Error(
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.`
)
);
return;
}
} }
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
if (!settled) { if (!settled) {
settled = true; settled = true;
if (ptyProcess && !ptyProcess.killed) { if (ptyProcess && !ptyProcess.killed) {
ptyProcess.kill(); this.killPtyProcess(ptyProcess);
} }
// Don't fail if we have data - return it instead // Don't fail if we have data - return it instead
if (output.includes('Current session')) { if (output.includes('Current session')) {
@@ -244,16 +324,23 @@ export class ClaudeUsageService {
const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
// Check for specific authentication/permission errors // Check for specific authentication/permission errors
if ( // Must be very specific to avoid false positives from garbled terminal encoding
cleanOutput.includes('OAuth token does not meet scope requirement') || // Removed permission_error check as it was causing false positives with winpty encoding
cleanOutput.includes('permission_error') || const authChecks = {
cleanOutput.includes('token_expired') || oauth: cleanOutput.includes('OAuth token does not meet scope requirement'),
cleanOutput.includes('authentication_error') tokenExpired: cleanOutput.includes('token_expired'),
) { // Only match if it looks like a JSON API error response
authError:
cleanOutput.includes('"type":"authentication_error"') ||
cleanOutput.includes('"type": "authentication_error"'),
};
const hasAuthError = authChecks.oauth || authChecks.tokenExpired || authChecks.authError;
if (hasAuthError) {
if (!settled) { if (!settled) {
settled = true; settled = true;
if (ptyProcess && !ptyProcess.killed) { if (ptyProcess && !ptyProcess.killed) {
ptyProcess.kill(); this.killPtyProcess(ptyProcess);
} }
reject( reject(
new Error( new Error(
@@ -265,11 +352,16 @@ export class ClaudeUsageService {
} }
// Check if we've seen the usage data (look for "Current session" or the TUI Usage header) // Check if we've seen the usage data (look for "Current session" or the TUI Usage header)
if ( // Also check for percentage patterns that appear in usage output
!hasSeenUsageData && const hasUsageIndicators =
(cleanOutput.includes('Current session') || cleanOutput.includes('Current session') ||
(cleanOutput.includes('Usage') && cleanOutput.includes('% left'))) (cleanOutput.includes('Usage') && cleanOutput.includes('% left')) ||
) { // Additional patterns for winpty - look for percentage patterns
/\d+%\s*(left|used|remaining)/i.test(cleanOutput) ||
cleanOutput.includes('Resets in') ||
cleanOutput.includes('Current week');
if (!hasSeenUsageData && hasUsageIndicators) {
hasSeenUsageData = true; hasSeenUsageData = true;
// Wait for full output, then send escape to exit // Wait for full output, then send escape to exit
setTimeout(() => { setTimeout(() => {
@@ -277,9 +369,10 @@ export class ClaudeUsageService {
ptyProcess.write('\x1b'); // Send escape key ptyProcess.write('\x1b'); // Send escape key
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s // Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
// Windows doesn't support signals, so killPtyProcess handles platform differences
setTimeout(() => { setTimeout(() => {
if (!settled && ptyProcess && !ptyProcess.killed) { if (!settled && ptyProcess && !ptyProcess.killed) {
ptyProcess.kill('SIGTERM'); this.killPtyProcess(ptyProcess);
} }
}, 2000); }, 2000);
} }
@@ -307,10 +400,18 @@ export class ClaudeUsageService {
} }
// Detect REPL prompt and send /usage command // Detect REPL prompt and send /usage command
if ( // On Windows with winpty, Unicode prompt char gets garbled, so also check for ASCII indicators
!hasSentCommand && const isReplReady =
(cleanOutput.includes('') || cleanOutput.includes('? for shortcuts')) cleanOutput.includes('') ||
) { cleanOutput.includes('? for shortcuts') ||
// Fallback for winpty garbled encoding - detect CLI welcome screen elements
(cleanOutput.includes('Welcome back') && cleanOutput.includes('Claude')) ||
(cleanOutput.includes('Tips for getting started') && cleanOutput.includes('Claude')) ||
// Detect model indicator which appears when REPL is ready
(cleanOutput.includes('Opus') && cleanOutput.includes('Claude API')) ||
(cleanOutput.includes('Sonnet') && cleanOutput.includes('Claude API'));
if (!hasSentCommand && isReplReady) {
hasSentCommand = true; hasSentCommand = true;
// Wait for REPL to fully settle // Wait for REPL to fully settle
setTimeout(() => { setTimeout(() => {
@@ -347,11 +448,9 @@ export class ClaudeUsageService {
if (settled) return; if (settled) return;
settled = true; settled = true;
if ( // Check for auth errors - must be specific to avoid false positives
output.includes('token_expired') || // Removed permission_error check as it was causing false positives with winpty encoding
output.includes('authentication_error') || if (output.includes('token_expired') || output.includes('"type":"authentication_error"')) {
output.includes('permission_error')
) {
reject(new Error("Authentication required - please run 'claude login'")); reject(new Error("Authentication required - please run 'claude login'"));
return; return;
} }

View File

@@ -379,10 +379,11 @@ class DevServerService {
// Create server info early so we can reference it in handlers // Create server info early so we can reference it in handlers
// We'll add it to runningServers after verifying the process started successfully // We'll add it to runningServers after verifying the process started successfully
const hostname = process.env.HOSTNAME || 'localhost';
const serverInfo: DevServerInfo = { const serverInfo: DevServerInfo = {
worktreePath, worktreePath,
port, port,
url: `http://localhost:${port}`, url: `http://${hostname}:${port}`,
process: devProcess, process: devProcess,
startedAt: new Date(), startedAt: new Date(),
scrollbackBuffer: '', scrollbackBuffer: '',
@@ -474,7 +475,7 @@ class DevServerService {
result: { result: {
worktreePath, worktreePath,
port, port,
url: `http://localhost:${port}`, url: `http://${hostname}:${port}`,
message: `Dev server started on port ${port}`, message: `Dev server started on port ${port}`,
}, },
}; };
@@ -594,6 +595,7 @@ class DevServerService {
result?: { result?: {
worktreePath: string; worktreePath: string;
port: number; port: number;
url: string;
logs: string; logs: string;
startedAt: string; startedAt: string;
}; };
@@ -613,6 +615,7 @@ class DevServerService {
result: { result: {
worktreePath: server.worktreePath, worktreePath: server.worktreePath,
port: server.port, port: server.port,
url: server.url,
logs: server.scrollbackBuffer, logs: server.scrollbackBuffer,
startedAt: server.startedAt.toISOString(), startedAt: server.startedAt.toISOString(),
}, },

View File

@@ -0,0 +1,338 @@
/**
* Event History Service - Stores and retrieves event records for debugging and replay
*
* Provides persistent storage for events in {projectPath}/.automaker/events/
* Each event is stored as a separate JSON file with an index for quick listing.
*
* Features:
* - Store events when they occur
* - List and filter historical events
* - Replay events to test hook configurations
* - Delete old events to manage disk space
*/
import { createLogger } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import {
getEventHistoryDir,
getEventHistoryIndexPath,
getEventPath,
ensureEventHistoryDir,
} from '@automaker/platform';
import type {
StoredEvent,
StoredEventIndex,
StoredEventSummary,
EventHistoryFilter,
EventHookTrigger,
} from '@automaker/types';
import { DEFAULT_EVENT_HISTORY_INDEX } from '@automaker/types';
import { randomUUID } from 'crypto';
const logger = createLogger('EventHistoryService');
/** Maximum events to keep in the index (oldest are pruned) */
const MAX_EVENTS_IN_INDEX = 1000;
/**
* Atomic file write - write to temp file then rename
*/
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const tempPath = `${filePath}.tmp.${Date.now()}`;
const content = JSON.stringify(data, null, 2);
try {
await secureFs.writeFile(tempPath, content, 'utf-8');
await secureFs.rename(tempPath, filePath);
} catch (error) {
try {
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Safely read JSON file with fallback to default
*/
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
return JSON.parse(content) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return defaultValue;
}
logger.error(`Error reading ${filePath}:`, error);
return defaultValue;
}
}
/**
* Input for storing a new event
*/
export interface StoreEventInput {
trigger: EventHookTrigger;
projectPath: string;
featureId?: string;
featureName?: string;
error?: string;
errorType?: string;
passes?: boolean;
metadata?: Record<string, unknown>;
}
/**
* EventHistoryService - Manages persistent storage of events
*/
export class EventHistoryService {
/**
* Store a new event to history
*
* @param input - Event data to store
* @returns Promise resolving to the stored event
*/
async storeEvent(input: StoreEventInput): Promise<StoredEvent> {
const { projectPath, trigger, featureId, featureName, error, errorType, passes, metadata } =
input;
// Ensure events directory exists
await ensureEventHistoryDir(projectPath);
const eventId = `evt-${Date.now()}-${randomUUID().slice(0, 8)}`;
const timestamp = new Date().toISOString();
const projectName = this.extractProjectName(projectPath);
const event: StoredEvent = {
id: eventId,
trigger,
timestamp,
projectPath,
projectName,
featureId,
featureName,
error,
errorType,
passes,
metadata,
};
// Write the full event to its own file
const eventPath = getEventPath(projectPath, eventId);
await atomicWriteJson(eventPath, event);
// Update the index
await this.addToIndex(projectPath, event);
logger.info(`Stored event ${eventId} (${trigger}) for project ${projectName}`);
return event;
}
/**
* Get all events for a project with optional filtering
*
* @param projectPath - Absolute path to project directory
* @param filter - Optional filter criteria
* @returns Promise resolving to array of event summaries
*/
async getEvents(projectPath: string, filter?: EventHistoryFilter): Promise<StoredEventSummary[]> {
const indexPath = getEventHistoryIndexPath(projectPath);
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
let events = [...index.events];
// Apply filters
if (filter) {
if (filter.trigger) {
events = events.filter((e) => e.trigger === filter.trigger);
}
if (filter.featureId) {
events = events.filter((e) => e.featureId === filter.featureId);
}
if (filter.since) {
const sinceDate = new Date(filter.since).getTime();
events = events.filter((e) => new Date(e.timestamp).getTime() >= sinceDate);
}
if (filter.until) {
const untilDate = new Date(filter.until).getTime();
events = events.filter((e) => new Date(e.timestamp).getTime() <= untilDate);
}
}
// Sort by timestamp (newest first)
events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
// Apply pagination
if (filter?.offset) {
events = events.slice(filter.offset);
}
if (filter?.limit) {
events = events.slice(0, filter.limit);
}
return events;
}
/**
* Get a single event by ID
*
* @param projectPath - Absolute path to project directory
* @param eventId - Event identifier
* @returns Promise resolving to the full event or null if not found
*/
async getEvent(projectPath: string, eventId: string): Promise<StoredEvent | null> {
const eventPath = getEventPath(projectPath, eventId);
try {
const content = (await secureFs.readFile(eventPath, 'utf-8')) as string;
return JSON.parse(content) as StoredEvent;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`Error reading event ${eventId}:`, error);
return null;
}
}
/**
* Delete an event by ID
*
* @param projectPath - Absolute path to project directory
* @param eventId - Event identifier
* @returns Promise resolving to true if deleted
*/
async deleteEvent(projectPath: string, eventId: string): Promise<boolean> {
// Remove from index
const indexPath = getEventHistoryIndexPath(projectPath);
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
const initialLength = index.events.length;
index.events = index.events.filter((e) => e.id !== eventId);
if (index.events.length === initialLength) {
return false; // Event not found in index
}
await atomicWriteJson(indexPath, index);
// Delete the event file
const eventPath = getEventPath(projectPath, eventId);
try {
await secureFs.unlink(eventPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error(`Error deleting event file ${eventId}:`, error);
}
}
logger.info(`Deleted event ${eventId}`);
return true;
}
/**
* Clear all events for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to number of events cleared
*/
async clearEvents(projectPath: string): Promise<number> {
const indexPath = getEventHistoryIndexPath(projectPath);
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
const count = index.events.length;
// Delete all event files
for (const event of index.events) {
const eventPath = getEventPath(projectPath, event.id);
try {
await secureFs.unlink(eventPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error(`Error deleting event file ${event.id}:`, error);
}
}
}
// Reset the index
await atomicWriteJson(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
logger.info(`Cleared ${count} events for project`);
return count;
}
/**
* Get event count for a project
*
* @param projectPath - Absolute path to project directory
* @param filter - Optional filter criteria
* @returns Promise resolving to event count
*/
async getEventCount(projectPath: string, filter?: EventHistoryFilter): Promise<number> {
const events = await this.getEvents(projectPath, {
...filter,
limit: undefined,
offset: undefined,
});
return events.length;
}
/**
* Add an event to the index (internal)
*/
private async addToIndex(projectPath: string, event: StoredEvent): Promise<void> {
const indexPath = getEventHistoryIndexPath(projectPath);
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
const summary: StoredEventSummary = {
id: event.id,
trigger: event.trigger,
timestamp: event.timestamp,
featureName: event.featureName,
featureId: event.featureId,
};
// Add to beginning (newest first)
index.events.unshift(summary);
// Prune old events if over limit
if (index.events.length > MAX_EVENTS_IN_INDEX) {
const removed = index.events.splice(MAX_EVENTS_IN_INDEX);
// Delete the pruned event files
for (const oldEvent of removed) {
const eventPath = getEventPath(projectPath, oldEvent.id);
try {
await secureFs.unlink(eventPath);
} catch {
// Ignore deletion errors for pruned events
}
}
logger.info(`Pruned ${removed.length} old events from history`);
}
await atomicWriteJson(indexPath, index);
}
/**
* Extract project name from path
*/
private extractProjectName(projectPath: string): string {
const parts = projectPath.split(/[/\\]/);
return parts[parts.length - 1] || projectPath;
}
}
// Singleton instance
let eventHistoryServiceInstance: EventHistoryService | null = null;
/**
* Get the singleton event history service instance
*/
export function getEventHistoryService(): EventHistoryService {
if (!eventHistoryServiceInstance) {
eventHistoryServiceInstance = new EventHistoryService();
}
return eventHistoryServiceInstance;
}

View File

@@ -5,7 +5,10 @@
* - Shell commands: Executed with configurable timeout * - Shell commands: Executed with configurable timeout
* - HTTP webhooks: POST/GET/PUT/PATCH requests with variable substitution * - HTTP webhooks: POST/GET/PUT/PATCH requests with variable substitution
* *
* Also stores events to history for debugging and replay.
*
* Supported events: * Supported events:
* - feature_created: A new feature was created
* - feature_success: Feature completed successfully * - feature_success: Feature completed successfully
* - feature_error: Feature failed with an error * - feature_error: Feature failed with an error
* - auto_mode_complete: Auto mode finished all features (idle state) * - auto_mode_complete: Auto mode finished all features (idle state)
@@ -17,6 +20,7 @@ import { promisify } from 'util';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js'; import type { EventEmitter } from '../lib/events.js';
import type { SettingsService } from './settings-service.js'; import type { SettingsService } from './settings-service.js';
import type { EventHistoryService } from './event-history-service.js';
import type { import type {
EventHook, EventHook,
EventHookTrigger, EventHookTrigger,
@@ -60,27 +64,45 @@ interface AutoModeEventPayload {
projectPath?: string; projectPath?: string;
} }
/**
* Feature created event payload structure
*/
interface FeatureCreatedPayload {
featureId: string;
featureName?: string;
projectPath: string;
}
/** /**
* Event Hook Service * Event Hook Service
* *
* Manages execution of user-configured event hooks in response to system events. * Manages execution of user-configured event hooks in response to system events.
* Also stores events to history for debugging and replay.
*/ */
export class EventHookService { export class EventHookService {
private emitter: EventEmitter | null = null; private emitter: EventEmitter | null = null;
private settingsService: SettingsService | null = null; private settingsService: SettingsService | null = null;
private eventHistoryService: EventHistoryService | null = null;
private unsubscribe: (() => void) | null = null; private unsubscribe: (() => void) | null = null;
/** /**
* Initialize the service with event emitter and settings service * Initialize the service with event emitter, settings service, and event history service
*/ */
initialize(emitter: EventEmitter, settingsService: SettingsService): void { initialize(
emitter: EventEmitter,
settingsService: SettingsService,
eventHistoryService?: EventHistoryService
): void {
this.emitter = emitter; this.emitter = emitter;
this.settingsService = settingsService; this.settingsService = settingsService;
this.eventHistoryService = eventHistoryService || null;
// Subscribe to auto-mode events // Subscribe to events
this.unsubscribe = emitter.subscribe((type, payload) => { this.unsubscribe = emitter.subscribe((type, payload) => {
if (type === 'auto-mode:event') { if (type === 'auto-mode:event') {
this.handleAutoModeEvent(payload as AutoModeEventPayload); this.handleAutoModeEvent(payload as AutoModeEventPayload);
} else if (type === 'feature:created') {
this.handleFeatureCreatedEvent(payload as FeatureCreatedPayload);
} }
}); });
@@ -97,6 +119,7 @@ export class EventHookService {
} }
this.emitter = null; this.emitter = null;
this.settingsService = null; this.settingsService = null;
this.eventHistoryService = null;
} }
/** /**
@@ -137,17 +160,51 @@ export class EventHookService {
eventType: trigger, eventType: trigger,
}; };
// Execute matching hooks // Execute matching hooks (pass passes for feature completion events)
await this.executeHooksForTrigger(trigger, context); await this.executeHooksForTrigger(trigger, context, { passes: payload.passes });
} }
/** /**
* Execute all enabled hooks matching the given trigger * Handle feature:created events and trigger matching hooks
*/
private async handleFeatureCreatedEvent(payload: FeatureCreatedPayload): Promise<void> {
const context: HookContext = {
featureId: payload.featureId,
featureName: payload.featureName,
projectPath: payload.projectPath,
projectName: this.extractProjectName(payload.projectPath),
timestamp: new Date().toISOString(),
eventType: 'feature_created',
};
await this.executeHooksForTrigger('feature_created', context);
}
/**
* Execute all enabled hooks matching the given trigger and store event to history
*/ */
private async executeHooksForTrigger( private async executeHooksForTrigger(
trigger: EventHookTrigger, trigger: EventHookTrigger,
context: HookContext context: HookContext,
additionalData?: { passes?: boolean }
): Promise<void> { ): Promise<void> {
// Store event to history (even if no hooks match)
if (this.eventHistoryService && context.projectPath) {
try {
await this.eventHistoryService.storeEvent({
trigger,
projectPath: context.projectPath,
featureId: context.featureId,
featureName: context.featureName,
error: context.error,
errorType: context.errorType,
passes: additionalData?.passes,
});
} catch (error) {
logger.error('Failed to store event to history:', error);
}
}
if (!this.settingsService) { if (!this.settingsService) {
logger.warn('Settings service not available'); logger.warn('Settings service not available');
return; return;

View File

@@ -5,14 +5,22 @@
import path from 'path'; import path from 'path';
import type { Feature, DescriptionHistoryEntry } from '@automaker/types'; import type { Feature, DescriptionHistoryEntry } from '@automaker/types';
import { createLogger } from '@automaker/utils'; import {
createLogger,
atomicWriteJson,
readJsonWithRecovery,
logRecoveryWarning,
DEFAULT_BACKUP_COUNT,
} from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js'; import * as secureFs from '../lib/secure-fs.js';
import { import {
getFeaturesDir, getFeaturesDir,
getFeatureDir, getFeatureDir,
getFeatureImagesDir, getFeatureImagesDir,
getAppSpecPath,
ensureAutomakerDir, ensureAutomakerDir,
} from '@automaker/platform'; } from '@automaker/platform';
import { addImplementedFeature, type ImplementedFeature } from '../lib/xml-extractor.js';
const logger = createLogger('FeatureLoader'); const logger = createLogger('FeatureLoader');
@@ -192,31 +200,31 @@ export class FeatureLoader {
})) as any[]; })) as any[];
const featureDirs = entries.filter((entry) => entry.isDirectory()); const featureDirs = entries.filter((entry) => entry.isDirectory());
// Load all features concurrently (secureFs has built-in concurrency limiting) // Load all features concurrently with automatic recovery from backups
const featurePromises = featureDirs.map(async (dir) => { const featurePromises = featureDirs.map(async (dir) => {
const featureId = dir.name; const featureId = dir.name;
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
try { // Use recovery-enabled read to handle corrupted files
const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string; const result = await readJsonWithRecovery<Feature | null>(featureJsonPath, null, {
const feature = JSON.parse(content); maxBackups: DEFAULT_BACKUP_COUNT,
autoRestore: true,
});
if (!feature.id) { logRecoveryWarning(result, `Feature ${featureId}`, logger);
logger.warn(`Feature ${featureId} missing required 'id' field, skipping`);
return null;
}
return feature as Feature; const feature = result.data;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') { if (!feature) {
return null;
} else if (error instanceof SyntaxError) {
logger.warn(`Failed to parse feature.json for ${featureId}: ${error.message}`);
} else {
logger.error(`Failed to load feature ${featureId}:`, (error as Error).message);
}
return null; return null;
} }
if (!feature.id) {
logger.warn(`Feature ${featureId} missing required 'id' field, skipping`);
return null;
}
return feature;
}); });
const results = await Promise.all(featurePromises); const results = await Promise.all(featurePromises);
@@ -236,21 +244,85 @@ export class FeatureLoader {
} }
} }
/**
* Normalize a title for comparison (case-insensitive, trimmed)
*/
private normalizeTitle(title: string): string {
return title.toLowerCase().trim();
}
/**
* Find a feature by its title (case-insensitive match)
* @param projectPath - Path to the project
* @param title - Title to search for
* @returns The matching feature or null if not found
*/
async findByTitle(projectPath: string, title: string): Promise<Feature | null> {
if (!title || !title.trim()) {
return null;
}
const normalizedTitle = this.normalizeTitle(title);
const features = await this.getAll(projectPath);
for (const feature of features) {
if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) {
return feature;
}
}
return null;
}
/**
* Check if a title already exists on another feature (for duplicate detection)
* @param projectPath - Path to the project
* @param title - Title to check
* @param excludeFeatureId - Optional feature ID to exclude from the check (for updates)
* @returns The duplicate feature if found, null otherwise
*/
async findDuplicateTitle(
projectPath: string,
title: string,
excludeFeatureId?: string
): Promise<Feature | null> {
if (!title || !title.trim()) {
return null;
}
const normalizedTitle = this.normalizeTitle(title);
const features = await this.getAll(projectPath);
for (const feature of features) {
// Skip the feature being updated (if provided)
if (excludeFeatureId && feature.id === excludeFeatureId) {
continue;
}
if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) {
return feature;
}
}
return null;
}
/** /**
* Get a single feature by ID * Get a single feature by ID
* Uses automatic recovery from backups if the main file is corrupted
*/ */
async get(projectPath: string, featureId: string): Promise<Feature | null> { async get(projectPath: string, featureId: string): Promise<Feature | null> {
try { const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string; // Use recovery-enabled read to handle corrupted files
return JSON.parse(content); const result = await readJsonWithRecovery<Feature | null>(featureJsonPath, null, {
} catch (error) { maxBackups: DEFAULT_BACKUP_COUNT,
if ((error as NodeJS.ErrnoException).code === 'ENOENT') { autoRestore: true,
return null; });
}
logger.error(`Failed to get feature ${featureId}:`, error); logRecoveryWarning(result, `Feature ${featureId}`, logger);
throw error;
} return result.data;
} }
/** /**
@@ -294,8 +366,8 @@ export class FeatureLoader {
descriptionHistory: initialHistory, descriptionHistory: initialHistory,
}; };
// Write feature.json // Write feature.json atomically with backup support
await secureFs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2), 'utf-8'); await atomicWriteJson(featureJsonPath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
logger.info(`Created feature ${featureId}`); logger.info(`Created feature ${featureId}`);
return feature; return feature;
@@ -379,9 +451,9 @@ export class FeatureLoader {
descriptionHistory: updatedHistory, descriptionHistory: updatedHistory,
}; };
// Write back to file // Write back to file atomically with backup support
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
await secureFs.writeFile(featureJsonPath, JSON.stringify(updatedFeature, null, 2), 'utf-8'); await atomicWriteJson(featureJsonPath, updatedFeature, { backupCount: DEFAULT_BACKUP_COUNT });
logger.info(`Updated feature ${featureId}`); logger.info(`Updated feature ${featureId}`);
return updatedFeature; return updatedFeature;
@@ -460,4 +532,64 @@ export class FeatureLoader {
} }
} }
} }
/**
* Sync a completed feature to the app_spec.txt implemented_features section
*
* When a feature is completed, this method adds it to the implemented_features
* section of the project's app_spec.txt file. This keeps the spec in sync
* with the actual state of the codebase.
*
* @param projectPath - Path to the project
* @param feature - The feature to sync (must have title or description)
* @param fileLocations - Optional array of file paths where the feature was implemented
* @returns True if the spec was updated, false if no spec exists or feature was skipped
*/
async syncFeatureToAppSpec(
projectPath: string,
feature: Feature,
fileLocations?: string[]
): Promise<boolean> {
try {
const appSpecPath = getAppSpecPath(projectPath);
// Read the current app_spec.txt
let specContent: string;
try {
specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info(`No app_spec.txt found for project, skipping sync for feature ${feature.id}`);
return false;
}
throw error;
}
// Build the implemented feature entry
const featureName = feature.title || `Feature: ${feature.id}`;
const implementedFeature: ImplementedFeature = {
name: featureName,
description: feature.description,
...(fileLocations && fileLocations.length > 0 ? { file_locations: fileLocations } : {}),
};
// Add the feature to the implemented_features section
const updatedSpecContent = addImplementedFeature(specContent, implementedFeature);
// Check if the content actually changed (feature might already exist)
if (updatedSpecContent === specContent) {
logger.info(`Feature "${featureName}" already exists in app_spec.txt, skipping`);
return false;
}
// Write the updated spec back to the file
await secureFs.writeFile(appSpecPath, updatedSpecContent, 'utf-8');
logger.info(`Synced feature "${featureName}" to app_spec.txt`);
return true;
} catch (error) {
logger.error(`Failed to sync feature ${feature.id} to app_spec.txt:`, error);
throw error;
}
}
} }

View File

@@ -0,0 +1,280 @@
/**
* Notification Service - Handles reading/writing notifications to JSON files
*
* Provides persistent storage for project-level notifications in
* {projectPath}/.automaker/notifications.json
*
* Notifications alert users when:
* - Features reach specific statuses (waiting_approval, verified)
* - Long-running operations complete (spec generation)
*/
import { createLogger } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import { getNotificationsPath, ensureAutomakerDir } from '@automaker/platform';
import type { Notification, NotificationsFile, NotificationType } from '@automaker/types';
import { DEFAULT_NOTIFICATIONS_FILE } from '@automaker/types';
import type { EventEmitter } from '../lib/events.js';
import { randomUUID } from 'crypto';
const logger = createLogger('NotificationService');
/**
* Atomic file write - write to temp file then rename
*/
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const tempPath = `${filePath}.tmp.${Date.now()}`;
const content = JSON.stringify(data, null, 2);
try {
await secureFs.writeFile(tempPath, content, 'utf-8');
await secureFs.rename(tempPath, filePath);
} catch (error) {
// Clean up temp file if it exists
try {
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Safely read JSON file with fallback to default
*/
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
return JSON.parse(content) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return defaultValue;
}
logger.error(`Error reading ${filePath}:`, error);
return defaultValue;
}
}
/**
* Input for creating a new notification
*/
export interface CreateNotificationInput {
type: NotificationType;
title: string;
message: string;
featureId?: string;
projectPath: string;
}
/**
* NotificationService - Manages persistent storage of notifications
*
* Handles reading and writing notifications to JSON files with atomic operations
* for reliability. Each project has its own notifications.json file.
*/
export class NotificationService {
private events: EventEmitter | null = null;
/**
* Set the event emitter for broadcasting notification events
*/
setEventEmitter(events: EventEmitter): void {
this.events = events;
}
/**
* Get all notifications for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to array of notifications
*/
async getNotifications(projectPath: string): Promise<Notification[]> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
// Filter out dismissed notifications and sort by date (newest first)
return file.notifications
.filter((n) => !n.dismissed)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
/**
* Get unread notification count for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to unread count
*/
async getUnreadCount(projectPath: string): Promise<number> {
const notifications = await this.getNotifications(projectPath);
return notifications.filter((n) => !n.read).length;
}
/**
* Create a new notification
*
* @param input - Notification creation input
* @returns Promise resolving to the created notification
*/
async createNotification(input: CreateNotificationInput): Promise<Notification> {
const { projectPath, type, title, message, featureId } = input;
// Ensure automaker directory exists
await ensureAutomakerDir(projectPath);
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
const notification: Notification = {
id: randomUUID(),
type,
title,
message,
createdAt: new Date().toISOString(),
read: false,
dismissed: false,
featureId,
projectPath,
};
file.notifications.push(notification);
await atomicWriteJson(notificationsPath, file);
logger.info(`Created notification: ${title} for project ${projectPath}`);
// Emit event for real-time updates
if (this.events) {
this.events.emit('notification:created', notification);
}
return notification;
}
/**
* Mark a notification as read
*
* @param projectPath - Absolute path to project directory
* @param notificationId - ID of the notification to mark as read
* @returns Promise resolving to the updated notification or null if not found
*/
async markAsRead(projectPath: string, notificationId: string): Promise<Notification | null> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
const notification = file.notifications.find((n) => n.id === notificationId);
if (!notification) {
return null;
}
notification.read = true;
await atomicWriteJson(notificationsPath, file);
logger.info(`Marked notification ${notificationId} as read`);
return notification;
}
/**
* Mark all notifications as read for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to number of notifications marked as read
*/
async markAllAsRead(projectPath: string): Promise<number> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
let count = 0;
for (const notification of file.notifications) {
if (!notification.read && !notification.dismissed) {
notification.read = true;
count++;
}
}
if (count > 0) {
await atomicWriteJson(notificationsPath, file);
logger.info(`Marked ${count} notifications as read`);
}
return count;
}
/**
* Dismiss a notification
*
* @param projectPath - Absolute path to project directory
* @param notificationId - ID of the notification to dismiss
* @returns Promise resolving to true if notification was dismissed
*/
async dismissNotification(projectPath: string, notificationId: string): Promise<boolean> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
const notification = file.notifications.find((n) => n.id === notificationId);
if (!notification) {
return false;
}
notification.dismissed = true;
await atomicWriteJson(notificationsPath, file);
logger.info(`Dismissed notification ${notificationId}`);
return true;
}
/**
* Dismiss all notifications for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to number of notifications dismissed
*/
async dismissAll(projectPath: string): Promise<number> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
let count = 0;
for (const notification of file.notifications) {
if (!notification.dismissed) {
notification.dismissed = true;
count++;
}
}
if (count > 0) {
await atomicWriteJson(notificationsPath, file);
logger.info(`Dismissed ${count} notifications`);
}
return count;
}
}
// Singleton instance
let notificationServiceInstance: NotificationService | null = null;
/**
* Get the singleton notification service instance
*/
export function getNotificationService(): NotificationService {
if (!notificationServiceInstance) {
notificationServiceInstance = new NotificationService();
}
return notificationServiceInstance;
}

View File

@@ -7,7 +7,7 @@
* - Per-project settings ({projectPath}/.automaker/settings.json) * - Per-project settings ({projectPath}/.automaker/settings.json)
*/ */
import { createLogger } from '@automaker/utils'; import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js'; import * as secureFs from '../lib/secure-fs.js';
import { import {
@@ -42,28 +42,8 @@ import {
const logger = createLogger('SettingsService'); const logger = createLogger('SettingsService');
/** /**
* Atomic file write - write to temp file then rename * Wrapper for readJsonFile from utils that uses the local secureFs
*/ * to maintain compatibility with the server's secure file system
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const tempPath = `${filePath}.tmp.${Date.now()}`;
const content = JSON.stringify(data, null, 2);
try {
await secureFs.writeFile(tempPath, content, 'utf-8');
await secureFs.rename(tempPath, filePath);
} catch (error) {
// Clean up temp file if it exists
try {
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Safely read JSON file with fallback to default
*/ */
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> { async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try { try {
@@ -90,6 +70,13 @@ async function fileExists(filePath: string): Promise<boolean> {
} }
} }
/**
* Write settings atomically with backup support
*/
async function writeSettingsJson(filePath: string, data: unknown): Promise<void> {
await atomicWriteJson(filePath, data, { backupCount: DEFAULT_BACKUP_COUNT });
}
/** /**
* SettingsService - Manages persistent storage of user settings and credentials * SettingsService - Manages persistent storage of user settings and credentials
* *
@@ -180,7 +167,7 @@ export class SettingsService {
if (needsSave) { if (needsSave) {
try { try {
await ensureDataDir(this.dataDir); await ensureDataDir(this.dataDir);
await atomicWriteJson(settingsPath, result); await writeSettingsJson(settingsPath, result);
logger.info('Settings migration complete'); logger.info('Settings migration complete');
} catch (error) { } catch (error) {
logger.error('Failed to save migrated settings:', error); logger.error('Failed to save migrated settings:', error);
@@ -340,7 +327,7 @@ export class SettingsService {
}; };
} }
await atomicWriteJson(settingsPath, updated); await writeSettingsJson(settingsPath, updated);
logger.info('Global settings updated'); logger.info('Global settings updated');
return updated; return updated;
@@ -414,7 +401,7 @@ export class SettingsService {
}; };
} }
await atomicWriteJson(credentialsPath, updated); await writeSettingsJson(credentialsPath, updated);
logger.info('Credentials updated'); logger.info('Credentials updated');
return updated; return updated;
@@ -525,7 +512,7 @@ export class SettingsService {
}; };
} }
await atomicWriteJson(settingsPath, updated); await writeSettingsJson(settingsPath, updated);
logger.info(`Project settings updated for ${projectPath}`); logger.info(`Project settings updated for ${projectPath}`);
return updated; return updated;

View File

@@ -70,6 +70,29 @@ export class TerminalService extends EventEmitter {
private sessions: Map<string, TerminalSession> = new Map(); private sessions: Map<string, TerminalSession> = new Map();
private dataCallbacks: Set<DataCallback> = new Set(); private dataCallbacks: Set<DataCallback> = new Set();
private exitCallbacks: Set<ExitCallback> = new Set(); private exitCallbacks: Set<ExitCallback> = new Set();
private isWindows = os.platform() === 'win32';
// On Windows, ConPTY requires AttachConsole which fails in Electron/service mode
// Detect Electron by checking for electron-specific env vars or process properties
private isElectron =
!!(process.versions && (process.versions as Record<string, string>).electron) ||
!!process.env.ELECTRON_RUN_AS_NODE;
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
/**
* Kill a PTY process with platform-specific handling.
* Windows doesn't support Unix signals like SIGTERM/SIGKILL, so we call kill() without arguments.
* On Unix-like systems (macOS, Linux), we can specify the signal.
*
* @param ptyProcess - The PTY process to kill
* @param signal - The signal to send on Unix-like systems (default: 'SIGTERM')
*/
private killPtyProcess(ptyProcess: pty.IPty, signal: string = 'SIGTERM'): void {
if (this.isWindows) {
ptyProcess.kill();
} else {
ptyProcess.kill(signal);
}
}
/** /**
* Detect the best shell for the current platform * Detect the best shell for the current platform
@@ -322,13 +345,60 @@ export class TerminalService extends EventEmitter {
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`); logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
const ptyProcess = pty.spawn(shell, shellArgs, { // Build PTY spawn options
const ptyOptions: pty.IPtyForkOptions = {
name: 'xterm-256color', name: 'xterm-256color',
cols: options.cols || 80, cols: options.cols || 80,
rows: options.rows || 24, rows: options.rows || 24,
cwd, cwd,
env, env,
}); };
// On Windows, always use winpty instead of ConPTY
// ConPTY requires AttachConsole which fails in many contexts:
// - Electron apps without a console
// - VS Code integrated terminal
// - Spawned from other applications
// The error happens in a subprocess so we can't catch it - must proactively disable
if (this.isWindows) {
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
logger.info(
`[createSession] Using winpty for session ${id} (ConPTY disabled for compatibility)`
);
}
let ptyProcess: pty.IPty;
try {
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
} catch (spawnError) {
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
// Check for Windows ConPTY-specific errors
if (this.isWindows && errorMessage.includes('AttachConsole failed')) {
// ConPTY failed - try winpty fallback
if (!this.useConptyFallback) {
logger.warn(`[createSession] ConPTY AttachConsole failed, retrying with winpty fallback`);
this.useConptyFallback = true;
try {
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
logger.info(`[createSession] Successfully spawned session ${id} with winpty fallback`);
} catch (fallbackError) {
const fallbackMessage =
fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
logger.error(`[createSession] Winpty fallback also failed:`, fallbackMessage);
return null;
}
} else {
logger.error(`[createSession] PTY spawn failed (winpty):`, errorMessage);
return null;
}
} else {
logger.error(`[createSession] PTY spawn failed:`, errorMessage);
return null;
}
}
const session: TerminalSession = { const session: TerminalSession = {
id, id,
@@ -392,7 +462,11 @@ export class TerminalService extends EventEmitter {
// Handle exit // Handle exit
ptyProcess.onExit(({ exitCode }) => { ptyProcess.onExit(({ exitCode }) => {
logger.info(`Session ${id} exited with code ${exitCode}`); const exitMessage =
exitCode === undefined || exitCode === null
? 'Session terminated'
: `Session exited with code ${exitCode}`;
logger.info(`${exitMessage} (${id})`);
this.sessions.delete(id); this.sessions.delete(id);
this.exitCallbacks.forEach((cb) => cb(id, exitCode)); this.exitCallbacks.forEach((cb) => cb(id, exitCode));
this.emit('exit', id, exitCode); this.emit('exit', id, exitCode);
@@ -477,8 +551,9 @@ export class TerminalService extends EventEmitter {
} }
// First try graceful SIGTERM to allow process cleanup // First try graceful SIGTERM to allow process cleanup
// On Windows, killPtyProcess calls kill() without signal since Windows doesn't support Unix signals
logger.info(`Session ${sessionId} sending SIGTERM`); logger.info(`Session ${sessionId} sending SIGTERM`);
session.pty.kill('SIGTERM'); this.killPtyProcess(session.pty, 'SIGTERM');
// Schedule SIGKILL fallback if process doesn't exit gracefully // Schedule SIGKILL fallback if process doesn't exit gracefully
// The onExit handler will remove session from map when it actually exits // The onExit handler will remove session from map when it actually exits
@@ -486,7 +561,7 @@ export class TerminalService extends EventEmitter {
if (this.sessions.has(sessionId)) { if (this.sessions.has(sessionId)) {
logger.info(`Session ${sessionId} still alive after SIGTERM, sending SIGKILL`); logger.info(`Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
try { try {
session.pty.kill('SIGKILL'); this.killPtyProcess(session.pty, 'SIGKILL');
} catch { } catch {
// Process may have already exited // Process may have already exited
} }
@@ -588,7 +663,8 @@ export class TerminalService extends EventEmitter {
if (session.flushTimeout) { if (session.flushTimeout) {
clearTimeout(session.flushTimeout); clearTimeout(session.flushTimeout);
} }
session.pty.kill(); // Use platform-specific kill to ensure proper termination on Windows
this.killPtyProcess(session.pty);
} catch { } catch {
// Ignore errors during cleanup // Ignore errors during cleanup
} }

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,11 @@ import {
getCodexConfigDir, getCodexConfigDir,
getCodexAuthIndicators, getCodexAuthIndicators,
} from '@automaker/platform'; } from '@automaker/platform';
import {
calculateReasoningTimeout,
REASONING_TIMEOUT_MULTIPLIERS,
DEFAULT_TIMEOUT_MS,
} from '@automaker/types';
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
const originalOpenAIKey = process.env[OPENAI_API_KEY_ENV]; const originalOpenAIKey = process.env[OPENAI_API_KEY_ENV];
@@ -289,5 +294,121 @@ describe('codex-provider.ts', () => {
expect(codexRunMock).not.toHaveBeenCalled(); expect(codexRunMock).not.toHaveBeenCalled();
expect(spawnJSONLProcess).toHaveBeenCalled(); expect(spawnJSONLProcess).toHaveBeenCalled();
}); });
it('passes extended timeout for high reasoning effort', async () => {
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Complex reasoning task',
model: 'gpt-5.1-codex-max',
cwd: '/tmp',
reasoningEffort: 'high',
})
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
// High reasoning effort should have 3x the default timeout (90000ms)
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high);
});
it('passes extended timeout for xhigh reasoning effort', async () => {
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Very complex reasoning task',
model: 'gpt-5.1-codex-max',
cwd: '/tmp',
reasoningEffort: 'xhigh',
})
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
// xhigh reasoning effort should have 4x the default timeout (120000ms)
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh);
});
it('uses default timeout when no reasoning effort is specified', async () => {
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Simple task',
model: 'gpt-5.2',
cwd: '/tmp',
})
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
// No reasoning effort should use the default timeout
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS);
});
});
describe('calculateReasoningTimeout', () => {
it('returns default timeout when no reasoning effort is specified', () => {
expect(calculateReasoningTimeout()).toBe(DEFAULT_TIMEOUT_MS);
expect(calculateReasoningTimeout(undefined)).toBe(DEFAULT_TIMEOUT_MS);
});
it('returns default timeout for none reasoning effort', () => {
expect(calculateReasoningTimeout('none')).toBe(DEFAULT_TIMEOUT_MS);
});
it('applies correct multiplier for minimal reasoning effort', () => {
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.minimal);
expect(calculateReasoningTimeout('minimal')).toBe(expected);
});
it('applies correct multiplier for low reasoning effort', () => {
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.low);
expect(calculateReasoningTimeout('low')).toBe(expected);
});
it('applies correct multiplier for medium reasoning effort', () => {
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.medium);
expect(calculateReasoningTimeout('medium')).toBe(expected);
});
it('applies correct multiplier for high reasoning effort', () => {
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high);
expect(calculateReasoningTimeout('high')).toBe(expected);
});
it('applies correct multiplier for xhigh reasoning effort', () => {
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh);
expect(calculateReasoningTimeout('xhigh')).toBe(expected);
});
it('uses custom base timeout when provided', () => {
const customBase = 60000;
expect(calculateReasoningTimeout('high', customBase)).toBe(
Math.round(customBase * REASONING_TIMEOUT_MULTIPLIERS.high)
);
});
it('falls back to 1.0 multiplier for invalid reasoning effort', () => {
// Test that invalid values fallback gracefully to default multiplier
// This tests the defensive ?? 1.0 in calculateReasoningTimeout
const invalidEffort = 'invalid_effort' as never;
expect(calculateReasoningTimeout(invalidEffort)).toBe(DEFAULT_TIMEOUT_MS);
});
it('produces expected absolute timeout values', () => {
// Verify the actual timeout values that will be used:
// none: 30000ms (30s)
// minimal: 36000ms (36s)
// low: 45000ms (45s)
// medium: 60000ms (1m)
// high: 90000ms (1m 30s)
// xhigh: 120000ms (2m)
expect(calculateReasoningTimeout('none')).toBe(30000);
expect(calculateReasoningTimeout('minimal')).toBe(36000);
expect(calculateReasoningTimeout('low')).toBe(45000);
expect(calculateReasoningTimeout('medium')).toBe(60000);
expect(calculateReasoningTimeout('high')).toBe(90000);
expect(calculateReasoningTimeout('xhigh')).toBe(120000);
});
}); });
}); });

View File

@@ -518,7 +518,11 @@ Resets in 2h
const promise = ptyService.fetchUsageData(); const promise = ptyService.fetchUsageData();
dataCallback!('authentication_error'); // Send data containing the authentication error pattern the service looks for
dataCallback!('"type":"authentication_error"');
// Trigger the exit handler which checks for auth errors
exitCallback!({ exitCode: 1 });
await expect(promise).rejects.toThrow( await expect(promise).rejects.toThrow(
"Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions." "Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions."
@@ -586,6 +590,8 @@ Resets in 2h
it('should send SIGTERM after ESC if process does not exit', async () => { it('should send SIGTERM after ESC if process does not exit', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
// Mock Unix platform to test SIGTERM behavior (Windows calls kill() without signal)
vi.mocked(os.platform).mockReturnValue('darwin');
const ptyService = new ClaudeUsageService(); const ptyService = new ClaudeUsageService();
let dataCallback: Function | undefined; let dataCallback: Function | undefined;

View File

@@ -190,9 +190,10 @@ describe('feature-loader.ts', () => {
const result = await loader.getAll(testProjectPath); const result = await loader.getAll(testProjectPath);
expect(result).toEqual([]); expect(result).toEqual([]);
// With recovery-enabled reads, warnings come from AtomicWriter and FeatureLoader
expect(consoleSpy).toHaveBeenCalledWith( expect(consoleSpy).toHaveBeenCalledWith(
expect.stringMatching(/WARN.*\[FeatureLoader\]/), expect.stringMatching(/WARN.*\[AtomicWriter\]/),
expect.stringContaining('Failed to parse feature.json') expect.stringContaining('unavailable')
); );
consoleSpy.mockRestore(); consoleSpy.mockRestore();
@@ -260,10 +261,13 @@ describe('feature-loader.ts', () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it('should throw on other errors', async () => { it('should return null on other errors (with recovery attempt)', async () => {
// With recovery-enabled reads, get() returns null instead of throwing
// because it attempts to recover from backups before giving up
vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied')); vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied'));
await expect(loader.get(testProjectPath, 'feature-123')).rejects.toThrow('Permission denied'); const result = await loader.get(testProjectPath, 'feature-123');
expect(result).toBeNull();
}); });
}); });
@@ -442,4 +446,471 @@ describe('feature-loader.ts', () => {
); );
}); });
}); });
describe('findByTitle', () => {
it('should find feature by exact title match (case-insensitive)', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
{ name: 'feature-2', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
title: 'Login Feature',
category: 'auth',
description: 'Login implementation',
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-2000-def',
title: 'Logout Feature',
category: 'auth',
description: 'Logout implementation',
})
);
const result = await loader.findByTitle(testProjectPath, 'LOGIN FEATURE');
expect(result).not.toBeNull();
expect(result?.id).toBe('feature-1000-abc');
expect(result?.title).toBe('Login Feature');
});
it('should return null when title is not found', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile).mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
title: 'Login Feature',
category: 'auth',
description: 'Login implementation',
})
);
const result = await loader.findByTitle(testProjectPath, 'Nonexistent Feature');
expect(result).toBeNull();
});
it('should return null for empty or whitespace title', async () => {
const result1 = await loader.findByTitle(testProjectPath, '');
const result2 = await loader.findByTitle(testProjectPath, ' ');
expect(result1).toBeNull();
expect(result2).toBeNull();
});
it('should skip features without titles', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
{ name: 'feature-2', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
// no title
category: 'auth',
description: 'Login implementation',
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-2000-def',
title: 'Login Feature',
category: 'auth',
description: 'Another login',
})
);
const result = await loader.findByTitle(testProjectPath, 'Login Feature');
expect(result).not.toBeNull();
expect(result?.id).toBe('feature-2000-def');
});
});
describe('findDuplicateTitle', () => {
it('should find duplicate title', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile).mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
title: 'My Feature',
category: 'ui',
description: 'Feature description',
})
);
const result = await loader.findDuplicateTitle(testProjectPath, 'my feature');
expect(result).not.toBeNull();
expect(result?.id).toBe('feature-1000-abc');
});
it('should exclude specified feature ID from duplicate check', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
{ name: 'feature-2', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
title: 'My Feature',
category: 'ui',
description: 'Feature 1',
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-2000-def',
title: 'Other Feature',
category: 'ui',
description: 'Feature 2',
})
);
// Should not find duplicate when excluding the feature that has the title
const result = await loader.findDuplicateTitle(
testProjectPath,
'My Feature',
'feature-1000-abc'
);
expect(result).toBeNull();
});
it('should find duplicate when title exists on different feature', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
{ name: 'feature-2', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
title: 'My Feature',
category: 'ui',
description: 'Feature 1',
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-2000-def',
title: 'Other Feature',
category: 'ui',
description: 'Feature 2',
})
);
// Should find duplicate because feature-1000-abc has the title and we're excluding feature-2000-def
const result = await loader.findDuplicateTitle(
testProjectPath,
'My Feature',
'feature-2000-def'
);
expect(result).not.toBeNull();
expect(result?.id).toBe('feature-1000-abc');
});
it('should return null for empty or whitespace title', async () => {
const result1 = await loader.findDuplicateTitle(testProjectPath, '');
const result2 = await loader.findDuplicateTitle(testProjectPath, ' ');
expect(result1).toBeNull();
expect(result2).toBeNull();
});
it('should handle titles with leading/trailing whitespace', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile).mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
title: 'My Feature',
category: 'ui',
description: 'Feature description',
})
);
const result = await loader.findDuplicateTitle(testProjectPath, ' My Feature ');
expect(result).not.toBeNull();
expect(result?.id).toBe('feature-1000-abc');
});
});
describe('syncFeatureToAppSpec', () => {
const sampleAppSpec = `<?xml version="1.0" encoding="UTF-8"?>
<project_specification>
<project_name>Test Project</project_name>
<core_capabilities>
<capability>Testing</capability>
</core_capabilities>
<implemented_features>
<feature>
<name>Existing Feature</name>
<description>Already implemented</description>
</feature>
</implemented_features>
</project_specification>`;
const appSpecWithoutFeatures = `<?xml version="1.0" encoding="UTF-8"?>
<project_specification>
<project_name>Test Project</project_name>
<core_capabilities>
<capability>Testing</capability>
</core_capabilities>
</project_specification>`;
it('should add feature to app_spec.txt', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
title: 'New Feature',
category: 'ui',
description: 'A new feature description',
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
expect(result).toBe(true);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining('app_spec.txt'),
expect.stringContaining('New Feature'),
'utf-8'
);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('A new feature description'),
'utf-8'
);
});
it('should add feature with file locations', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
title: 'Feature With Locations',
category: 'backend',
description: 'Feature with file locations',
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature, [
'src/feature.ts',
'src/utils/helper.ts',
]);
expect(result).toBe(true);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('src/feature.ts'),
'utf-8'
);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('src/utils/helper.ts'),
'utf-8'
);
});
it('should return false when app_spec.txt does not exist', async () => {
const error: any = new Error('File not found');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValueOnce(error);
const feature = {
id: 'feature-1234-abc',
title: 'New Feature',
category: 'ui',
description: 'A new feature description',
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
expect(result).toBe(false);
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('should return false when feature already exists (duplicate)', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
const feature = {
id: 'feature-5678-xyz',
title: 'Existing Feature', // Same name as existing feature
category: 'ui',
description: 'Different description',
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
expect(result).toBe(false);
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('should use feature ID as fallback name when title is missing', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
category: 'ui',
description: 'Feature without title',
// No title property
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
expect(result).toBe(true);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('Feature: feature-1234-abc'),
'utf-8'
);
});
it('should handle app_spec without implemented_features section', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(appSpecWithoutFeatures);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
title: 'First Feature',
category: 'ui',
description: 'First implemented feature',
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
expect(result).toBe(true);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('<implemented_features>'),
'utf-8'
);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('First Feature'),
'utf-8'
);
});
it('should throw on non-ENOENT file read errors', async () => {
const error = new Error('Permission denied');
vi.mocked(fs.readFile).mockRejectedValueOnce(error);
const feature = {
id: 'feature-1234-abc',
title: 'New Feature',
category: 'ui',
description: 'A new feature description',
};
await expect(loader.syncFeatureToAppSpec(testProjectPath, feature)).rejects.toThrow(
'Permission denied'
);
});
it('should preserve existing features when adding a new one', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
title: 'New Feature',
category: 'ui',
description: 'A new feature',
};
await loader.syncFeatureToAppSpec(testProjectPath, feature);
// Verify both old and new features are in the output
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('Existing Feature'),
'utf-8'
);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('New Feature'),
'utf-8'
);
});
it('should escape special characters in feature name and description', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
title: 'Feature with <special> & "chars"',
category: 'ui',
description: 'Description with <tags> & "quotes"',
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
expect(result).toBe(true);
// The XML should have escaped characters
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('&lt;special&gt;'),
'utf-8'
);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('&amp;'),
'utf-8'
);
});
it('should not add empty file_locations array', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
title: 'Feature Without Locations',
category: 'ui',
description: 'No file locations',
};
await loader.syncFeatureToAppSpec(testProjectPath, feature, []);
// File locations should not be included when array is empty
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
const writtenContent = writeCall[1] as string;
// Count occurrences of file_locations - should only have the one from Existing Feature if any
// The new feature should not add file_locations
expect(writtenContent).toContain('Feature Without Locations');
});
});
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "@automaker/ui", "name": "@automaker/ui",
"version": "0.11.0", "version": "0.12.0",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents", "description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker", "homepage": "https://github.com/AutoMaker-Org/automaker",
"repository": { "repository": {

View File

@@ -0,0 +1,207 @@
/**
* Notification Bell - Bell icon with unread count and popover
*/
import { useCallback } from 'react';
import { Bell, Check, Trash2, ExternalLink } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import { useNotificationsStore } from '@/store/notifications-store';
import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';
import { getHttpApiClient } from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { Notification } from '@automaker/types';
import { cn } from '@/lib/utils';
/**
* Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
*/
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return 'just now';
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
return date.toLocaleDateString();
}
interface NotificationBellProps {
projectPath: string | null;
}
export function NotificationBell({ projectPath }: NotificationBellProps) {
const navigate = useNavigate();
const {
notifications,
unreadCount,
isPopoverOpen,
setPopoverOpen,
markAsRead,
dismissNotification,
} = useNotificationsStore();
// Load notifications and subscribe to events
useLoadNotifications(projectPath);
useNotificationEvents(projectPath);
const handleMarkAsRead = useCallback(
async (notificationId: string) => {
if (!projectPath) return;
// Optimistic update
markAsRead(notificationId);
// Sync with server
const api = getHttpApiClient();
await api.notifications.markAsRead(projectPath, notificationId);
},
[projectPath, markAsRead]
);
const handleDismiss = useCallback(
async (notificationId: string) => {
if (!projectPath) return;
// Optimistic update
dismissNotification(notificationId);
// Sync with server
const api = getHttpApiClient();
await api.notifications.dismiss(projectPath, notificationId);
},
[projectPath, dismissNotification]
);
const handleNotificationClick = useCallback(
(notification: Notification) => {
// Mark as read
handleMarkAsRead(notification.id);
setPopoverOpen(false);
// Navigate to the relevant view based on notification type
if (notification.featureId) {
navigate({ to: '/board' });
}
},
[handleMarkAsRead, setPopoverOpen, navigate]
);
const handleViewAll = useCallback(() => {
setPopoverOpen(false);
navigate({ to: '/notifications' });
}, [setPopoverOpen, navigate]);
const getNotificationIcon = (type: string) => {
switch (type) {
case 'feature_waiting_approval':
return <Bell className="h-4 w-4 text-yellow-500" />;
case 'feature_verified':
return <Check className="h-4 w-4 text-green-500" />;
case 'spec_regeneration_complete':
return <Check className="h-4 w-4 text-blue-500" />;
default:
return <Bell className="h-4 w-4" />;
}
};
// Show recent 3 notifications in popover
const recentNotifications = notifications.slice(0, 3);
if (!projectPath) {
return null;
}
return (
<Popover open={isPopoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<button
className={cn(
'relative flex items-center justify-center w-8 h-8 rounded-md',
'hover:bg-accent transition-colors',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2'
)}
title="Notifications"
>
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start" side="right">
<div className="flex items-center justify-between px-4 py-3 border-b">
<h4 className="font-medium text-sm">Notifications</h4>
{unreadCount > 0 && (
<span className="text-xs text-muted-foreground">{unreadCount} unread</span>
)}
</div>
{recentNotifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 px-4">
<Bell className="h-8 w-8 text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">No notifications</p>
</div>
) : (
<div className="max-h-[300px] overflow-y-auto">
{recentNotifications.map((notification) => (
<div
key={notification.id}
className={cn(
'flex items-start gap-3 px-4 py-3 cursor-pointer hover:bg-accent/50 border-b last:border-b-0',
!notification.read && 'bg-primary/5'
)}
onClick={() => handleNotificationClick(notification)}
>
<div className="flex-shrink-0 mt-0.5">{getNotificationIcon(notification.type)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium truncate">{notification.title}</p>
{!notification.read && (
<span className="h-1.5 w-1.5 rounded-full bg-primary flex-shrink-0" />
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">
{notification.message}
</p>
<p className="text-[10px] text-muted-foreground mt-1">
{formatRelativeTime(new Date(notification.createdAt))}
</p>
</div>
<div className="flex-shrink-0 flex flex-col gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleDismiss(notification.id);
}}
title="Dismiss"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
{notifications.length > 0 && (
<div className="border-t px-4 py-2">
<Button variant="ghost" size="sm" className="w-full text-xs" onClick={handleViewAll}>
View all notifications
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
}

View File

@@ -7,6 +7,7 @@ import { useOSDetection } from '@/hooks/use-os-detection';
import { ProjectSwitcherItem } from './components/project-switcher-item'; import { ProjectSwitcherItem } from './components/project-switcher-item';
import { ProjectContextMenu } from './components/project-context-menu'; import { ProjectContextMenu } from './components/project-context-menu';
import { EditProjectDialog } from './components/edit-project-dialog'; import { EditProjectDialog } from './components/edit-project-dialog';
import { NotificationBell } from './components/notification-bell';
import { NewProjectModal } from '@/components/dialogs/new-project-modal'; import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs'; import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks'; import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks';
@@ -327,6 +328,11 @@ export function ProjectSwitcher() {
v{appVersion} {versionSuffix} v{appVersion} {versionSuffix}
</span> </span>
</button> </button>
{/* Notification Bell */}
<div className="flex justify-center mt-2">
<NotificationBell projectPath={currentProject?.path ?? null} />
</div>
<div className="w-full h-px bg-border mt-3" /> <div className="w-full h-px bg-border mt-3" />
</div> </div>

View File

@@ -5,6 +5,7 @@ import { useNavigate, useLocation } from '@tanstack/react-router';
const logger = createLogger('Sidebar'); const logger = createLogger('Sidebar');
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store'; import { useAppStore, type ThemeMode } from '@/store/app-store';
import { useNotificationsStore } from '@/store/notifications-store';
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
@@ -19,7 +20,10 @@ import {
SidebarHeader, SidebarHeader,
SidebarNavigation, SidebarNavigation,
SidebarFooter, SidebarFooter,
MobileSidebarToggle,
} from './sidebar/components'; } from './sidebar/components';
import { useIsCompact } from '@/hooks/use-media-query';
import { PanelLeftClose } from 'lucide-react';
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs'; import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants'; import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
import { import {
@@ -43,9 +47,11 @@ export function Sidebar() {
trashedProjects, trashedProjects,
currentProject, currentProject,
sidebarOpen, sidebarOpen,
mobileSidebarHidden,
projectHistory, projectHistory,
upsertAndSetCurrentProject, upsertAndSetCurrentProject,
toggleSidebar, toggleSidebar,
toggleMobileSidebarHidden,
restoreTrashedProject, restoreTrashedProject,
deleteTrashedProject, deleteTrashedProject,
emptyTrash, emptyTrash,
@@ -56,12 +62,17 @@ export function Sidebar() {
setSpecCreatingForProject, setSpecCreatingForProject,
} = useAppStore(); } = useAppStore();
const isCompact = useIsCompact();
// Environment variable flags for hiding sidebar items // Environment variable flags for hiding sidebar items
const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor } = SIDEBAR_FEATURE_FLAGS; const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor } = SIDEBAR_FEATURE_FLAGS;
// Get customizable keyboard shortcuts // Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig(); const shortcuts = useKeyboardShortcutsConfig();
// Get unread notifications count
const unreadNotificationsCount = useNotificationsStore((s) => s.unreadCount);
// State for delete project confirmation dialog // State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
@@ -238,6 +249,7 @@ export function Sidebar() {
cyclePrevProject, cyclePrevProject,
cycleNextProject, cycleNextProject,
unviewedValidationsCount, unviewedValidationsCount,
unreadNotificationsCount,
isSpecGenerating: isCurrentProjectGeneratingSpec, isSpecGenerating: isCurrentProjectGeneratingSpec,
}); });
@@ -250,10 +262,16 @@ export function Sidebar() {
return location.pathname === routePath; return location.pathname === routePath;
}; };
// Check if sidebar should be completely hidden on mobile
const shouldHideSidebar = isCompact && mobileSidebarHidden;
return ( return (
<> <>
{/* Floating toggle to show sidebar on mobile when hidden */}
<MobileSidebarToggle />
{/* Mobile backdrop overlay */} {/* Mobile backdrop overlay */}
{sidebarOpen && ( {sidebarOpen && !shouldHideSidebar && (
<div <div
className="fixed inset-0 bg-black/50 z-20 lg:hidden" className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={toggleSidebar} onClick={toggleSidebar}
@@ -269,8 +287,11 @@ export function Sidebar() {
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]', 'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
// Smooth width transition // Smooth width transition
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]', 'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
// Mobile: completely hidden when mobileSidebarHidden is true
shouldHideSidebar && 'hidden',
// Mobile: overlay when open, collapsed when closed // Mobile: overlay when open, collapsed when closed
sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16' !shouldHideSidebar &&
(sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16')
)} )}
data-testid="sidebar" data-testid="sidebar"
> >
@@ -280,8 +301,33 @@ export function Sidebar() {
shortcut={shortcuts.toggleSidebar} shortcut={shortcuts.toggleSidebar}
/> />
{/* Floating hide button on right edge - only visible on compact screens when sidebar is collapsed */}
{!sidebarOpen && isCompact && (
<button
onClick={toggleMobileSidebarHidden}
className={cn(
'absolute -right-6 top-1/2 -translate-y-1/2 z-40',
'flex items-center justify-center w-6 h-10 rounded-r-lg',
'bg-card/95 backdrop-blur-sm border border-l-0 border-border/80',
'text-muted-foreground hover:text-brand-500 hover:bg-accent/80',
'shadow-lg hover:shadow-xl hover:shadow-brand-500/10',
'transition-all duration-200',
'hover:w-8 active:scale-95'
)}
aria-label="Hide sidebar"
data-testid="sidebar-mobile-hide"
>
<PanelLeftClose className="w-3.5 h-3.5" />
</button>
)}
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} currentProject={currentProject} /> <SidebarHeader
sidebarOpen={sidebarOpen}
currentProject={currentProject}
onClose={toggleSidebar}
onExpand={toggleSidebar}
/>
<SidebarNavigation <SidebarNavigation
currentProject={currentProject} currentProject={currentProject}

View File

@@ -1,6 +1,7 @@
import { PanelLeft, PanelLeftClose } from 'lucide-react'; import { PanelLeft, PanelLeftClose } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store'; import { formatShortcut } from '@/store/app-store';
import { useIsCompact } from '@/hooks/use-media-query';
interface CollapseToggleButtonProps { interface CollapseToggleButtonProps {
sidebarOpen: boolean; sidebarOpen: boolean;
@@ -13,6 +14,13 @@ export function CollapseToggleButton({
toggleSidebar, toggleSidebar,
shortcut, shortcut,
}: CollapseToggleButtonProps) { }: CollapseToggleButtonProps) {
const isCompact = useIsCompact();
// Hide when in compact mode (mobile menu is shown in board header)
if (isCompact) {
return null;
}
return ( return (
<button <button
onClick={toggleSidebar} onClick={toggleSidebar}

View File

@@ -8,3 +8,4 @@ export { ProjectActions } from './project-actions';
export { SidebarNavigation } from './sidebar-navigation'; export { SidebarNavigation } from './sidebar-navigation';
export { ProjectSelectorWithOptions } from './project-selector-with-options'; export { ProjectSelectorWithOptions } from './project-selector-with-options';
export { SidebarFooter } from './sidebar-footer'; export { SidebarFooter } from './sidebar-footer';
export { MobileSidebarToggle } from './mobile-sidebar-toggle';

View File

@@ -0,0 +1,42 @@
import { PanelLeft } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { useIsCompact } from '@/hooks/use-media-query';
/**
* Floating toggle button for mobile that completely hides/shows the sidebar.
* Positioned at the left-center of the screen.
* Only visible on compact/mobile screens when the sidebar is hidden.
*/
export function MobileSidebarToggle() {
const isCompact = useIsCompact();
const { mobileSidebarHidden, toggleMobileSidebarHidden } = useAppStore();
// Only show on compact screens when sidebar is hidden
if (!isCompact || !mobileSidebarHidden) {
return null;
}
return (
<button
onClick={toggleMobileSidebarHidden}
className={cn(
'fixed left-0 top-1/2 -translate-y-1/2 z-50',
'flex items-center justify-center',
'w-8 h-12 rounded-r-lg',
// Glass morphism background
'bg-card/95 backdrop-blur-sm border border-l-0 border-border/80',
// Shadow and hover effects
'shadow-lg shadow-black/10 hover:shadow-xl hover:shadow-brand-500/10',
'text-muted-foreground hover:text-brand-500 hover:bg-accent/80',
'hover:border-brand-500/30',
'transition-all duration-200 ease-out',
'hover:w-10 active:scale-95'
)}
aria-label="Show sidebar"
data-testid="mobile-sidebar-toggle"
>
<PanelLeft className="w-4 h-4 pointer-events-none" />
</button>
);
}

View File

@@ -151,7 +151,7 @@ export function SidebarFooter({
sidebarOpen ? 'justify-start' : 'justify-center', sidebarOpen ? 'justify-start' : 'justify-center',
'hover:scale-[1.02] active:scale-[0.97]' 'hover:scale-[1.02] active:scale-[0.97]'
)} )}
title={!sidebarOpen ? 'Settings' : undefined} title={!sidebarOpen ? 'Global Settings' : undefined}
data-testid="settings-button" data-testid="settings-button"
> >
<Settings <Settings
@@ -168,7 +168,7 @@ export function SidebarFooter({
sidebarOpen ? 'block' : 'hidden' sidebarOpen ? 'block' : 'hidden'
)} )}
> >
Settings Global Settings
</span> </span>
{sidebarOpen && ( {sidebarOpen && (
<span <span
@@ -194,7 +194,7 @@ export function SidebarFooter({
'translate-x-1 group-hover:translate-x-0' 'translate-x-1 group-hover:translate-x-0'
)} )}
> >
Settings Global Settings
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground"> <span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
{formatShortcut(shortcuts.settings, true)} {formatShortcut(shortcuts.settings, true)}
</span> </span>

View File

@@ -1,15 +1,29 @@
import { Folder, LucideIcon } from 'lucide-react'; import { useState } from 'react';
import { Folder, LucideIcon, X, Menu, Check } from 'lucide-react';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
import { cn, isMac } from '@/lib/utils'; import { cn, isMac } from '@/lib/utils';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { isElectron, type Project } from '@/lib/electron'; import { isElectron, type Project } from '@/lib/electron';
import { useIsCompact } from '@/hooks/use-media-query';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useAppStore } from '@/store/app-store';
interface SidebarHeaderProps { interface SidebarHeaderProps {
sidebarOpen: boolean; sidebarOpen: boolean;
currentProject: Project | null; currentProject: Project | null;
onClose?: () => void;
onExpand?: () => void;
} }
export function SidebarHeader({ sidebarOpen, currentProject }: SidebarHeaderProps) { export function SidebarHeader({
sidebarOpen,
currentProject,
onClose,
onExpand,
}: SidebarHeaderProps) {
const isCompact = useIsCompact();
const [projectListOpen, setProjectListOpen] = useState(false);
const { projects, setCurrentProject } = useAppStore();
// Get the icon component from lucide-react // Get the icon component from lucide-react
const getIconComponent = (): LucideIcon => { const getIconComponent = (): LucideIcon => {
if (currentProject?.icon && currentProject.icon in LucideIcons) { if (currentProject?.icon && currentProject.icon in LucideIcons) {
@@ -24,43 +38,141 @@ export function SidebarHeader({ sidebarOpen, currentProject }: SidebarHeaderProp
return ( return (
<div <div
className={cn( className={cn(
'shrink-0 flex flex-col', 'shrink-0 flex flex-col relative',
// Add padding on macOS Electron for traffic light buttons // Add padding on macOS Electron for traffic light buttons
isMac && isElectron() && 'pt-[10px]' isMac && isElectron() && 'pt-[10px]'
)} )}
> >
{/* Project name and icon display */} {/* Mobile close button - only visible on mobile when sidebar is open */}
{currentProject && ( {sidebarOpen && onClose && (
<div <button
onClick={onClose}
className={cn( className={cn(
'flex items-center gap-3 px-4 pt-3 pb-1', 'lg:hidden absolute top-3 right-3 z-10',
!sidebarOpen && 'justify-center px-2' 'flex items-center justify-center w-8 h-8 rounded-lg',
'bg-muted/50 hover:bg-muted',
'text-muted-foreground hover:text-foreground',
'transition-colors duration-200'
)} )}
aria-label="Close navigation"
data-testid="sidebar-mobile-close"
> >
{/* Project Icon */} <X className="w-5 h-5" />
<div className="shrink-0"> </button>
{hasCustomIcon ? ( )}
<img {/* Mobile expand button - hamburger menu to expand sidebar when collapsed on mobile */}
src={getAuthenticatedImageUrl(currentProject.customIconPath!, currentProject.path)} {!sidebarOpen && isCompact && onExpand && (
alt={currentProject.name} <button
className="w-8 h-8 rounded-lg object-cover ring-1 ring-border/50" onClick={onExpand}
/> className={cn(
) : ( 'flex items-center justify-center w-10 h-10 mx-auto mt-2 rounded-lg',
<div className="w-8 h-8 rounded-lg bg-brand-500/10 border border-brand-500/20 flex items-center justify-center"> 'bg-muted/50 hover:bg-muted',
<IconComponent className="w-5 h-5 text-brand-500" /> 'text-muted-foreground hover:text-foreground',
</div> 'transition-colors duration-200'
)}
</div>
{/* Project Name - only show when sidebar is open */}
{sidebarOpen && (
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-foreground truncate">
{currentProject.name}
</h2>
</div>
)} )}
</div> aria-label="Expand navigation"
data-testid="sidebar-mobile-expand"
>
<Menu className="w-5 h-5" />
</button>
)}
{/* Project name and icon display - entire element clickable on mobile */}
{currentProject && (
<Popover open={projectListOpen} onOpenChange={setProjectListOpen}>
<PopoverTrigger asChild>
<button
className={cn(
'flex items-center gap-3 px-4 pt-3 pb-1 w-full text-left',
'rounded-lg transition-colors duration-150',
!sidebarOpen && 'justify-center px-2',
// Only enable click behavior on compact screens
isCompact && 'hover:bg-accent/50 cursor-pointer',
!isCompact && 'pointer-events-none'
)}
title={isCompact ? 'Switch project' : undefined}
>
{/* Project Icon */}
<div className="shrink-0">
{hasCustomIcon ? (
<img
src={getAuthenticatedImageUrl(
currentProject.customIconPath!,
currentProject.path
)}
alt={currentProject.name}
className="w-8 h-8 rounded-lg object-cover ring-1 ring-border/50"
/>
) : (
<div className="w-8 h-8 rounded-lg bg-brand-500/10 border border-brand-500/20 flex items-center justify-center">
<IconComponent className="w-5 h-5 text-brand-500" />
</div>
)}
</div>
{/* Project Name - only show when sidebar is open */}
{sidebarOpen && (
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-foreground truncate">
{currentProject.name}
</h2>
</div>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="start" side="bottom" sideOffset={8}>
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground px-2 py-1">Switch Project</p>
{projects.map((project) => {
const ProjectIcon =
project.icon && project.icon in LucideIcons
? (LucideIcons as Record<string, LucideIcon>)[project.icon]
: Folder;
const isActive = currentProject?.id === project.id;
return (
<button
key={project.id}
onClick={() => {
setCurrentProject(project);
setProjectListOpen(false);
}}
className={cn(
'w-full flex items-center gap-3 px-2 py-2 rounded-lg text-left',
'transition-colors duration-150',
isActive
? 'bg-brand-500/10 text-brand-500'
: 'hover:bg-accent text-foreground'
)}
>
{project.customIconPath ? (
<img
src={getAuthenticatedImageUrl(project.customIconPath, project.path)}
alt={project.name}
className="w-6 h-6 rounded object-cover ring-1 ring-border/50"
/>
) : (
<div
className={cn(
'w-6 h-6 rounded flex items-center justify-center',
isActive ? 'bg-brand-500/20' : 'bg-muted'
)}
>
<ProjectIcon
className={cn(
'w-4 h-4',
isActive ? 'text-brand-500' : 'text-muted-foreground'
)}
/>
</div>
)}
<span className="flex-1 text-sm truncate">{project.name}</span>
{isActive && <Check className="w-4 h-4 text-brand-500" />}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
)} )}
</div> </div>
); );

View File

@@ -21,7 +21,12 @@ export function SidebarNavigation({
navigate, navigate,
}: SidebarNavigationProps) { }: SidebarNavigationProps) {
return ( 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 scrollbar-hide px-3 pb-2',
sidebarOpen ? 'mt-1' : 'mt-1'
)}
>
{!currentProject && sidebarOpen ? ( {!currentProject && sidebarOpen ? (
// Placeholder when no project is selected (only in expanded state) // Placeholder when no project is selected (only in expanded state)
<div className="flex items-center justify-center h-full px-4"> <div className="flex items-center justify-center h-full px-4">
@@ -41,7 +46,13 @@ export function SidebarNavigation({
</span> </span>
</div> </div>
)} )}
{section.label && !sidebarOpen && <div className="h-px bg-border/30 mx-2 my-1.5"></div>} {/* Separator for sections without label (visual separation) */}
{!section.label && sectionIdx > 0 && sidebarOpen && (
<div className="h-px bg-border/40 mx-3 mb-4"></div>
)}
{(section.label || sectionIdx > 0) && !sidebarOpen && (
<div className="h-px bg-border/30 mx-2 my-1.5"></div>
)}
{/* Nav Items */} {/* Nav Items */}
<div className="space-y-1.5"> <div className="space-y-1.5">

View File

@@ -11,6 +11,8 @@ import {
Lightbulb, Lightbulb,
Brain, Brain,
Network, Network,
Bell,
Settings,
} from 'lucide-react'; } from 'lucide-react';
import type { NavSection, NavItem } from '../types'; import type { NavSection, NavItem } from '../types';
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
@@ -32,9 +34,11 @@ interface UseNavigationProps {
agent: string; agent: string;
terminal: string; terminal: string;
settings: string; settings: string;
projectSettings: string;
ideation: string; ideation: string;
githubIssues: string; githubIssues: string;
githubPrs: string; githubPrs: string;
notifications: string;
}; };
hideSpecEditor: boolean; hideSpecEditor: boolean;
hideContext: boolean; hideContext: boolean;
@@ -49,6 +53,8 @@ interface UseNavigationProps {
cycleNextProject: () => void; cycleNextProject: () => void;
/** Count of unviewed validations to show on GitHub Issues nav item */ /** Count of unviewed validations to show on GitHub Issues nav item */
unviewedValidationsCount?: number; unviewedValidationsCount?: number;
/** Count of unread notifications to show on Notifications nav item */
unreadNotificationsCount?: number;
/** Whether spec generation is currently running for the current project */ /** Whether spec generation is currently running for the current project */
isSpecGenerating?: boolean; isSpecGenerating?: boolean;
} }
@@ -67,6 +73,7 @@ export function useNavigation({
cyclePrevProject, cyclePrevProject,
cycleNextProject, cycleNextProject,
unviewedValidationsCount, unviewedValidationsCount,
unreadNotificationsCount,
isSpecGenerating, isSpecGenerating,
}: UseNavigationProps) { }: UseNavigationProps) {
// Track if current project has a GitHub remote // Track if current project has a GitHub remote
@@ -199,6 +206,26 @@ export function useNavigation({
}); });
} }
// Add Notifications and Project Settings as a standalone section (no label for visual separation)
sections.push({
label: '',
items: [
{
id: 'notifications',
label: 'Notifications',
icon: Bell,
shortcut: shortcuts.notifications,
count: unreadNotificationsCount,
},
{
id: 'project-settings',
label: 'Project Settings',
icon: Settings,
shortcut: shortcuts.projectSettings,
},
],
});
return sections; return sections;
}, [ }, [
shortcuts, shortcuts,
@@ -207,6 +234,7 @@ export function useNavigation({
hideTerminal, hideTerminal,
hasGitHubRemote, hasGitHubRemote,
unviewedValidationsCount, unviewedValidationsCount,
unreadNotificationsCount,
isSpecGenerating, isSpecGenerating,
]); ]);
@@ -257,11 +285,11 @@ export function useNavigation({
}); });
}); });
// Add settings shortcut // Add global settings shortcut
shortcutsList.push({ shortcutsList.push({
key: shortcuts.settings, key: shortcuts.settings,
action: () => navigate({ to: '/settings' }), action: () => navigate({ to: '/settings' }),
description: 'Navigate to Settings', description: 'Navigate to Global Settings',
}); });
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger'; import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
@@ -6,6 +6,7 @@ const logger = createLogger('RunningAgents');
export function useRunningAgents() { export function useRunningAgents() {
const [runningAgentsCount, setRunningAgentsCount] = useState(0); const [runningAgentsCount, setRunningAgentsCount] = useState(0);
const fetchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Fetch running agents count function - used for initial load and event-driven updates // Fetch running agents count function - used for initial load and event-driven updates
const fetchRunningAgentsCount = useCallback(async () => { const fetchRunningAgentsCount = useCallback(async () => {
@@ -32,6 +33,16 @@ export function useRunningAgents() {
} }
}, []); }, []);
// Debounced fetch to avoid excessive API calls from frequent events
const debouncedFetchRunningAgentsCount = useCallback(() => {
if (fetchTimeoutRef.current) {
clearTimeout(fetchTimeoutRef.current);
}
fetchTimeoutRef.current = setTimeout(() => {
fetchRunningAgentsCount();
}, 300);
}, [fetchRunningAgentsCount]);
// Subscribe to auto-mode events to update running agents count in real-time // Subscribe to auto-mode events to update running agents count in real-time
useEffect(() => { useEffect(() => {
const api = getElectronAPI(); const api = getElectronAPI();
@@ -80,6 +91,41 @@ export function useRunningAgents() {
}; };
}, [fetchRunningAgentsCount]); }, [fetchRunningAgentsCount]);
// Subscribe to spec regeneration events to update running agents count
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
fetchRunningAgentsCount();
const unsubscribe = api.specRegeneration.onEvent((event) => {
logger.debug('Spec regeneration event for running agents hook', {
type: event.type,
});
// When spec regeneration completes or errors, refresh immediately
if (event.type === 'spec_regeneration_complete' || event.type === 'spec_regeneration_error') {
fetchRunningAgentsCount();
}
// For progress events, use debounced fetch to avoid excessive calls
else if (event.type === 'spec_regeneration_progress') {
debouncedFetchRunningAgentsCount();
}
});
return () => {
unsubscribe();
};
}, [fetchRunningAgentsCount, debouncedFetchRunningAgentsCount]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (fetchTimeoutRef.current) {
clearTimeout(fetchTimeoutRef.current);
}
};
}, []);
return { return {
runningAgentsCount, runningAgentsCount,
}; };

View File

@@ -0,0 +1,105 @@
import { createPortal } from 'react-dom';
import { X, Menu } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface HeaderActionsPanelProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
}
/**
* A slide-out panel for header actions on tablet and below.
* Shows as a right-side panel that slides in from the right edge.
* On desktop (lg+), this component is hidden and children should be rendered inline.
*/
export function HeaderActionsPanel({
isOpen,
onClose,
title = 'Actions',
children,
}: HeaderActionsPanelProps) {
// Use portal to render outside parent stacking contexts (backdrop-blur creates stacking context)
const panelContent = (
<>
{/* Mobile backdrop overlay - only shown when isOpen is true on tablet/mobile */}
{isOpen && (
<div
className="fixed inset-0 bg-black/50 z-[60] lg:hidden"
onClick={onClose}
data-testid="header-actions-backdrop"
/>
)}
{/* Actions panel */}
<div
className={cn(
// Mobile: fixed position overlay with slide transition from right
'fixed inset-y-0 right-0 w-72 z-[70]',
'transition-transform duration-200 ease-out',
// Hide on mobile when closed, show when open
isOpen ? 'translate-x-0' : 'translate-x-full',
// Desktop: hidden entirely (actions shown inline in header)
'lg:hidden',
'flex flex-col',
'border-l border-border/50',
'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl'
)}
>
{/* Panel header with close button */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50">
<span className="text-sm font-semibold text-foreground">{title}</span>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
aria-label="Close actions panel"
>
<X className="w-4 h-4" />
</Button>
</div>
{/* Panel content */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">{children}</div>
</div>
</>
);
// Render to document.body to escape stacking context
if (typeof document !== 'undefined') {
return createPortal(panelContent, document.body);
}
return panelContent;
}
interface HeaderActionsPanelTriggerProps {
isOpen: boolean;
onToggle: () => void;
className?: string;
}
/**
* Toggle button for the HeaderActionsPanel.
* Only visible on tablet and below (lg:hidden).
*/
export function HeaderActionsPanelTrigger({
isOpen,
onToggle,
className,
}: HeaderActionsPanelTriggerProps) {
return (
<Button
variant="ghost"
size="sm"
onClick={onToggle}
className={cn('h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden', className)}
aria-label={isOpen ? 'Close actions menu' : 'Open actions menu'}
>
{isOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</Button>
);
}

View File

@@ -70,8 +70,7 @@ const editorTheme = EditorView.theme({
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)', backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
}, },
'.cm-activeLine': { '.cm-activeLine': {
backgroundColor: 'var(--accent)', backgroundColor: 'transparent',
opacity: '0.3',
}, },
'.cm-line': { '.cm-line': {
padding: '0 0.25rem', padding: '0 0.25rem',
@@ -114,7 +113,7 @@ export function ShellSyntaxEditor({
}: ShellSyntaxEditorProps) { }: ShellSyntaxEditorProps) {
return ( return (
<div <div
className={cn('w-full rounded-lg border border-border bg-muted/30', className)} className={cn('w-full rounded-lg border border-border bg-background', className)}
style={{ minHeight }} style={{ minHeight }}
data-testid={testId} data-testid={testId}
> >

View File

@@ -27,18 +27,6 @@ export function AgentHeader({
return ( return (
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm"> <div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={onToggleSessionManager}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
{showSessionManager ? (
<PanelLeftClose className="w-4 h-4" />
) : (
<PanelLeft className="w-4 h-4" />
)}
</Button>
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center"> <div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
<Bot className="w-5 h-5 text-primary" /> <Bot className="w-5 h-5 text-primary" />
</div> </div>
@@ -71,6 +59,19 @@ export function AgentHeader({
Clear Clear
</Button> </Button>
)} )}
<Button
variant="ghost"
size="sm"
onClick={onToggleSessionManager}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
aria-label={showSessionManager ? 'Hide sessions panel' : 'Show sessions panel'}
>
{showSessionManager ? (
<PanelLeftClose className="w-4 h-4" />
) : (
<PanelLeft className="w-4 h-4" />
)}
</Button>
</div> </div>
</div> </div>
); );

View File

@@ -521,9 +521,9 @@ export function BoardView() {
// Empty string clears the branch assignment, moving features to main/current branch // Empty string clears the branch assignment, moving features to main/current branch
finalBranchName = ''; finalBranchName = '';
} else if (workMode === 'auto') { } else if (workMode === 'auto') {
// Auto-generate a branch name based on current branch and timestamp // Auto-generate a branch name based on primary branch (main/master) and timestamp
const baseBranch = // Always use primary branch to avoid nested feature/feature/... paths
currentWorktreeBranch || getPrimaryWorktreeBranch(currentProject.path) || 'main'; const baseBranch = getPrimaryWorktreeBranch(currentProject.path) || 'main';
const timestamp = Date.now(); const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 6); const randomSuffix = Math.random().toString(36).substring(2, 6);
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`; finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
@@ -603,7 +603,6 @@ export function BoardView() {
selectedFeatureIds, selectedFeatureIds,
updateFeature, updateFeature,
exitSelectionMode, exitSelectionMode,
currentWorktreeBranch,
getPrimaryWorktreeBranch, getPrimaryWorktreeBranch,
addAndSelectWorktree, addAndSelectWorktree,
setWorktreeRefreshKey, setWorktreeRefreshKey,

View File

@@ -1,11 +1,11 @@
import { useCallback } from 'react'; import { useCallback, useState } from 'react';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react'; import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react';
import { UsagePopover } from '@/components/usage-popover'; import { UsagePopover } from '@/components/usage-popover';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store'; import { useSetupStore } from '@/store/setup-store';
import { useIsMobile } from '@/hooks/use-media-query'; import { useIsTablet } from '@/hooks/use-media-query';
import { AutoModeSettingsPopover } from './dialogs/auto-mode-settings-popover'; import { AutoModeSettingsPopover } from './dialogs/auto-mode-settings-popover';
import { WorktreeSettingsPopover } from './dialogs/worktree-settings-popover'; import { WorktreeSettingsPopover } from './dialogs/worktree-settings-popover';
import { PlanSettingsPopover } from './dialogs/plan-settings-popover'; import { PlanSettingsPopover } from './dialogs/plan-settings-popover';
@@ -108,7 +108,10 @@ export function BoardHeader({
// Show if Codex is authenticated (CLI or API key) // Show if Codex is authenticated (CLI or API key)
const showCodexUsage = !!codexAuthStatus?.authenticated; const showCodexUsage = !!codexAuthStatus?.authenticated;
const isMobile = useIsMobile(); // State for mobile actions panel
const [showActionsPanel, setShowActionsPanel] = useState(false);
const isTablet = useIsTablet();
return ( return (
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md"> <div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
@@ -125,11 +128,13 @@ export function BoardHeader({
</div> </div>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
{/* Usage Popover - show if either provider is authenticated, only on desktop */} {/* Usage Popover - show if either provider is authenticated, only on desktop */}
{isMounted && !isMobile && (showClaudeUsage || showCodexUsage) && <UsagePopover />} {isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
{/* Mobile view: show hamburger menu with all controls */} {/* Tablet/Mobile view: show hamburger menu with all controls */}
{isMounted && isMobile && ( {isMounted && isTablet && (
<HeaderMobileMenu <HeaderMobileMenu
isOpen={showActionsPanel}
onToggle={() => setShowActionsPanel(!showActionsPanel)}
isWorktreePanelVisible={isWorktreePanelVisible} isWorktreePanelVisible={isWorktreePanelVisible}
onWorktreePanelToggle={handleWorktreePanelToggle} onWorktreePanelToggle={handleWorktreePanelToggle}
maxConcurrency={maxConcurrency} maxConcurrency={maxConcurrency}
@@ -146,7 +151,7 @@ export function BoardHeader({
{/* Desktop view: show full controls */} {/* Desktop view: show full controls */}
{/* Worktrees Toggle - only show after mount to prevent hydration issues */} {/* Worktrees Toggle - only show after mount to prevent hydration issues */}
{isMounted && !isMobile && ( {isMounted && !isTablet && (
<div className={controlContainerClass} data-testid="worktrees-toggle-container"> <div className={controlContainerClass} data-testid="worktrees-toggle-container">
<GitBranch className="w-4 h-4 text-muted-foreground" /> <GitBranch className="w-4 h-4 text-muted-foreground" />
<Label <Label
@@ -169,7 +174,7 @@ export function BoardHeader({
)} )}
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */} {/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && !isMobile && ( {isMounted && !isTablet && (
<div className={controlContainerClass} data-testid="auto-mode-toggle-container"> <div className={controlContainerClass} data-testid="auto-mode-toggle-container">
<Label <Label
htmlFor="auto-mode-toggle" htmlFor="auto-mode-toggle"
@@ -193,8 +198,8 @@ export function BoardHeader({
</div> </div>
)} )}
{/* Plan Button with Settings - only show on desktop, mobile has it in the menu */} {/* Plan Button with Settings - only show on desktop, tablet/mobile has it in the panel */}
{isMounted && !isMobile && ( {isMounted && !isTablet && (
<div className={controlContainerClass} data-testid="plan-button-container"> <div className={controlContainerClass} data-testid="plan-button-container">
{hasPendingPlan && ( {hasPendingPlan && (
<button <button

View File

@@ -2,18 +2,17 @@ import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { import {
DropdownMenu, HeaderActionsPanel,
DropdownMenuContent, HeaderActionsPanelTrigger,
DropdownMenuItem, } from '@/components/ui/header-actions-panel';
DropdownMenuLabel, import { Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Menu, Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { MobileUsageBar } from './mobile-usage-bar'; import { MobileUsageBar } from './mobile-usage-bar';
interface HeaderMobileMenuProps { interface HeaderMobileMenuProps {
// Panel visibility
isOpen: boolean;
onToggle: () => void;
// Worktree panel visibility // Worktree panel visibility
isWorktreePanelVisible: boolean; isWorktreePanelVisible: boolean;
onWorktreePanelToggle: (visible: boolean) => void; onWorktreePanelToggle: (visible: boolean) => void;
@@ -33,6 +32,8 @@ interface HeaderMobileMenuProps {
} }
export function HeaderMobileMenu({ export function HeaderMobileMenu({
isOpen,
onToggle,
isWorktreePanelVisible, isWorktreePanelVisible,
onWorktreePanelToggle, onWorktreePanelToggle,
maxConcurrency, maxConcurrency,
@@ -46,129 +47,122 @@ export function HeaderMobileMenu({
showCodexUsage, showCodexUsage,
}: HeaderMobileMenuProps) { }: HeaderMobileMenuProps) {
return ( return (
<DropdownMenu> <>
<DropdownMenuTrigger asChild> <HeaderActionsPanelTrigger isOpen={isOpen} onToggle={onToggle} />
<Button <HeaderActionsPanel isOpen={isOpen} onClose={onToggle} title="Board Controls">
variant="outline"
size="sm"
className="h-8 w-8 p-0"
data-testid="header-mobile-menu-trigger"
>
<Menu className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{/* Usage Bar - show if either provider is authenticated */} {/* Usage Bar - show if either provider is authenticated */}
{(showClaudeUsage || showCodexUsage) && ( {(showClaudeUsage || showCodexUsage) && (
<> <div className="space-y-2">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Usage Usage
</DropdownMenuLabel> </span>
<MobileUsageBar showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} /> <MobileUsageBar showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} />
<DropdownMenuSeparator /> </div>
</>
)} )}
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> {/* Controls Section */}
Controls <div className="space-y-1">
</DropdownMenuLabel> <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
<DropdownMenuSeparator /> Controls
</span>
{/* Auto Mode Toggle */} {/* Auto Mode Toggle */}
<div <div
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm" className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
onClick={() => onAutoModeToggle(!isAutoModeRunning)} onClick={() => onAutoModeToggle(!isAutoModeRunning)}
data-testid="mobile-auto-mode-toggle-container" data-testid="mobile-auto-mode-toggle-container"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Zap <Zap
className={cn( className={cn(
'w-4 h-4', 'w-4 h-4',
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground' isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
)} )}
/> />
<span className="text-sm font-medium">Auto Mode</span> <span className="text-sm font-medium">Auto Mode</span>
</div>
<div className="flex items-center gap-2">
<Switch
id="mobile-auto-mode-toggle"
checked={isAutoModeRunning}
onCheckedChange={onAutoModeToggle}
onClick={(e) => e.stopPropagation()}
data-testid="mobile-auto-mode-toggle"
/>
<button
onClick={(e) => {
e.stopPropagation();
onOpenAutoModeSettings();
}}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Auto Mode Settings"
data-testid="mobile-auto-mode-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
</div> </div>
<div className="flex items-center gap-2">
{/* Worktrees Toggle */}
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
onClick={() => onWorktreePanelToggle(!isWorktreePanelVisible)}
data-testid="mobile-worktrees-toggle-container"
>
<div className="flex items-center gap-2">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Worktree Bar</span>
</div>
<Switch <Switch
id="mobile-auto-mode-toggle" id="mobile-worktrees-toggle"
checked={isAutoModeRunning} checked={isWorktreePanelVisible}
onCheckedChange={onAutoModeToggle} onCheckedChange={onWorktreePanelToggle}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
data-testid="mobile-auto-mode-toggle" data-testid="mobile-worktrees-toggle"
/> />
<button
onClick={(e) => {
e.stopPropagation();
onOpenAutoModeSettings();
}}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Auto Mode Settings"
data-testid="mobile-auto-mode-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div> </div>
</div>
<DropdownMenuSeparator /> {/* Concurrency Control */}
<div
{/* Worktrees Toggle */} className="p-3 rounded-lg border border-border/50"
<div data-testid="mobile-concurrency-control"
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm" >
onClick={() => onWorktreePanelToggle(!isWorktreePanelVisible)} <div className="flex items-center gap-2 mb-3">
data-testid="mobile-worktrees-toggle-container" <Bot className="w-4 h-4 text-muted-foreground" />
> <span className="text-sm font-medium">Max Agents</span>
<div className="flex items-center gap-2"> <span
<GitBranch className="w-4 h-4 text-muted-foreground" /> className="text-sm text-muted-foreground ml-auto"
<span className="text-sm font-medium">Worktrees</span> data-testid="mobile-concurrency-value"
>
{runningAgentsCount}/{maxConcurrency}
</span>
</div>
<Slider
value={[maxConcurrency]}
onValueChange={(value) => onConcurrencyChange(value[0])}
min={1}
max={10}
step={1}
className="w-full"
data-testid="mobile-concurrency-slider"
/>
</div> </div>
<Switch
id="mobile-worktrees-toggle" {/* Plan Button */}
checked={isWorktreePanelVisible} <Button
onCheckedChange={onWorktreePanelToggle} variant="outline"
onClick={(e) => e.stopPropagation()} className="w-full justify-start"
data-testid="mobile-worktrees-toggle" onClick={() => {
/> onOpenPlanDialog();
onToggle();
}}
data-testid="mobile-plan-button"
>
<Wand2 className="w-4 h-4 mr-2" />
Plan
</Button>
</div> </div>
</HeaderActionsPanel>
<DropdownMenuSeparator /> </>
{/* Concurrency Control */}
<div className="px-2 py-2" data-testid="mobile-concurrency-control">
<div className="flex items-center gap-2 mb-2">
<Bot className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Max Agents</span>
<span
className="text-sm text-muted-foreground ml-auto"
data-testid="mobile-concurrency-value"
>
{runningAgentsCount}/{maxConcurrency}
</span>
</div>
<Slider
value={[maxConcurrency]}
onValueChange={(value) => onConcurrencyChange(value[0])}
min={1}
max={10}
step={1}
className="w-full"
data-testid="mobile-concurrency-slider"
/>
</div>
<DropdownMenuSeparator />
{/* Plan Button */}
<DropdownMenuItem
onClick={onOpenPlanDialog}
className="flex items-center gap-2"
data-testid="mobile-plan-button"
>
<Wand2 className="w-4 h-4" />
<span>Plan</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
); );
} }

View File

@@ -127,8 +127,10 @@ export function useBoardActions({
// No worktree isolation - work directly on current branch // No worktree isolation - work directly on current branch
finalBranchName = undefined; finalBranchName = undefined;
} else if (workMode === 'auto') { } else if (workMode === 'auto') {
// Auto-generate a branch name based on current branch and timestamp // Auto-generate a branch name based on primary branch (main/master) and timestamp
const baseBranch = currentWorktreeBranch || 'main'; // Always use primary branch to avoid nested feature/feature/... paths
const baseBranch =
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
const timestamp = Date.now(); const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 6); const randomSuffix = Math.random().toString(36).substring(2, 6);
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`; finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
@@ -245,7 +247,7 @@ export function useBoardActions({
currentProject, currentProject,
onWorktreeCreated, onWorktreeCreated,
onWorktreeAutoSelect, onWorktreeAutoSelect,
currentWorktreeBranch, getPrimaryWorktreeBranch,
features, features,
] ]
); );
@@ -282,7 +284,10 @@ export function useBoardActions({
if (workMode === 'current') { if (workMode === 'current') {
finalBranchName = undefined; finalBranchName = undefined;
} else if (workMode === 'auto') { } else if (workMode === 'auto') {
const baseBranch = currentWorktreeBranch || 'main'; // Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
const baseBranch =
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
const timestamp = Date.now(); const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 6); const randomSuffix = Math.random().toString(36).substring(2, 6);
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`; finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
@@ -390,7 +395,7 @@ export function useBoardActions({
setEditingFeature, setEditingFeature,
currentProject, currentProject,
onWorktreeCreated, onWorktreeCreated,
currentWorktreeBranch, getPrimaryWorktreeBranch,
features, features,
] ]
); );

View File

@@ -93,7 +93,7 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
isRunning: true, isRunning: true,
isLoading: false, isLoading: false,
port: result.result!.port, port: result.result!.port,
url: `http://localhost:${result.result!.port}`, url: result.result!.url,
startedAt: result.result!.startedAt, startedAt: result.result!.startedAt,
error: null, error: null,
})); }));

View File

@@ -7,6 +7,10 @@ import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button'; import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import {
HeaderActionsPanel,
HeaderActionsPanelTrigger,
} from '@/components/ui/header-actions-panel';
import { import {
RefreshCw, RefreshCw,
FileText, FileText,
@@ -94,6 +98,9 @@ export function ContextView() {
const [editDescriptionValue, setEditDescriptionValue] = useState(''); const [editDescriptionValue, setEditDescriptionValue] = useState('');
const [editDescriptionFileName, setEditDescriptionFileName] = useState(''); const [editDescriptionFileName, setEditDescriptionFileName] = useState('');
// Actions panel state (for tablet/mobile)
const [showActionsPanel, setShowActionsPanel] = useState(false);
// File input ref for import // File input ref for import
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -691,30 +698,70 @@ export function ContextView() {
</p> </p>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex items-center gap-2">
<Button {/* Desktop: show actions inline */}
variant="outline" <div className="hidden lg:flex gap-2">
size="sm" <Button
onClick={handleImportClick} variant="outline"
disabled={isUploading} size="sm"
data-testid="import-file-button" onClick={handleImportClick}
> disabled={isUploading}
<FileUp className="w-4 h-4 mr-2" /> data-testid="import-file-button"
Import File >
</Button> <FileUp className="w-4 h-4 mr-2" />
<HotkeyButton Import File
size="sm" </Button>
onClick={() => setIsCreateMarkdownOpen(true)} <HotkeyButton
hotkey={shortcuts.addContextFile} size="sm"
hotkeyActive={false} onClick={() => setIsCreateMarkdownOpen(true)}
data-testid="create-markdown-button" hotkey={shortcuts.addContextFile}
> hotkeyActive={false}
<FilePlus className="w-4 h-4 mr-2" /> data-testid="create-markdown-button"
Create Markdown >
</HotkeyButton> <FilePlus className="w-4 h-4 mr-2" />
Create Markdown
</HotkeyButton>
</div>
{/* Tablet/Mobile: show trigger for actions panel */}
<HeaderActionsPanelTrigger
isOpen={showActionsPanel}
onToggle={() => setShowActionsPanel(!showActionsPanel)}
/>
</div> </div>
</div> </div>
{/* Actions Panel (tablet/mobile) */}
<HeaderActionsPanel
isOpen={showActionsPanel}
onClose={() => setShowActionsPanel(false)}
title="Context Actions"
>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => {
handleImportClick();
setShowActionsPanel(false);
}}
disabled={isUploading}
data-testid="import-file-button-mobile"
>
<FileUp className="w-4 h-4 mr-2" />
Import File
</Button>
<Button
className="w-full justify-start"
onClick={() => {
setIsCreateMarkdownOpen(true);
setShowActionsPanel(false);
}}
data-testid="create-markdown-button-mobile"
>
<FilePlus className="w-4 h-4 mr-2" />
Create Markdown
</Button>
</HeaderActionsPanel>
{/* Main content area with file list and editor */} {/* Main content area with file list and editor */}
<div <div
className={cn( className={cn(

View File

@@ -21,10 +21,15 @@ import {
Loader2, Loader2,
ChevronDown, ChevronDown,
MessageSquare, MessageSquare,
Settings,
MoreVertical, MoreVertical,
Trash2, Trash2,
Search,
X,
type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { Input } from '@/components/ui/input';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -55,6 +60,13 @@ function getOSAbbreviation(os: string): string {
} }
} }
function getIconComponent(iconName?: string): LucideIcon {
if (iconName && iconName in LucideIcons) {
return (LucideIcons as unknown as Record<string, LucideIcon>)[iconName];
}
return Folder;
}
export function DashboardView() { export function DashboardView() {
const navigate = useNavigate(); const navigate = useNavigate();
const { os } = useOSDetection(); const { os } = useOSDetection();
@@ -79,6 +91,7 @@ export function DashboardView() {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [isOpening, setIsOpening] = useState(false); const [isOpening, setIsOpening] = useState(false);
const [projectToRemove, setProjectToRemove] = useState<{ id: string; name: string } | null>(null); const [projectToRemove, setProjectToRemove] = useState<{ id: string; name: string } | null>(null);
const [searchQuery, setSearchQuery] = useState('');
// Sort projects: favorites first, then by last opened // Sort projects: favorites first, then by last opened
const sortedProjects = [...projects].sort((a, b) => { const sortedProjects = [...projects].sort((a, b) => {
@@ -91,8 +104,15 @@ export function DashboardView() {
return dateB - dateA; return dateB - dateA;
}); });
const favoriteProjects = sortedProjects.filter((p) => p.isFavorite); // Filter projects based on search query
const recentProjects = sortedProjects.filter((p) => !p.isFavorite); const filteredProjects = sortedProjects.filter((project) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return project.name.toLowerCase().includes(query) || project.path.toLowerCase().includes(query);
});
const favoriteProjects = filteredProjects.filter((p) => p.isFavorite);
const recentProjects = filteredProjects.filter((p) => !p.isFavorite);
/** /**
* Initialize project and navigate to board * Initialize project and navigate to board
@@ -529,14 +549,35 @@ export function DashboardView() {
</span> </span>
</div> </div>
</div> </div>
<Button
variant="ghost" {/* Mobile action buttons in header */}
size="icon" {hasProjects && (
onClick={() => navigate({ to: '/settings' })} <div className="flex sm:hidden gap-2 titlebar-no-drag">
className="titlebar-no-drag" <Button variant="outline" size="icon" onClick={handleOpenProject}>
> <FolderOpen className="w-4 h-4" />
<Settings className="w-5 h-5" /> </Button>
</Button> <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
className="bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
>
<Plus className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={handleNewProject}>
<Plus className="w-4 h-4 mr-2" />
Quick Setup
</DropdownMenuItem>
<DropdownMenuItem onClick={handleInteractiveMode}>
<MessageSquare className="w-4 h-4 mr-2" />
Interactive Mode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div> </div>
</header> </header>
@@ -646,25 +687,42 @@ export function DashboardView() {
{/* Has projects - show project list */} {/* Has projects - show project list */}
{hasProjects && ( {hasProjects && (
<div className="space-y-6 sm:space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500"> <div className="space-y-6 sm:space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Quick actions header */} {/* Search and actions header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<h2 className="text-2xl font-bold text-foreground">Your Projects</h2> <h2 className="text-xl sm:text-2xl font-bold text-foreground">Your Projects</h2>
<div className="flex gap-2"> <div className="flex items-center gap-2">
<Button {/* Search input */}
variant="outline" <div className="relative flex-1 sm:flex-none">
onClick={handleOpenProject} <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
className="flex-1 sm:flex-none" <Input
> type="text"
<FolderOpen className="w-4 h-4 sm:mr-2" /> placeholder="Search projects..."
<span className="hidden sm:inline">Open Folder</span> value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 w-full sm:w-64"
data-testid="project-search-input"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-muted transition-colors"
title="Clear search"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
)}
</div>
{/* Desktop only buttons */}
<Button variant="outline" onClick={handleOpenProject} className="hidden sm:flex">
<FolderOpen className="w-4 h-4 mr-2" />
Open Folder
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button className="flex-1 sm:flex-none bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"> <Button className="hidden sm:flex bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white">
<Plus className="w-4 h-4 sm:mr-2" /> <Plus className="w-4 h-4 mr-2" />
<span className="hidden sm:inline">New Project</span> New Project
<span className="sm:hidden">New</span> <ChevronDown className="w-4 h-4 ml-2" />
<ChevronDown className="w-4 h-4 ml-1 sm:ml-2" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56"> <DropdownMenuContent align="end" className="w-56">
@@ -703,8 +761,24 @@ export function DashboardView() {
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-yellow-500/5 to-amber-600/5 opacity-0 group-hover:opacity-100 transition-all duration-300" /> <div className="absolute inset-0 rounded-xl bg-linear-to-br from-yellow-500/5 to-amber-600/5 opacity-0 group-hover:opacity-100 transition-all duration-300" />
<div className="relative p-3 sm:p-4"> <div className="relative p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3"> <div className="flex items-start gap-2.5 sm:gap-3">
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-yellow-500/10 border border-yellow-500/30 flex items-center justify-center group-hover:bg-yellow-500/20 transition-all duration-300 shrink-0"> <div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-yellow-500/10 border border-yellow-500/30 flex items-center justify-center group-hover:bg-yellow-500/20 transition-all duration-300 shrink-0 overflow-hidden">
<Folder className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-500" /> {project.customIconPath ? (
<img
src={getAuthenticatedImageUrl(
project.customIconPath,
project.path
)}
alt={project.name}
className="w-full h-full object-cover"
/>
) : (
(() => {
const IconComponent = getIconComponent(project.icon);
return (
<IconComponent className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-500" />
);
})()
)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-yellow-500 transition-colors duration-300"> <p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-yellow-500 transition-colors duration-300">
@@ -778,8 +852,24 @@ export function DashboardView() {
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all duration-300" /> <div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all duration-300" />
<div className="relative p-3 sm:p-4"> <div className="relative p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3"> <div className="flex items-start gap-2.5 sm:gap-3">
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-muted/80 border border-border flex items-center justify-center group-hover:bg-brand-500/10 group-hover:border-brand-500/30 transition-all duration-300 shrink-0"> <div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-muted/80 border border-border flex items-center justify-center group-hover:bg-brand-500/10 group-hover:border-brand-500/30 transition-all duration-300 shrink-0 overflow-hidden">
<Folder className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground group-hover:text-brand-500 transition-colors duration-300" /> {project.customIconPath ? (
<img
src={getAuthenticatedImageUrl(
project.customIconPath,
project.path
)}
alt={project.name}
className="w-full h-full object-cover"
/>
) : (
(() => {
const IconComponent = getIconComponent(project.icon);
return (
<IconComponent className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground group-hover:text-brand-500 transition-colors duration-300" />
);
})()
)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-brand-500 transition-colors duration-300"> <p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-brand-500 transition-colors duration-300">
@@ -797,10 +887,10 @@ export function DashboardView() {
<div className="flex items-center gap-0.5 sm:gap-1"> <div className="flex items-center gap-0.5 sm:gap-1">
<button <button
onClick={(e) => handleToggleFavorite(e, project.id)} onClick={(e) => handleToggleFavorite(e, project.id)}
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100" className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors"
title="Add to favorites" title="Add to favorites"
> >
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground hover:text-yellow-500" /> <Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground/50 hover:text-yellow-500 transition-colors" />
</button> </button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -830,6 +920,22 @@ export function DashboardView() {
</div> </div>
</div> </div>
)} )}
{/* No search results */}
{searchQuery && favoriteProjects.length === 0 && recentProjects.length === 0 && (
<div className="text-center py-12">
<div className="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
<Search className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">No projects found</h3>
<p className="text-sm text-muted-foreground mb-4">
No projects match "{searchQuery}"
</p>
<Button variant="outline" size="sm" onClick={() => setSearchQuery('')}>
Clear search
</Button>
</div>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,20 +1,26 @@
// @ts-nocheck // @ts-nocheck
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger'; import { createLogger } from '@automaker/utils/logger';
import { CircleDot, RefreshCw } from 'lucide-react'; import { CircleDot, RefreshCw, SearchX } from 'lucide-react';
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron'; import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { LoadingState } from '@/components/ui/loading-state'; import { LoadingState } from '@/components/ui/loading-state';
import { ErrorState } from '@/components/ui/error-state'; import { ErrorState } from '@/components/ui/error-state';
import { cn, pathsEqual } from '@/lib/utils'; import { cn, pathsEqual } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks'; import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components'; import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
import { ValidationDialog } from './github-issues-view/dialogs'; import { ValidationDialog } from './github-issues-view/dialogs';
import { formatDate, getFeaturePriority } from './github-issues-view/utils'; import { formatDate, getFeaturePriority } from './github-issues-view/utils';
import { useModelOverride } from '@/components/shared'; import { useModelOverride } from '@/components/shared';
import type { ValidateIssueOptions } from './github-issues-view/types'; import type {
ValidateIssueOptions,
IssuesFilterState,
IssuesStateFilter,
} from './github-issues-view/types';
import { DEFAULT_ISSUES_FILTER_STATE } from './github-issues-view/types';
const logger = createLogger('GitHubIssuesView'); const logger = createLogger('GitHubIssuesView');
@@ -26,6 +32,9 @@ export function GitHubIssuesView() {
const [pendingRevalidateOptions, setPendingRevalidateOptions] = const [pendingRevalidateOptions, setPendingRevalidateOptions] =
useState<ValidateIssueOptions | null>(null); useState<ValidateIssueOptions | null>(null);
// Filter state
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore(); const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
// Model override for validation // Model override for validation
@@ -44,6 +53,41 @@ export function GitHubIssuesView() {
onShowValidationDialogChange: setShowValidationDialog, onShowValidationDialogChange: setShowValidationDialog,
}); });
// Combine all issues for filtering
const allIssues = useMemo(() => [...openIssues, ...closedIssues], [openIssues, closedIssues]);
// Apply filter to issues - now returns matched issues directly for better performance
const filterResult = useIssuesFilter(allIssues, filterState, cachedValidations);
// Separate filtered issues by state - this is O(n) but now only done once
// since filterResult.matchedIssues already contains the filtered issues
const { filteredOpenIssues, filteredClosedIssues } = useMemo(() => {
const open: typeof openIssues = [];
const closed: typeof closedIssues = [];
for (const issue of filterResult.matchedIssues) {
if (issue.state.toLowerCase() === 'open') {
open.push(issue);
} else {
closed.push(issue);
}
}
return { filteredOpenIssues: open, filteredClosedIssues: closed };
}, [filterResult.matchedIssues]);
// Filter state change handlers
const handleStateFilterChange = useCallback((stateFilter: IssuesStateFilter) => {
setFilterState((prev) => ({ ...prev, stateFilter }));
}, []);
const handleLabelsChange = useCallback((selectedLabels: string[]) => {
setFilterState((prev) => ({ ...prev, selectedLabels }));
}, []);
// Clear all filters to default state
const handleClearFilters = useCallback(() => {
setFilterState(DEFAULT_ISSUES_FILTER_STATE);
}, []);
// Get current branch from selected worktree // Get current branch from selected worktree
const currentBranch = useMemo(() => { const currentBranch = useMemo(() => {
if (!currentProject?.path) return ''; if (!currentProject?.path) return '';
@@ -130,7 +174,10 @@ export function GitHubIssuesView() {
return <ErrorState error={error} title="Failed to Load Issues" onRetry={refresh} />; return <ErrorState error={error} title="Failed to Load Issues" onRetry={refresh} />;
} }
const totalIssues = openIssues.length + closedIssues.length; const totalIssues = filteredOpenIssues.length + filteredClosedIssues.length;
const totalUnfilteredIssues = openIssues.length + closedIssues.length;
const isFilteredEmpty =
totalIssues === 0 && totalUnfilteredIssues > 0 && filterResult.hasActiveFilter;
return ( return (
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">
@@ -143,10 +190,21 @@ export function GitHubIssuesView() {
> >
{/* Header */} {/* Header */}
<IssuesListHeader <IssuesListHeader
openCount={openIssues.length} openCount={filteredOpenIssues.length}
closedCount={closedIssues.length} closedCount={filteredClosedIssues.length}
totalOpenCount={openIssues.length}
totalClosedCount={closedIssues.length}
hasActiveFilter={filterResult.hasActiveFilter}
refreshing={refreshing} refreshing={refreshing}
onRefresh={refresh} onRefresh={refresh}
compact={!!selectedIssue}
filterProps={{
stateFilter: filterState.stateFilter,
selectedLabels: filterState.selectedLabels,
availableLabels: filterResult.availableLabels,
onStateFilterChange: handleStateFilterChange,
onLabelsChange: handleLabelsChange,
}}
/> />
{/* Issues List */} {/* Issues List */}
@@ -154,15 +212,35 @@ export function GitHubIssuesView() {
{totalIssues === 0 ? ( {totalIssues === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-6"> <div className="flex flex-col items-center justify-center h-full text-center p-6">
<div className="p-4 rounded-full bg-muted/50 mb-4"> <div className="p-4 rounded-full bg-muted/50 mb-4">
<CircleDot className="h-8 w-8 text-muted-foreground" /> {isFilteredEmpty ? (
<SearchX className="h-8 w-8 text-muted-foreground" />
) : (
<CircleDot className="h-8 w-8 text-muted-foreground" />
)}
</div> </div>
<h2 className="text-base font-medium mb-2">No Issues</h2> <h2 className="text-base font-medium mb-2">
<p className="text-sm text-muted-foreground">This repository has no issues yet.</p> {isFilteredEmpty ? 'No Matching Issues' : 'No Issues'}
</h2>
<p className="text-sm text-muted-foreground mb-4">
{isFilteredEmpty
? 'No issues match your current filters.'
: 'This repository has no issues yet.'}
</p>
{isFilteredEmpty && (
<Button
variant="outline"
size="sm"
onClick={handleClearFilters}
className="text-xs"
>
Clear Filters
</Button>
)}
</div> </div>
) : ( ) : (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{/* Open Issues */} {/* Open Issues */}
{openIssues.map((issue) => ( {filteredOpenIssues.map((issue) => (
<IssueRow <IssueRow
key={issue.number} key={issue.number}
issue={issue} issue={issue}
@@ -176,12 +254,12 @@ export function GitHubIssuesView() {
))} ))}
{/* Closed Issues Section */} {/* Closed Issues Section */}
{closedIssues.length > 0 && ( {filteredClosedIssues.length > 0 && (
<> <>
<div className="px-4 py-2 bg-muted/30 text-xs font-medium text-muted-foreground"> <div className="px-4 py-2 bg-muted/30 text-xs font-medium text-muted-foreground">
Closed Issues ({closedIssues.length}) Closed Issues ({filteredClosedIssues.length})
</div> </div>
{closedIssues.map((issue) => ( {filteredClosedIssues.map((issue) => (
<IssueRow <IssueRow
key={issue.number} key={issue.number}
issue={issue} issue={issue}

View File

@@ -1,4 +1,5 @@
export { IssueRow } from './issue-row'; export { IssueRow } from './issue-row';
export { IssueDetailPanel } from './issue-detail-panel'; export { IssueDetailPanel } from './issue-detail-panel';
export { IssuesListHeader } from './issues-list-header'; export { IssuesListHeader } from './issues-list-header';
export { IssuesFilterControls } from './issues-filter-controls';
export { CommentItem } from './comment-item'; export { CommentItem } from './comment-item';

View File

@@ -0,0 +1,191 @@
import { ChevronDown, Tag, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { IssuesStateFilter } from '../types';
import { ISSUES_STATE_FILTER_OPTIONS } from '../types';
/** Maximum number of labels to display before showing "+N more" in normal layout */
const VISIBLE_LABELS_LIMIT = 3;
/** Maximum number of labels to display before showing "+N more" in compact layout */
const VISIBLE_LABELS_LIMIT_COMPACT = 2;
interface IssuesFilterControlsProps {
/** Current state filter value */
stateFilter: IssuesStateFilter;
/** Currently selected labels */
selectedLabels: string[];
/** Available labels to choose from (typically from useIssuesFilter result) */
availableLabels: string[];
/** Callback when state filter changes */
onStateFilterChange: (filter: IssuesStateFilter) => void;
/** Callback when labels selection changes */
onLabelsChange: (labels: string[]) => void;
/** Whether the controls are disabled (e.g., during loading) */
disabled?: boolean;
/** Whether to use compact layout (stacked vertically) */
compact?: boolean;
/** Additional class name for the container */
className?: string;
}
/** Human-readable labels for state filter options */
const STATE_FILTER_LABELS: Record<IssuesStateFilter, string> = {
open: 'Open',
closed: 'Closed',
all: 'All',
};
export function IssuesFilterControls({
stateFilter,
selectedLabels,
availableLabels,
onStateFilterChange,
onLabelsChange,
disabled = false,
compact = false,
className,
}: IssuesFilterControlsProps) {
/**
* Handles toggling a label in the selection.
* If the label is already selected, it removes it; otherwise, it adds it.
*/
const handleLabelToggle = (label: string) => {
const isSelected = selectedLabels.includes(label);
if (isSelected) {
onLabelsChange(selectedLabels.filter((l) => l !== label));
} else {
onLabelsChange([...selectedLabels, label]);
}
};
/**
* Clears all selected labels.
*/
const handleClearLabels = () => {
onLabelsChange([]);
};
const hasSelectedLabels = selectedLabels.length > 0;
const hasAvailableLabels = availableLabels.length > 0;
return (
<div className={cn('flex flex-col gap-2', className)}>
{/* Filter Controls Row */}
<div className="flex items-center gap-2">
{/* State Filter Select */}
<Select
value={stateFilter}
onValueChange={(value) => onStateFilterChange(value as IssuesStateFilter)}
disabled={disabled}
>
<SelectTrigger className={cn('h-8 text-sm', compact ? 'w-[90px]' : 'w-[110px]')}>
<SelectValue placeholder="State" />
</SelectTrigger>
<SelectContent>
{ISSUES_STATE_FILTER_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{STATE_FILTER_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Labels Filter Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={disabled || !hasAvailableLabels}>
<Button
variant="outline"
size="sm"
className={cn('h-8 gap-1.5', hasSelectedLabels && 'border-primary/50 bg-primary/5')}
disabled={disabled || !hasAvailableLabels}
>
<Tag className="h-3.5 w-3.5" />
<span>Labels</span>
{hasSelectedLabels && (
<Badge variant="secondary" size="sm" className="ml-1 px-1.5 py-0">
{selectedLabels.length}
</Badge>
)}
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56 max-h-64 overflow-y-auto">
<DropdownMenuLabel className="flex items-center justify-between">
<span>Filter by label</span>
{hasSelectedLabels && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={handleClearLabels}
>
<X className="h-3 w-3 mr-0.5" />
Clear
</Button>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{availableLabels.map((label) => (
<DropdownMenuCheckboxItem
key={label}
checked={selectedLabels.includes(label)}
onCheckedChange={() => handleLabelToggle(label)}
onSelect={(e) => e.preventDefault()} // Prevent dropdown from closing
>
{label}
</DropdownMenuCheckboxItem>
))}
{!hasAvailableLabels && (
<div className="px-2 py-1.5 text-sm text-muted-foreground">No labels available</div>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Selected Labels Display - shown on separate row */}
{hasSelectedLabels && (
<div className="flex items-center gap-1 flex-wrap">
{selectedLabels
.slice(0, compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT)
.map((label) => (
<Badge
key={label}
variant="outline"
size="sm"
className="gap-1 cursor-pointer hover:bg-destructive/10 hover:border-destructive/50"
onClick={() => handleLabelToggle(label)}
>
{label}
<X className="h-2.5 w-2.5" />
</Badge>
))}
{selectedLabels.length >
(compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT) && (
<Badge variant="muted" size="sm">
+
{selectedLabels.length -
(compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT)}{' '}
more
</Badge>
)}
</div>
)}
</div>
);
}

View File

@@ -1,38 +1,100 @@
import { CircleDot, RefreshCw } from 'lucide-react'; import { CircleDot, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { IssuesStateFilter } from '../types';
import { IssuesFilterControls } from './issues-filter-controls';
interface IssuesListHeaderProps { interface IssuesListHeaderProps {
openCount: number; openCount: number;
closedCount: number; closedCount: number;
/** Total open issues count (unfiltered) - used to show "X of Y" when filtered */
totalOpenCount?: number;
/** Total closed issues count (unfiltered) - used to show "X of Y" when filtered */
totalClosedCount?: number;
/** Whether any filter is currently active */
hasActiveFilter?: boolean;
refreshing: boolean; refreshing: boolean;
onRefresh: () => void; onRefresh: () => void;
/** Whether the list is in compact mode (e.g., when detail panel is open) */
compact?: boolean;
/** Optional filter state and handlers - when provided, filter controls are rendered */
filterProps?: {
stateFilter: IssuesStateFilter;
selectedLabels: string[];
availableLabels: string[];
onStateFilterChange: (filter: IssuesStateFilter) => void;
onLabelsChange: (labels: string[]) => void;
};
} }
export function IssuesListHeader({ export function IssuesListHeader({
openCount, openCount,
closedCount, closedCount,
totalOpenCount,
totalClosedCount,
hasActiveFilter = false,
refreshing, refreshing,
onRefresh, onRefresh,
compact = false,
filterProps,
}: IssuesListHeaderProps) { }: IssuesListHeaderProps) {
const totalIssues = openCount + closedCount; const totalIssues = openCount + closedCount;
// Format the counts subtitle based on filter state
const getCountsSubtitle = () => {
if (totalIssues === 0) {
return hasActiveFilter ? 'No matching issues' : 'No issues found';
}
// When filters are active and we have total counts, show "X of Y" format
if (hasActiveFilter && totalOpenCount !== undefined && totalClosedCount !== undefined) {
const openText =
openCount === totalOpenCount
? `${openCount} open`
: `${openCount} of ${totalOpenCount} open`;
const closedText =
closedCount === totalClosedCount
? `${closedCount} closed`
: `${closedCount} of ${totalClosedCount} closed`;
return `${openText}, ${closedText}`;
}
// Default format when no filters active
return `${openCount} open, ${closedCount} closed`;
};
return ( return (
<div className="flex items-center justify-between p-4 border-b border-border"> <div className="border-b border-border">
<div className="flex items-center gap-3"> {/* Top row: Title and refresh button */}
<div className="p-2 rounded-lg bg-green-500/10"> <div className="flex items-center justify-between p-4 pb-2">
<CircleDot className="h-5 w-5 text-green-500" /> <div className="flex items-center gap-3">
</div> <div className="p-2 rounded-lg bg-green-500/10">
<div> <CircleDot className="h-5 w-5 text-green-500" />
<h1 className="text-lg font-bold">Issues</h1> </div>
<p className="text-xs text-muted-foreground"> <div>
{totalIssues === 0 ? 'No issues found' : `${openCount} open, ${closedCount} closed`} <h1 className="text-lg font-bold">Issues</h1>
</p> <p className="text-xs text-muted-foreground">{getCountsSubtitle()}</p>
</div>
</div> </div>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
</Button>
</div> </div>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} /> {/* Filter controls row (optional) */}
</Button> {filterProps && (
<div className="px-4 pb-3 pt-1">
<IssuesFilterControls
stateFilter={filterProps.stateFilter}
selectedLabels={filterProps.selectedLabels}
availableLabels={filterProps.availableLabels}
onStateFilterChange={filterProps.onStateFilterChange}
onLabelsChange={filterProps.onLabelsChange}
disabled={refreshing}
compact={compact}
/>
</div>
)}
</div> </div>
); );
} }

View File

@@ -1,3 +1,4 @@
export { useGithubIssues } from './use-github-issues'; export { useGithubIssues } from './use-github-issues';
export { useIssueValidation } from './use-issue-validation'; export { useIssueValidation } from './use-issue-validation';
export { useIssueComments } from './use-issue-comments'; export { useIssueComments } from './use-issue-comments';
export { useIssuesFilter } from './use-issues-filter';

View File

@@ -0,0 +1,240 @@
import { useMemo } from 'react';
import type { GitHubIssue, StoredValidation } from '@/lib/electron';
import type { IssuesFilterState, IssuesFilterResult, IssuesValidationStatus } from '../types';
import { isValidationStale } from '../utils';
/**
* Determines the validation status of an issue based on its cached validation.
*/
function getValidationStatus(
issueNumber: number,
cachedValidations: Map<number, StoredValidation>
): IssuesValidationStatus | null {
const validation = cachedValidations.get(issueNumber);
if (!validation) {
return 'not_validated';
}
if (isValidationStale(validation.validatedAt)) {
return 'stale';
}
return 'validated';
}
/**
* Checks if a search query matches an issue's searchable content.
* Searches through title and body (case-insensitive).
*/
function matchesSearchQuery(issue: GitHubIssue, normalizedQuery: string): boolean {
if (!normalizedQuery) return true;
const titleMatch = issue.title?.toLowerCase().includes(normalizedQuery);
const bodyMatch = issue.body?.toLowerCase().includes(normalizedQuery);
return titleMatch || bodyMatch;
}
/**
* Checks if an issue matches the state filter (open/closed/all).
* Note: GitHub CLI returns state in uppercase (OPEN/CLOSED), so we compare case-insensitively.
*/
function matchesStateFilter(
issue: GitHubIssue,
stateFilter: IssuesFilterState['stateFilter']
): boolean {
if (stateFilter === 'all') return true;
return issue.state.toLowerCase() === stateFilter;
}
/**
* Checks if an issue matches any of the selected labels.
* Returns true if no labels are selected (no filter) or if any selected label matches.
*/
function matchesLabels(issue: GitHubIssue, selectedLabels: string[]): boolean {
if (selectedLabels.length === 0) return true;
const issueLabels = issue.labels.map((l) => l.name);
return selectedLabels.some((label) => issueLabels.includes(label));
}
/**
* Checks if an issue matches any of the selected assignees.
* Returns true if no assignees are selected (no filter) or if any selected assignee matches.
*/
function matchesAssignees(issue: GitHubIssue, selectedAssignees: string[]): boolean {
if (selectedAssignees.length === 0) return true;
const issueAssignees = issue.assignees?.map((a) => a.login) ?? [];
return selectedAssignees.some((assignee) => issueAssignees.includes(assignee));
}
/**
* Checks if an issue matches any of the selected milestones.
* Returns true if no milestones are selected (no filter) or if any selected milestone matches.
* Note: GitHub issues may not have milestone data in the current schema, this is a placeholder.
*/
function matchesMilestones(issue: GitHubIssue, selectedMilestones: string[]): boolean {
if (selectedMilestones.length === 0) return true;
// GitHub issues in the current schema don't have milestone field
// This is a placeholder for future milestone support
// For now, issues with no milestone won't match if a milestone filter is active
return false;
}
/**
* Checks if an issue matches the validation status filter.
*/
function matchesValidationStatus(
issue: GitHubIssue,
validationStatusFilter: IssuesValidationStatus | null,
cachedValidations: Map<number, StoredValidation>
): boolean {
if (!validationStatusFilter) return true;
const status = getValidationStatus(issue.number, cachedValidations);
return status === validationStatusFilter;
}
/**
* Extracts all unique labels from a list of issues.
*/
function extractAvailableLabels(issues: GitHubIssue[]): string[] {
const labelsSet = new Set<string>();
for (const issue of issues) {
for (const label of issue.labels) {
labelsSet.add(label.name);
}
}
return Array.from(labelsSet).sort();
}
/**
* Extracts all unique assignees from a list of issues.
*/
function extractAvailableAssignees(issues: GitHubIssue[]): string[] {
const assigneesSet = new Set<string>();
for (const issue of issues) {
for (const assignee of issue.assignees ?? []) {
assigneesSet.add(assignee.login);
}
}
return Array.from(assigneesSet).sort();
}
/**
* Extracts all unique milestones from a list of issues.
* Note: Currently returns empty array as milestone is not in the GitHubIssue schema.
*/
function extractAvailableMilestones(_issues: GitHubIssue[]): string[] {
// GitHub issues in the current schema don't have milestone field
// This is a placeholder for future milestone support
return [];
}
/**
* Determines if any filter is currently active.
*/
function hasActiveFilterCheck(filterState: IssuesFilterState): boolean {
const {
searchQuery,
stateFilter,
selectedLabels,
selectedAssignees,
selectedMilestones,
validationStatusFilter,
} = filterState;
// Note: stateFilter 'open' is the default, so we consider it "not active" for UI purposes
// Only 'closed' or 'all' are considered active filters
const hasStateFilter = stateFilter !== 'open';
const hasSearchQuery = searchQuery.trim().length > 0;
const hasLabelFilter = selectedLabels.length > 0;
const hasAssigneeFilter = selectedAssignees.length > 0;
const hasMilestoneFilter = selectedMilestones.length > 0;
const hasValidationFilter = validationStatusFilter !== null;
return (
hasSearchQuery ||
hasStateFilter ||
hasLabelFilter ||
hasAssigneeFilter ||
hasMilestoneFilter ||
hasValidationFilter
);
}
/**
* Hook to filter GitHub issues based on the current filter state.
*
* This hook follows the same pattern as useGraphFilter but is tailored for GitHub issues.
* It computes matched issues and extracts available filter options from all issues.
*
* @param issues - Combined array of all issues (open + closed) to filter
* @param filterState - Current filter state including search, labels, assignees, etc.
* @param cachedValidations - Map of issue numbers to their cached validation results
* @returns Filter result containing matched issue numbers and available filter options
*/
export function useIssuesFilter(
issues: GitHubIssue[],
filterState: IssuesFilterState,
cachedValidations: Map<number, StoredValidation> = new Map()
): IssuesFilterResult {
const {
searchQuery,
stateFilter,
selectedLabels,
selectedAssignees,
selectedMilestones,
validationStatusFilter,
} = filterState;
return useMemo(() => {
// Extract available options from all issues (for filter dropdown population)
const availableLabels = extractAvailableLabels(issues);
const availableAssignees = extractAvailableAssignees(issues);
const availableMilestones = extractAvailableMilestones(issues);
// Check if any filter is active
const hasActiveFilter = hasActiveFilterCheck(filterState);
// Normalize search query for case-insensitive matching
const normalizedQuery = searchQuery.toLowerCase().trim();
// Filter issues based on all criteria - return matched issues directly
// This eliminates the redundant O(n) filtering operation in the consuming component
const matchedIssues: GitHubIssue[] = [];
for (const issue of issues) {
// All conditions must be true for a match
const matchesAllFilters =
matchesSearchQuery(issue, normalizedQuery) &&
matchesStateFilter(issue, stateFilter) &&
matchesLabels(issue, selectedLabels) &&
matchesAssignees(issue, selectedAssignees) &&
matchesMilestones(issue, selectedMilestones) &&
matchesValidationStatus(issue, validationStatusFilter, cachedValidations);
if (matchesAllFilters) {
matchedIssues.push(issue);
}
}
return {
matchedIssues,
availableLabels,
availableAssignees,
availableMilestones,
hasActiveFilter,
matchedCount: matchedIssues.length,
};
}, [
issues,
searchQuery,
stateFilter,
selectedLabels,
selectedAssignees,
selectedMilestones,
validationStatusFilter,
cachedValidations,
]);
}

View File

@@ -1,6 +1,111 @@
import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron'; import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron';
import type { ModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types'; import type { ModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types';
// ============================================================================
// Issues Filter State Types
// ============================================================================
/**
* Available sort columns for issues list
*/
export const ISSUES_SORT_COLUMNS = [
'title',
'created_at',
'updated_at',
'comments',
'number',
] as const;
export type IssuesSortColumn = (typeof ISSUES_SORT_COLUMNS)[number];
/**
* Sort direction options
*/
export type IssuesSortDirection = 'asc' | 'desc';
/**
* Available issue state filter values
*/
export const ISSUES_STATE_FILTER_OPTIONS = ['open', 'closed', 'all'] as const;
export type IssuesStateFilter = (typeof ISSUES_STATE_FILTER_OPTIONS)[number];
/**
* Validation status filter values for filtering issues by validation state
*/
export const ISSUES_VALIDATION_STATUS_OPTIONS = ['validated', 'not_validated', 'stale'] as const;
export type IssuesValidationStatus = (typeof ISSUES_VALIDATION_STATUS_OPTIONS)[number];
/**
* Sort configuration for issues list
*/
export interface IssuesSortConfig {
column: IssuesSortColumn;
direction: IssuesSortDirection;
}
/**
* Main filter state interface for the GitHub Issues view
*
* This interface defines all filterable/sortable state for the issues list.
* It follows the same pattern as GraphFilterState but is tailored for GitHub issues.
*/
export interface IssuesFilterState {
/** Search query for filtering by issue title or body */
searchQuery: string;
/** Filter by issue state (open/closed/all) */
stateFilter: IssuesStateFilter;
/** Filter by selected labels (matches any) */
selectedLabels: string[];
/** Filter by selected assignees (matches any) */
selectedAssignees: string[];
/** Filter by selected milestones (matches any) */
selectedMilestones: string[];
/** Filter by validation status */
validationStatusFilter: IssuesValidationStatus | null;
/** Current sort configuration */
sortConfig: IssuesSortConfig;
}
/**
* Result of applying filters to the issues list
*/
export interface IssuesFilterResult {
/** Array of GitHubIssue objects that match the current filters */
matchedIssues: GitHubIssue[];
/** Available labels from all issues (for filter dropdown population) */
availableLabels: string[];
/** Available assignees from all issues (for filter dropdown population) */
availableAssignees: string[];
/** Available milestones from all issues (for filter dropdown population) */
availableMilestones: string[];
/** Whether any filter is currently active */
hasActiveFilter: boolean;
/** Total count of matched issues */
matchedCount: number;
}
/**
* Default values for IssuesFilterState
*/
export const DEFAULT_ISSUES_FILTER_STATE: IssuesFilterState = {
searchQuery: '',
stateFilter: 'open',
selectedLabels: [],
selectedAssignees: [],
selectedMilestones: [],
validationStatusFilter: null,
sortConfig: {
column: 'updated_at',
direction: 'desc',
},
};
// ============================================================================
// Component Props Types
// ============================================================================
export interface IssueRowProps { export interface IssueRowProps {
issue: GitHubIssue; issue: GitHubIssue;
isSelected: boolean; isSelected: boolean;

View File

@@ -4,6 +4,10 @@ import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import {
HeaderActionsPanel,
HeaderActionsPanelTrigger,
} from '@/components/ui/header-actions-panel';
import { import {
RefreshCw, RefreshCw,
FileText, FileText,
@@ -60,6 +64,9 @@ export function MemoryView() {
const [newMemoryName, setNewMemoryName] = useState(''); const [newMemoryName, setNewMemoryName] = useState('');
const [newMemoryContent, setNewMemoryContent] = useState(''); const [newMemoryContent, setNewMemoryContent] = useState('');
// Actions panel state (for tablet/mobile)
const [showActionsPanel, setShowActionsPanel] = useState(false);
// Get memory directory path // Get memory directory path
const getMemoryPath = useCallback(() => { const getMemoryPath = useCallback(() => {
if (!currentProject) return null; if (!currentProject) return null;
@@ -310,27 +317,66 @@ export function MemoryView() {
</p> </p>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex items-center gap-2">
<Button {/* Desktop: show actions inline */}
variant="outline" <div className="hidden lg:flex gap-2">
size="sm" <Button
onClick={loadMemoryFiles} variant="outline"
data-testid="refresh-memory-button" size="sm"
> onClick={loadMemoryFiles}
<RefreshCw className="w-4 h-4 mr-2" /> data-testid="refresh-memory-button"
Refresh >
</Button> <RefreshCw className="w-4 h-4 mr-2" />
<Button Refresh
size="sm" </Button>
onClick={() => setIsCreateMemoryOpen(true)} <Button
data-testid="create-memory-button" size="sm"
> onClick={() => setIsCreateMemoryOpen(true)}
<FilePlus className="w-4 h-4 mr-2" /> data-testid="create-memory-button"
Create Memory File >
</Button> <FilePlus className="w-4 h-4 mr-2" />
Create Memory File
</Button>
</div>
{/* Tablet/Mobile: show trigger for actions panel */}
<HeaderActionsPanelTrigger
isOpen={showActionsPanel}
onToggle={() => setShowActionsPanel(!showActionsPanel)}
/>
</div> </div>
</div> </div>
{/* Actions Panel (tablet/mobile) */}
<HeaderActionsPanel
isOpen={showActionsPanel}
onClose={() => setShowActionsPanel(false)}
title="Memory Actions"
>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => {
loadMemoryFiles();
setShowActionsPanel(false);
}}
data-testid="refresh-memory-button-mobile"
>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
<Button
className="w-full justify-start"
onClick={() => {
setIsCreateMemoryOpen(true);
setShowActionsPanel(false);
}}
data-testid="create-memory-button-mobile"
>
<FilePlus className="w-4 h-4 mr-2" />
Create Memory File
</Button>
</HeaderActionsPanel>
{/* Main content area with file list and editor */} {/* Main content area with file list and editor */}
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">
{/* Left Panel - File List */} {/* Left Panel - File List */}

View File

@@ -0,0 +1,272 @@
/**
* Notifications View - Full page view for all notifications
*/
import { useEffect, useCallback } from 'react';
import { useAppStore } from '@/store/app-store';
import { useNotificationsStore } from '@/store/notifications-store';
import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';
import { getHttpApiClient } from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Bell, Check, CheckCheck, Trash2, ExternalLink, Loader2 } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import type { Notification } from '@automaker/types';
/**
* Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
*/
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return 'just now';
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
return date.toLocaleDateString();
}
export function NotificationsView() {
const { currentProject } = useAppStore();
const projectPath = currentProject?.path ?? null;
const navigate = useNavigate();
const {
notifications,
unreadCount,
isLoading,
error,
setNotifications,
setUnreadCount,
markAsRead,
dismissNotification,
markAllAsRead,
dismissAll,
} = useNotificationsStore();
// Load notifications when project changes
useLoadNotifications(projectPath);
// Subscribe to real-time notification events
useNotificationEvents(projectPath);
const handleMarkAsRead = useCallback(
async (notificationId: string) => {
if (!projectPath) return;
// Optimistic update
markAsRead(notificationId);
// Sync with server
const api = getHttpApiClient();
await api.notifications.markAsRead(projectPath, notificationId);
},
[projectPath, markAsRead]
);
const handleDismiss = useCallback(
async (notificationId: string) => {
if (!projectPath) return;
// Optimistic update
dismissNotification(notificationId);
// Sync with server
const api = getHttpApiClient();
await api.notifications.dismiss(projectPath, notificationId);
},
[projectPath, dismissNotification]
);
const handleMarkAllAsRead = useCallback(async () => {
if (!projectPath) return;
// Optimistic update
markAllAsRead();
// Sync with server
const api = getHttpApiClient();
await api.notifications.markAsRead(projectPath);
}, [projectPath, markAllAsRead]);
const handleDismissAll = useCallback(async () => {
if (!projectPath) return;
// Optimistic update
dismissAll();
// Sync with server
const api = getHttpApiClient();
await api.notifications.dismiss(projectPath);
}, [projectPath, dismissAll]);
const handleNotificationClick = useCallback(
(notification: Notification) => {
// Mark as read
handleMarkAsRead(notification.id);
// Navigate to the relevant view based on notification type
if (notification.featureId) {
// Navigate to board view - feature will be selected
navigate({ to: '/board' });
}
},
[handleMarkAsRead, navigate]
);
const getNotificationIcon = (type: string) => {
switch (type) {
case 'feature_waiting_approval':
return <Bell className="h-5 w-5 text-yellow-500" />;
case 'feature_verified':
return <Check className="h-5 w-5 text-green-500" />;
case 'spec_regeneration_complete':
return <Check className="h-5 w-5 text-blue-500" />;
case 'agent_complete':
return <Check className="h-5 w-5 text-purple-500" />;
default:
return <Bell className="h-5 w-5" />;
}
};
if (!projectPath) {
return (
<div className="flex flex-1 flex-col items-center justify-center p-8">
<Bell className="h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">Select a project to view notifications</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex flex-1 flex-col items-center justify-center p-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground mt-4">Loading notifications...</p>
</div>
);
}
if (error) {
return (
<div className="flex flex-1 flex-col items-center justify-center p-8">
<p className="text-destructive">{error}</p>
</div>
);
}
return (
<div className="flex flex-1 flex-col p-6 overflow-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Notifications</h1>
<p className="text-muted-foreground">
{unreadCount > 0 ? `${unreadCount} unread` : 'All caught up!'}
</p>
</div>
{notifications.length > 0 && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleMarkAllAsRead}
disabled={unreadCount === 0}
>
<CheckCheck className="h-4 w-4 mr-2" />
Mark all as read
</Button>
<Button variant="outline" size="sm" onClick={handleDismissAll}>
<Trash2 className="h-4 w-4 mr-2" />
Dismiss all
</Button>
</div>
)}
</div>
{notifications.length === 0 ? (
<Card className="flex-1">
<CardContent className="flex flex-col items-center justify-center h-full min-h-[300px]">
<Bell className="h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground text-lg">No notifications</p>
<p className="text-muted-foreground text-sm mt-2">
Notifications will appear here when features are ready for review or operations
complete.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{notifications.map((notification) => (
<Card
key={notification.id}
className={`transition-colors cursor-pointer hover:bg-accent/50 ${
!notification.read ? 'border-primary/50 bg-primary/5' : ''
}`}
onClick={() => handleNotificationClick(notification)}
>
<CardContent className="flex items-start gap-4 p-4">
<div className="flex-shrink-0 mt-1">{getNotificationIcon(notification.type)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<CardTitle className="text-base">{notification.title}</CardTitle>
{!notification.read && (
<span className="h-2 w-2 rounded-full bg-primary flex-shrink-0" />
)}
</div>
<CardDescription className="mt-1">{notification.message}</CardDescription>
<p className="text-xs text-muted-foreground mt-2">
{formatRelativeTime(new Date(notification.createdAt))}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{!notification.read && (
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleMarkAsRead(notification.id);
}}
title="Mark as read"
>
<Check className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleDismiss(notification.id);
}}
title="Dismiss"
>
<Trash2 className="h-4 w-4" />
</Button>
{notification.featureId && (
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleNotificationClick(notification);
}}
title="Go to feature"
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,122 @@
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { PROJECT_SETTINGS_NAV_ITEMS } from '../config/navigation';
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
interface ProjectSettingsNavigationProps {
activeSection: ProjectSettingsViewId;
onNavigate: (sectionId: ProjectSettingsViewId) => void;
isOpen?: boolean;
onClose?: () => void;
}
export function ProjectSettingsNavigation({
activeSection,
onNavigate,
isOpen = true,
onClose,
}: ProjectSettingsNavigationProps) {
return (
<>
{/* Mobile backdrop overlay - only shown when isOpen is true on mobile */}
{isOpen && (
<div
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={onClose}
data-testid="project-settings-nav-backdrop"
/>
)}
{/* Navigation sidebar */}
<nav
className={cn(
// Mobile: fixed position overlay with slide transition from right
'fixed inset-y-0 right-0 w-72 z-30',
'transition-transform duration-200 ease-out',
// Hide on mobile when closed, show when open
isOpen ? 'translate-x-0' : 'translate-x-full',
// Desktop: relative position in layout, always visible
'lg:relative lg:w-64 lg:z-auto lg:translate-x-0',
'shrink-0 overflow-y-auto',
'border-l border-border/50 lg:border-l-0 lg:border-r',
'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl',
// Desktop background
'lg:from-card/80 lg:via-card/60 lg:to-card/40'
)}
>
{/* Mobile close button */}
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-border/50">
<span className="text-sm font-semibold text-foreground">Navigation</span>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
aria-label="Close navigation menu"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="sticky top-0 p-4 space-y-1">
{PROJECT_SETTINGS_NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = activeSection === item.id;
const isDanger = item.id === 'danger';
return (
<button
key={item.id}
onClick={() => onNavigate(item.id)}
className={cn(
'group w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ease-out text-left relative overflow-hidden',
isActive
? [
isDanger
? 'bg-gradient-to-r from-red-500/15 via-red-500/10 to-red-600/5'
: 'bg-gradient-to-r from-brand-500/15 via-brand-500/10 to-brand-600/5',
'text-foreground',
isDanger ? 'border border-red-500/25' : 'border border-brand-500/25',
isDanger ? 'shadow-sm shadow-red-500/5' : 'shadow-sm shadow-brand-500/5',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
],
'hover:scale-[1.01] active:scale-[0.98]'
)}
>
{/* Active indicator bar */}
{isActive && (
<div
className={cn(
'absolute inset-y-0 left-0 w-0.5 rounded-r-full',
isDanger
? 'bg-gradient-to-b from-red-400 via-red-500 to-red-600'
: 'bg-gradient-to-b from-brand-400 via-brand-500 to-brand-600'
)}
/>
)}
<Icon
className={cn(
'w-4 h-4 shrink-0 transition-all duration-200',
isActive
? isDanger
? 'text-red-500'
: 'text-brand-500'
: isDanger
? 'group-hover:text-red-400 group-hover:scale-110'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
<span className={cn(isDanger && !isActive && 'text-red-400/70')}>{item.label}</span>
</button>
);
})}
</div>
</nav>
</>
);
}

View File

@@ -0,0 +1,16 @@
import type { LucideIcon } from 'lucide-react';
import { User, GitBranch, Palette, AlertTriangle } from 'lucide-react';
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
export interface ProjectNavigationItem {
id: ProjectSettingsViewId;
label: string;
icon: LucideIcon;
}
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
{ id: 'identity', label: 'Identity', icon: User },
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
{ id: 'theme', label: 'Theme', icon: Palette },
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
];

View File

@@ -0,0 +1 @@
export { useProjectSettingsView, type ProjectSettingsViewId } from './use-project-settings-view';

View File

@@ -0,0 +1,22 @@
import { useState, useCallback } from 'react';
export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'danger';
interface UseProjectSettingsViewOptions {
initialView?: ProjectSettingsViewId;
}
export function useProjectSettingsView({
initialView = 'identity',
}: UseProjectSettingsViewOptions = {}) {
const [activeView, setActiveView] = useState<ProjectSettingsViewId>(initialView);
const navigateTo = useCallback((viewId: ProjectSettingsViewId) => {
setActiveView(viewId);
}, []);
return {
activeView,
navigateTo,
};
}

View File

@@ -0,0 +1,6 @@
export { ProjectSettingsView } from './project-settings-view';
export { ProjectIdentitySection } from './project-identity-section';
export { ProjectThemeSection } from './project-theme-section';
export { WorktreePreferencesSection } from './worktree-preferences-section';
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
export { ProjectSettingsNavigation } from './components/project-settings-navigation';

View File

@@ -0,0 +1,225 @@
import { useState, useRef, useEffect } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Palette, Upload, X, ImageIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { IconPicker } from '@/components/layout/project-switcher/components/icon-picker';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import type { Project } from '@/lib/electron';
interface ProjectIdentitySectionProps {
project: Project;
}
export function ProjectIdentitySection({ project }: ProjectIdentitySectionProps) {
const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore();
const [projectName, setProjectNameLocal] = useState(project.name || '');
const [projectIcon, setProjectIconLocal] = useState<string | null>(project.icon || null);
const [customIconPath, setCustomIconPathLocal] = useState<string | null>(
project.customIconPath || null
);
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync local state when project changes
useEffect(() => {
setProjectNameLocal(project.name || '');
setProjectIconLocal(project.icon || null);
setCustomIconPathLocal(project.customIconPath || null);
}, [project]);
// Auto-save when values change
const handleNameChange = (name: string) => {
setProjectNameLocal(name);
if (name.trim() && name.trim() !== project.name) {
setProjectName(project.id, name.trim());
}
};
const handleIconChange = (icon: string | null) => {
setProjectIconLocal(icon);
setProjectIcon(project.id, icon);
};
const handleCustomIconChange = (path: string | null) => {
setCustomIconPathLocal(path);
setProjectCustomIcon(project.id, path);
// Clear Lucide icon when custom icon is set
if (path) {
setProjectIconLocal(null);
setProjectIcon(project.id, null);
}
};
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)) {
toast.error('Invalid file type', {
description: 'Please upload a PNG, JPG, GIF, or WebP image.',
});
return;
}
// Validate file size (max 2MB for icons)
if (file.size > 2 * 1024 * 1024) {
toast.error('File too large', {
description: 'Please upload an image smaller than 2MB.',
});
return;
}
setIsUploadingIcon(true);
try {
// Convert to base64
const reader = new FileReader();
reader.onload = async () => {
try {
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) {
handleCustomIconChange(result.path);
toast.success('Icon uploaded successfully');
} else {
toast.error('Failed to upload icon', {
description: result.error || 'Please try again.',
});
}
} catch (error) {
toast.error('Failed to upload icon', {
description: 'Network error. Please try again.',
});
} finally {
setIsUploadingIcon(false);
}
};
reader.onerror = () => {
toast.error('Failed to read file', {
description: 'Please try again with a different file.',
});
setIsUploadingIcon(false);
};
reader.readAsDataURL(file);
} catch {
toast.error('Failed to upload icon');
setIsUploadingIcon(false);
}
};
const handleRemoveCustomIcon = () => {
handleCustomIconChange(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Palette className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Project Identity</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Customize how your project appears in the sidebar and project switcher.
</p>
</div>
<div className="p-6 space-y-6">
{/* Project Name */}
<div className="space-y-2">
<Label htmlFor="project-name-settings">Project Name</Label>
<Input
id="project-name-settings"
value={projectName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Enter project name"
/>
</div>
{/* Project Icon */}
<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"
/>
<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={projectIcon} onSelectIcon={handleIconChange} />
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,174 @@
import { useState, useEffect } from 'react';
import { useAppStore } from '@/store/app-store';
import { Settings, FolderOpen, Menu, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ProjectIdentitySection } from './project-identity-section';
import { ProjectThemeSection } from './project-theme-section';
import { WorktreePreferencesSection } from './worktree-preferences-section';
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
import { useProjectSettingsView } from './hooks/use-project-settings-view';
import type { Project as ElectronProject } from '@/lib/electron';
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
const LG_BREAKPOINT = 1024;
// Convert to the shared types used by components
interface SettingsProject {
id: string;
name: string;
path: string;
theme?: string;
icon?: string | null;
customIconPath?: string | null;
}
export function ProjectSettingsView() {
const { currentProject, moveProjectToTrash } = useAppStore();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Use project settings view navigation hook
const { activeView, navigateTo } = useProjectSettingsView();
// Mobile navigation state - default to showing on desktop, hidden on mobile
const [showNavigation, setShowNavigation] = useState(() => {
if (typeof window !== 'undefined') {
return window.innerWidth >= LG_BREAKPOINT;
}
return true;
});
// Auto-close navigation on mobile when a section is selected
useEffect(() => {
if (typeof window !== 'undefined' && window.innerWidth < LG_BREAKPOINT) {
setShowNavigation(false);
}
}, [activeView]);
// Handle window resize to show/hide navigation appropriately
useEffect(() => {
const handleResize = () => {
if (window.innerWidth >= LG_BREAKPOINT) {
setShowNavigation(true);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Convert electron Project to settings-view Project type
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
if (!project) return null;
return {
id: project.id,
name: project.name,
path: project.path,
theme: project.theme,
icon: project.icon,
customIconPath: project.customIconPath,
};
};
const settingsProject = convertProject(currentProject);
// Render the active section based on current view
const renderActiveSection = () => {
if (!currentProject) return null;
switch (activeView) {
case 'identity':
return <ProjectIdentitySection project={currentProject} />;
case 'theme':
return <ProjectThemeSection project={currentProject} />;
case 'worktrees':
return <WorktreePreferencesSection project={currentProject} />;
case 'danger':
return (
<DangerZoneSection
project={settingsProject}
onDeleteClick={() => setShowDeleteDialog(true)}
/>
);
default:
return <ProjectIdentitySection project={currentProject} />;
}
};
// Show message if no project is selected
if (!currentProject) {
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="project-settings-view"
>
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center max-w-md">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-muted/50 flex items-center justify-center">
<FolderOpen className="w-8 h-8 text-muted-foreground/50" />
</div>
<h2 className="text-lg font-semibold text-foreground mb-2">No Project Selected</h2>
<p className="text-sm text-muted-foreground">
Select a project from the sidebar to configure project-specific settings.
</p>
</div>
</div>
</div>
);
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="project-settings-view"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
<Settings className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">Project Settings</h1>
<p className="text-sm text-muted-foreground">
Configure settings for {currentProject.name}
</p>
</div>
</div>
{/* Mobile menu button - far right */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowNavigation(!showNavigation)}
className="lg:hidden h-8 w-8 p-0"
aria-label={showNavigation ? 'Close navigation menu' : 'Open navigation menu'}
>
{showNavigation ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
</Button>
</div>
{/* Content Area with Sidebar */}
<div className="flex-1 flex overflow-hidden">
{/* Side Navigation */}
<ProjectSettingsNavigation
activeSection={activeView}
onNavigate={navigateTo}
isOpen={showNavigation}
onClose={() => setShowNavigation(false)}
/>
{/* Content Panel - Shows only the active section */}
<div className="flex-1 overflow-y-auto p-4 lg:p-8">
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
</div>
</div>
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
</div>
);
}

View File

@@ -0,0 +1,164 @@
import { useState } from 'react';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Palette, Moon, Sun } from 'lucide-react';
import { darkThemes, lightThemes, type Theme } from '@/config/theme-options';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type { Project } from '@/lib/electron';
interface ProjectThemeSectionProps {
project: Project;
}
export function ProjectThemeSection({ project }: ProjectThemeSectionProps) {
const { theme: globalTheme, setProjectTheme } = useAppStore();
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
const projectTheme = project.theme as Theme | undefined;
const hasCustomTheme = projectTheme !== undefined;
const effectiveTheme = projectTheme || globalTheme;
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
const handleThemeChange = (theme: Theme) => {
setProjectTheme(project.id, theme);
};
const handleUseGlobalTheme = (checked: boolean) => {
if (checked) {
// Clear project theme to use global
setProjectTheme(project.id, null);
} else {
// Set project theme to current global theme
setProjectTheme(project.id, globalTheme);
}
};
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Palette className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Theme</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Customize the theme for this project.
</p>
</div>
<div className="p-6 space-y-6">
{/* Use Global Theme Toggle */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="use-global-theme"
checked={!hasCustomTheme}
onCheckedChange={handleUseGlobalTheme}
className="mt-1"
data-testid="use-global-theme-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="use-global-theme"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Palette className="w-4 h-4 text-brand-500" />
Use Global Theme
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, this project will use the global theme setting. Disable to set a
project-specific theme.
</p>
</div>
</div>
{/* Theme Selection - only show if not using global theme */}
{hasCustomTheme && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Project Theme</Label>
{/* Dark/Light Tabs */}
<div className="flex gap-1 p-1 rounded-lg bg-accent/30">
<button
onClick={() => setActiveTab('dark')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200',
activeTab === 'dark'
? 'bg-brand-500 text-white shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Moon className="w-3.5 h-3.5" />
Dark
</button>
<button
onClick={() => setActiveTab('light')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200',
activeTab === 'light'
? 'bg-brand-500 text-white shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Sun className="w-3.5 h-3.5" />
Light
</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{themesToShow.map(({ value, label, Icon, testId, color }) => {
const isActive = effectiveTheme === value;
return (
<button
key={value}
onClick={() => handleThemeChange(value)}
className={cn(
'group flex items-center justify-center gap-2.5 px-4 py-3.5 rounded-xl',
'text-sm font-medium transition-all duration-200 ease-out',
isActive
? [
'bg-gradient-to-br from-brand-500/15 to-brand-600/10',
'border-2 border-brand-500/40',
'text-foreground',
'shadow-md shadow-brand-500/10',
]
: [
'bg-accent/30 hover:bg-accent/50',
'border border-border/50 hover:border-border',
'text-muted-foreground hover:text-foreground',
'hover:shadow-sm',
],
'hover:scale-[1.02] active:scale-[0.98]'
)}
data-testid={`project-${testId}`}
>
<Icon className="w-4 h-4 transition-all duration-200" style={{ color }} />
<span>{label}</span>
</button>
);
})}
</div>
</div>
)}
{/* Info when using global theme */}
{!hasCustomTheme && (
<div className="rounded-xl border border-border/30 bg-muted/30 p-4">
<p className="text-sm text-muted-foreground">
This project is using the global theme:{' '}
<span className="font-medium text-foreground">{globalTheme}</span>
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,478 @@
import { useState, useEffect, useCallback } from 'react';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
import {
GitBranch,
Terminal,
FileCode,
Save,
RotateCcw,
Trash2,
Loader2,
PanelBottomClose,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Project } from '@/lib/electron';
interface WorktreePreferencesSectionProps {
project: Project;
}
interface InitScriptResponse {
success: boolean;
exists: boolean;
content: string;
path: string;
error?: string;
}
export function WorktreePreferencesSection({ project }: WorktreePreferencesSectionProps) {
const globalUseWorktrees = useAppStore((s) => s.useWorktrees);
const getProjectUseWorktrees = useAppStore((s) => s.getProjectUseWorktrees);
const setProjectUseWorktrees = useAppStore((s) => s.setProjectUseWorktrees);
const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator);
const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator);
const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch);
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
// Get effective worktrees setting (project override or global fallback)
const projectUseWorktrees = getProjectUseWorktrees(project.path);
const effectiveUseWorktrees = projectUseWorktrees ?? globalUseWorktrees;
const [scriptContent, setScriptContent] = useState('');
const [originalContent, setOriginalContent] = useState('');
const [scriptExists, setScriptExists] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Get the current settings for this project
const showIndicator = getShowInitScriptIndicator(project.path);
const defaultDeleteBranch = getDefaultDeleteBranch(project.path);
const autoDismiss = getAutoDismissInitScriptIndicator(project.path);
// Check if there are unsaved changes
const hasChanges = scriptContent !== originalContent;
// Load project settings (including useWorktrees) when project changes
useEffect(() => {
let isCancelled = false;
const currentPath = project.path;
const loadProjectSettings = async () => {
try {
const httpClient = getHttpApiClient();
const response = await httpClient.settings.getProject(currentPath);
// Avoid updating state if component unmounted or project changed
if (isCancelled) return;
if (response.success && response.settings) {
// Sync useWorktrees to store if it has a value
if (response.settings.useWorktrees !== undefined) {
setProjectUseWorktrees(currentPath, response.settings.useWorktrees);
}
// Also sync other settings to store
if (response.settings.showInitScriptIndicator !== undefined) {
setShowInitScriptIndicator(currentPath, response.settings.showInitScriptIndicator);
}
if (response.settings.defaultDeleteBranchWithWorktree !== undefined) {
setDefaultDeleteBranch(currentPath, response.settings.defaultDeleteBranchWithWorktree);
}
if (response.settings.autoDismissInitScriptIndicator !== undefined) {
setAutoDismissInitScriptIndicator(
currentPath,
response.settings.autoDismissInitScriptIndicator
);
}
}
} catch (error) {
if (!isCancelled) {
console.error('Failed to load project settings:', error);
}
}
};
loadProjectSettings();
return () => {
isCancelled = true;
};
}, [
project.path,
setProjectUseWorktrees,
setShowInitScriptIndicator,
setDefaultDeleteBranch,
setAutoDismissInitScriptIndicator,
]);
// Load init script content when project changes
useEffect(() => {
let isCancelled = false;
const currentPath = project.path;
const loadInitScript = async () => {
setIsLoading(true);
try {
const response = await apiGet<InitScriptResponse>(
`/api/worktree/init-script?projectPath=${encodeURIComponent(currentPath)}`
);
// Avoid updating state if component unmounted or project changed
if (isCancelled) return;
if (response.success) {
const content = response.content || '';
setScriptContent(content);
setOriginalContent(content);
setScriptExists(response.exists);
}
} catch (error) {
if (!isCancelled) {
console.error('Failed to load init script:', error);
}
} finally {
if (!isCancelled) {
setIsLoading(false);
}
}
};
loadInitScript();
return () => {
isCancelled = true;
};
}, [project.path]);
// Save script
const handleSave = useCallback(async () => {
setIsSaving(true);
try {
const response = await apiPut<{ success: boolean; error?: string }>(
'/api/worktree/init-script',
{
projectPath: project.path,
content: scriptContent,
}
);
if (response.success) {
setOriginalContent(scriptContent);
setScriptExists(true);
toast.success('Init script saved');
} else {
toast.error('Failed to save init script', {
description: response.error,
});
}
} catch (error) {
console.error('Failed to save init script:', error);
toast.error('Failed to save init script');
} finally {
setIsSaving(false);
}
}, [project.path, scriptContent]);
// Reset to original content
const handleReset = useCallback(() => {
setScriptContent(originalContent);
}, [originalContent]);
// Delete script
const handleDelete = useCallback(async () => {
setIsDeleting(true);
try {
const response = await apiDelete<{ success: boolean; error?: string }>(
'/api/worktree/init-script',
{
body: { projectPath: project.path },
}
);
if (response.success) {
setScriptContent('');
setOriginalContent('');
setScriptExists(false);
toast.success('Init script deleted');
} else {
toast.error('Failed to delete init script', {
description: response.error,
});
}
} catch (error) {
console.error('Failed to delete init script:', error);
toast.error('Failed to delete init script');
} finally {
setIsDeleting(false);
}
}, [project.path]);
// Handle content change (no auto-save)
const handleContentChange = useCallback((value: string) => {
setScriptContent(value);
}, []);
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<GitBranch className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Worktree Preferences
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure worktree behavior for this project.
</p>
</div>
<div className="p-6 space-y-5">
{/* Enable Git Worktree Isolation Toggle */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="project-use-worktrees"
checked={effectiveUseWorktrees}
onCheckedChange={async (checked) => {
const value = checked === true;
setProjectUseWorktrees(project.path, value);
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
useWorktrees: value,
});
} catch (error) {
console.error('Failed to persist useWorktrees:', error);
}
}}
className="mt-1"
data-testid="project-use-worktrees-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="project-use-worktrees"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Enable Git Worktree Isolation
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Creates isolated git branches for each feature in this project. When disabled, agents
work directly in the main project directory.
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Show Init Script Indicator Toggle */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="show-init-script-indicator"
checked={showIndicator}
onCheckedChange={async (checked) => {
const value = checked === true;
setShowInitScriptIndicator(project.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
showInitScriptIndicator: value,
});
} catch (error) {
console.error('Failed to persist showInitScriptIndicator:', error);
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="show-init-script-indicator"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<PanelBottomClose className="w-4 h-4 text-brand-500" />
Show Init Script Indicator
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Display a floating panel in the bottom-right corner showing init script execution
status and output when a worktree is created.
</p>
</div>
</div>
{/* Auto-dismiss Init Script Indicator Toggle */}
{showIndicator && (
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 ml-6">
<Checkbox
id="auto-dismiss-indicator"
checked={autoDismiss}
onCheckedChange={async (checked) => {
const value = checked === true;
setAutoDismissInitScriptIndicator(project.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
autoDismissInitScriptIndicator: value,
});
} catch (error) {
console.error('Failed to persist autoDismissInitScriptIndicator:', error);
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="auto-dismiss-indicator"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
Auto-dismiss After Completion
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Automatically hide the indicator 5 seconds after the script completes.
</p>
</div>
</div>
)}
{/* Default Delete Branch Toggle */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="default-delete-branch"
checked={defaultDeleteBranch}
onCheckedChange={async (checked) => {
const value = checked === true;
setDefaultDeleteBranch(project.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
defaultDeleteBranch: value,
});
} catch (error) {
console.error('Failed to persist defaultDeleteBranch:', error);
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="default-delete-branch"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Trash2 className="w-4 h-4 text-brand-500" />
Delete Branch by Default
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When deleting a worktree, automatically check the "Also delete the branch" option.
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Init Script Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-brand-500" />
<Label className="text-foreground font-medium">Initialization Script</Label>
</div>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash
on Windows for cross-platform compatibility.
</p>
{/* File path indicator */}
<div className="flex items-center gap-2 text-xs text-muted-foreground/60">
<FileCode className="w-3.5 h-3.5" />
<code className="font-mono">.automaker/worktree-init.sh</code>
{hasChanges && <span className="text-amber-500 font-medium">(unsaved changes)</span>}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : (
<>
<ShellSyntaxEditor
value={scriptContent}
onChange={handleContentChange}
placeholder={`# Example initialization commands
npm install
# Or use pnpm
# pnpm install
# Copy environment file
# cp .env.example .env`}
minHeight="200px"
maxHeight="500px"
data-testid="init-script-editor"
/>
{/* Action buttons */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDelete}
disabled={!scriptExists || isSaving || isDeleting}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
{isDeleting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
Delete
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
{isSaving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Save className="w-3.5 h-3.5" />
)}
Save
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -6,7 +6,6 @@ import { useSettingsView, type SettingsViewId } from './settings-view/hooks';
import { NAV_ITEMS } from './settings-view/config/navigation'; import { NAV_ITEMS } from './settings-view/config/navigation';
import { SettingsHeader } from './settings-view/components/settings-header'; import { SettingsHeader } from './settings-view/components/settings-header';
import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog'; import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog';
import { DeleteProjectDialog } from './settings-view/components/delete-project-dialog';
import { SettingsNavigation } from './settings-view/components/settings-navigation'; import { SettingsNavigation } from './settings-view/components/settings-navigation';
import { ApiKeysSection } from './settings-view/api-keys/api-keys-section'; import { ApiKeysSection } from './settings-view/api-keys/api-keys-section';
import { ModelDefaultsSection } from './settings-view/model-defaults'; import { ModelDefaultsSection } from './settings-view/model-defaults';
@@ -16,7 +15,6 @@ import { AudioSection } from './settings-view/audio/audio-section';
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section'; import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section'; import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
import { WorktreesSection } from './settings-view/worktrees'; import { WorktreesSection } from './settings-view/worktrees';
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
import { AccountSection } from './settings-view/account'; import { AccountSection } from './settings-view/account';
import { SecuritySection } from './settings-view/security'; import { SecuritySection } from './settings-view/security';
import { DeveloperSection } from './settings-view/developer/developer-section'; import { DeveloperSection } from './settings-view/developer/developer-section';
@@ -30,8 +28,7 @@ import { MCPServersSection } from './settings-view/mcp-servers';
import { PromptCustomizationSection } from './settings-view/prompts'; import { PromptCustomizationSection } from './settings-view/prompts';
import { EventHooksSection } from './settings-view/event-hooks'; import { EventHooksSection } from './settings-view/event-hooks';
import { ImportExportDialog } from './settings-view/components/import-export-dialog'; import { ImportExportDialog } from './settings-view/components/import-export-dialog';
import type { Project as SettingsProject, Theme } from './settings-view/shared/types'; import type { Theme } from './settings-view/shared/types';
import type { Project as ElectronProject } from '@/lib/electron';
// Breakpoint constant for mobile (matches Tailwind lg breakpoint) // Breakpoint constant for mobile (matches Tailwind lg breakpoint)
const LG_BREAKPOINT = 1024; const LG_BREAKPOINT = 1024;
@@ -40,7 +37,6 @@ export function SettingsView() {
const { const {
theme, theme,
setTheme, setTheme,
setProjectTheme,
defaultSkipTests, defaultSkipTests,
setDefaultSkipTests, setDefaultSkipTests,
enableDependencyBlocking, enableDependencyBlocking,
@@ -54,7 +50,6 @@ export function SettingsView() {
muteDoneSound, muteDoneSound,
setMuteDoneSound, setMuteDoneSound,
currentProject, currentProject,
moveProjectToTrash,
defaultPlanningMode, defaultPlanningMode,
setDefaultPlanningMode, setDefaultPlanningMode,
defaultRequirePlanApproval, defaultRequirePlanApproval,
@@ -69,34 +64,8 @@ export function SettingsView() {
setSkipSandboxWarning, setSkipSandboxWarning,
} = useAppStore(); } = useAppStore();
// Convert electron Project to settings-view Project type // Global theme (project-specific themes are managed in Project Settings)
const convertProject = (project: ElectronProject | null): SettingsProject | null => { const globalTheme = theme as Theme;
if (!project) return null;
return {
id: project.id,
name: project.name,
path: project.path,
theme: project.theme as Theme | undefined,
icon: project.icon,
customIconPath: project.customIconPath,
};
};
const settingsProject = convertProject(currentProject);
// Compute the effective theme for the current project
const effectiveTheme = (settingsProject?.theme || theme) as Theme;
// Handler to set theme - always updates global theme (user's preference),
// and also sets per-project theme if a project is selected
const handleSetTheme = (newTheme: typeof theme) => {
// Always update global theme so user's preference persists across all projects
setTheme(newTheme);
// Also set per-project theme if a project is selected
if (currentProject) {
setProjectTheme(currentProject.id, newTheme);
}
};
// Get initial view from URL search params // Get initial view from URL search params
const { view: initialView } = useSearch({ from: '/settings' }); const { view: initialView } = useSearch({ from: '/settings' });
@@ -113,7 +82,6 @@ export function SettingsView() {
} }
}; };
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false); const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
const [showImportExportDialog, setShowImportExportDialog] = useState(false); const [showImportExportDialog, setShowImportExportDialog] = useState(false);
@@ -172,9 +140,8 @@ export function SettingsView() {
case 'appearance': case 'appearance':
return ( return (
<AppearanceSection <AppearanceSection
effectiveTheme={effectiveTheme as any} effectiveTheme={globalTheme}
currentProject={settingsProject as any} onThemeChange={(newTheme) => setTheme(newTheme as typeof theme)}
onThemeChange={(theme) => handleSetTheme(theme as any)}
/> />
); );
case 'terminal': case 'terminal':
@@ -223,13 +190,6 @@ export function SettingsView() {
); );
case 'developer': case 'developer':
return <DeveloperSection />; return <DeveloperSection />;
case 'danger':
return (
<DangerZoneSection
project={settingsProject}
onDeleteClick={() => setShowDeleteDialog(true)}
/>
);
default: default:
return <ApiKeysSection />; return <ApiKeysSection />;
} }
@@ -265,14 +225,6 @@ export function SettingsView() {
{/* Keyboard Map Dialog */} {/* Keyboard Map Dialog */}
<KeyboardMapDialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog} /> <KeyboardMapDialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog} />
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
{/* Import/Export Settings Dialog */} {/* Import/Export Settings Dialog */}
<ImportExportDialog open={showImportExportDialog} onOpenChange={setShowImportExportDialog} /> <ImportExportDialog open={showImportExportDialog} onOpenChange={setShowImportExportDialog} />
</div> </div>

View File

@@ -1,118 +1,20 @@
import { useState, useRef, useEffect } from 'react'; import { useState } from 'react';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input'; import { Palette, Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Palette, Moon, Sun, Upload, X, ImageIcon } from 'lucide-react';
import { darkThemes, lightThemes } from '@/config/theme-options'; import { darkThemes, lightThemes } from '@/config/theme-options';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store'; import type { Theme } from '../shared/types';
import { IconPicker } from '@/components/layout/project-switcher/components/icon-picker';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Theme, Project } from '../shared/types';
interface AppearanceSectionProps { interface AppearanceSectionProps {
effectiveTheme: Theme; effectiveTheme: Theme;
currentProject: Project | null;
onThemeChange: (theme: Theme) => void; onThemeChange: (theme: Theme) => void;
} }
export function AppearanceSection({ export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceSectionProps) {
effectiveTheme,
currentProject,
onThemeChange,
}: AppearanceSectionProps) {
const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore();
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark'); const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
const [projectName, setProjectNameLocal] = useState(currentProject?.name || '');
const [projectIcon, setProjectIconLocal] = useState<string | null>(currentProject?.icon || null);
const [customIconPath, setCustomIconPathLocal] = useState<string | null>(
currentProject?.customIconPath || null
);
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync local state when currentProject changes
useEffect(() => {
setProjectNameLocal(currentProject?.name || '');
setProjectIconLocal(currentProject?.icon || null);
setCustomIconPathLocal(currentProject?.customIconPath || null);
}, [currentProject]);
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes; const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
// Auto-save when values change
const handleNameChange = (name: string) => {
setProjectNameLocal(name);
if (currentProject && name.trim() && name.trim() !== currentProject.name) {
setProjectName(currentProject.id, name.trim());
}
};
const handleIconChange = (icon: string | null) => {
setProjectIconLocal(icon);
if (currentProject) {
setProjectIcon(currentProject.id, icon);
}
};
const handleCustomIconChange = (path: string | null) => {
setCustomIconPathLocal(path);
if (currentProject) {
setProjectCustomIcon(currentProject.id, path);
// Clear Lucide icon when custom icon is set
if (path) {
setProjectIconLocal(null);
setProjectIcon(currentProject.id, null);
}
}
};
const handleCustomIconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !currentProject) 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,
currentProject.path
);
if (result.success && result.path) {
handleCustomIconChange(result.path);
}
setIsUploadingIcon(false);
};
reader.readAsDataURL(file);
} catch {
setIsUploadingIcon(false);
}
};
const handleRemoveCustomIcon = () => {
handleCustomIconChange(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return ( return (
<div <div
className={cn( className={cn(
@@ -134,94 +36,10 @@ export function AppearanceSection({
</p> </p>
</div> </div>
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* Project Details Section */}
{currentProject && (
<div className="space-y-4 pb-6 border-b border-border/50">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="project-name-settings">Project Name</Label>
<Input
id="project-name-settings"
value={projectName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Enter project name"
/>
</div>
<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, currentProject.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"
/>
<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={projectIcon} onSelectIcon={handleIconChange} />
)}
</div>
</div>
</div>
)}
{/* Theme Section */} {/* Theme Section */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-foreground font-medium"> <Label className="text-foreground font-medium">Theme</Label>
Theme{' '}
<span className="text-muted-foreground font-normal">
{currentProject ? `(for ${currentProject.name})` : '(Global)'}
</span>
</Label>
{/* Dark/Light Tabs */} {/* Dark/Light Tabs */}
<div className="flex gap-1 p-1 rounded-lg bg-accent/30"> <div className="flex gap-1 p-1 rounded-lg bg-accent/30">
<button <button

View File

@@ -1,4 +1,4 @@
import { Settings, PanelLeft, PanelLeftClose, FileJson } from 'lucide-react'; import { Cog, Menu, X, FileJson } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -11,7 +11,7 @@ interface SettingsHeaderProps {
} }
export function SettingsHeader({ export function SettingsHeader({
title = 'Settings', title = 'Global Settings',
description = 'Configure your API keys and preferences', description = 'Configure your API keys and preferences',
showNavigation, showNavigation,
onToggleNavigation, onToggleNavigation,
@@ -28,6 +28,31 @@ export function SettingsHeader({
<div className="px-4 py-4 lg:px-8 lg:py-6"> <div className="px-4 py-4 lg:px-8 lg:py-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3 lg:gap-4"> <div className="flex items-center gap-3 lg:gap-4">
<div
className={cn(
'w-10 h-10 lg:w-12 lg:h-12 rounded-xl lg:rounded-2xl flex items-center justify-center',
'bg-gradient-to-br from-brand-500 to-brand-600',
'shadow-lg shadow-brand-500/25',
'ring-1 ring-white/10'
)}
>
<Cog className="w-5 h-5 lg:w-6 lg:h-6 text-white" />
</div>
<div>
<h1 className="text-xl lg:text-2xl font-bold text-foreground tracking-tight">
{title}
</h1>
<p className="text-xs lg:text-sm text-muted-foreground/80 mt-0.5">{description}</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Import/Export button */}
{onImportExportClick && (
<Button variant="outline" size="sm" onClick={onImportExportClick} className="gap-2">
<FileJson className="w-4 h-4" />
<span className="hidden sm:inline">Import / Export</span>
</Button>
)}
{/* Mobile menu toggle button - only visible on mobile */} {/* Mobile menu toggle button - only visible on mobile */}
{onToggleNavigation && ( {onToggleNavigation && (
<Button <Button
@@ -37,37 +62,10 @@ export function SettingsHeader({
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden" className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden"
aria-label={showNavigation ? 'Close navigation menu' : 'Open navigation menu'} aria-label={showNavigation ? 'Close navigation menu' : 'Open navigation menu'}
> >
{showNavigation ? ( {showNavigation ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
<PanelLeftClose className="w-5 h-5" />
) : (
<PanelLeft className="w-5 h-5" />
)}
</Button> </Button>
)} )}
<div
className={cn(
'w-10 h-10 lg:w-12 lg:h-12 rounded-xl lg:rounded-2xl flex items-center justify-center',
'bg-gradient-to-br from-brand-500 to-brand-600',
'shadow-lg shadow-brand-500/25',
'ring-1 ring-white/10'
)}
>
<Settings className="w-5 h-5 lg:w-6 lg:h-6 text-white" />
</div>
<div>
<h1 className="text-xl lg:text-2xl font-bold text-foreground tracking-tight">
{title}
</h1>
<p className="text-xs lg:text-sm text-muted-foreground/80 mt-0.5">{description}</p>
</div>
</div> </div>
{/* Import/Export button */}
{onImportExportClick && (
<Button variant="outline" size="sm" onClick={onImportExportClick} className="gap-2">
<FileJson className="w-4 h-4" />
<span className="hidden sm:inline">Import / Export</span>
</Button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { Project } from '@/lib/electron'; import type { Project } from '@/lib/electron';
import type { NavigationItem, NavigationGroup } from '../config/navigation'; import type { NavigationItem, NavigationGroup } from '../config/navigation';
import { GLOBAL_NAV_GROUPS, PROJECT_NAV_ITEMS } from '../config/navigation'; import { GLOBAL_NAV_GROUPS } from '../config/navigation';
import type { SettingsViewId } from '../hooks/use-settings-view'; import type { SettingsViewId } from '../hooks/use-settings-view';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import type { ModelProvider } from '@automaker/types'; import type { ModelProvider } from '@automaker/types';
@@ -210,15 +210,15 @@ export function SettingsNavigation({
{/* Navigation sidebar */} {/* Navigation sidebar */}
<nav <nav
className={cn( className={cn(
// Mobile: fixed position overlay with slide transition // Mobile: fixed position overlay with slide transition from right
'fixed inset-y-0 left-0 w-72 z-30', 'fixed inset-y-0 right-0 w-72 z-30',
'transition-transform duration-200 ease-out', 'transition-transform duration-200 ease-out',
// Hide on mobile when closed, show when open // Hide on mobile when closed, show when open
isOpen ? 'translate-x-0' : '-translate-x-full', isOpen ? 'translate-x-0' : 'translate-x-full',
// Desktop: relative position in layout, always visible // Desktop: relative position in layout, always visible
'lg:relative lg:w-64 lg:z-auto lg:translate-x-0', 'lg:relative lg:w-64 lg:z-auto lg:translate-x-0',
'shrink-0 overflow-y-auto', 'shrink-0 overflow-y-auto',
'border-r border-border/50', 'border-l border-border/50 lg:border-l-0 lg:border-r',
'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl', 'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl',
// Desktop background // Desktop background
'lg:from-card/80 lg:via-card/60 lg:to-card/40' 'lg:from-card/80 lg:via-card/60 lg:to-card/40'
@@ -272,31 +272,6 @@ export function SettingsNavigation({
</div> </div>
</div> </div>
))} ))}
{/* Project Settings - only show when a project is selected */}
{currentProject && (
<>
{/* Divider */}
<div className="my-3 border-t border-border/50" />
{/* Project Settings Label */}
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
Project Settings
</div>
{/* Project Settings Items */}
<div className="space-y-1">
{PROJECT_NAV_ITEMS.map((item) => (
<NavButton
key={item.id}
item={item}
isActive={activeSection === item.id}
onNavigate={onNavigate}
/>
))}
</div>
</>
)}
</div> </div>
</nav> </nav>
</> </>

View File

@@ -8,13 +8,11 @@ import {
Settings2, Settings2,
Volume2, Volume2,
FlaskConical, FlaskConical,
Trash2,
Workflow, Workflow,
Plug, Plug,
MessageSquareText, MessageSquareText,
User, User,
Shield, Shield,
Cpu,
GitBranch, GitBranch,
Code2, Code2,
Webhook, Webhook,
@@ -84,10 +82,5 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
// Flat list of all global nav items for backwards compatibility // Flat list of all global nav items for backwards compatibility
export const GLOBAL_NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_GROUPS.flatMap((group) => group.items); export const GLOBAL_NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_GROUPS.flatMap((group) => group.items);
// Project-specific settings - only visible when a project is selected
export const PROJECT_NAV_ITEMS: NavigationItem[] = [
{ id: 'danger', label: 'Danger Zone', icon: Trash2 },
];
// Legacy export for backwards compatibility // Legacy export for backwards compatibility
export const NAV_ITEMS: NavigationItem[] = [...GLOBAL_NAV_ITEMS, ...PROJECT_NAV_ITEMS]; export const NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_ITEMS;

View File

@@ -0,0 +1,341 @@
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
History,
RefreshCw,
Trash2,
Play,
ChevronDown,
ChevronRight,
CheckCircle,
XCircle,
Clock,
AlertCircle,
} from 'lucide-react';
import { useAppStore } from '@/store/app-store';
import type { StoredEventSummary, StoredEvent, EventHookTrigger } from '@automaker/types';
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
import { getHttpApiClient } from '@/lib/http-api-client';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
export function EventHistoryView() {
const currentProject = useAppStore((state) => state.currentProject);
const projectPath = currentProject?.path;
const [events, setEvents] = useState<StoredEventSummary[]>([]);
const [loading, setLoading] = useState(false);
const [expandedEvent, setExpandedEvent] = useState<string | null>(null);
const [expandedEventData, setExpandedEventData] = useState<StoredEvent | null>(null);
const [replayingEvent, setReplayingEvent] = useState<string | null>(null);
const [clearDialogOpen, setClearDialogOpen] = useState(false);
const loadEvents = useCallback(async () => {
if (!projectPath) return;
setLoading(true);
try {
const api = getHttpApiClient();
const result = await api.eventHistory.list(projectPath, { limit: 100 });
if (result.success && result.events) {
setEvents(result.events);
}
} catch (error) {
console.error('Failed to load events:', error);
} finally {
setLoading(false);
}
}, [projectPath]);
useEffect(() => {
loadEvents();
}, [loadEvents]);
const handleExpand = async (eventId: string) => {
if (expandedEvent === eventId) {
setExpandedEvent(null);
setExpandedEventData(null);
return;
}
if (!projectPath) return;
setExpandedEvent(eventId);
try {
const api = getHttpApiClient();
const result = await api.eventHistory.get(projectPath, eventId);
if (result.success && result.event) {
setExpandedEventData(result.event);
}
} catch (error) {
console.error('Failed to load event details:', error);
}
};
const handleReplay = async (eventId: string) => {
if (!projectPath) return;
setReplayingEvent(eventId);
try {
const api = getHttpApiClient();
const result = await api.eventHistory.replay(projectPath, eventId);
if (result.success && result.result) {
const { hooksTriggered, hookResults } = result.result;
const successCount = hookResults.filter((r) => r.success).length;
const failCount = hookResults.filter((r) => !r.success).length;
if (hooksTriggered === 0) {
alert('No matching hooks found for this event trigger.');
} else if (failCount === 0) {
alert(`Successfully ran ${successCount} hook(s).`);
} else {
alert(`Ran ${hooksTriggered} hook(s): ${successCount} succeeded, ${failCount} failed.`);
}
}
} catch (error) {
console.error('Failed to replay event:', error);
alert('Failed to replay event. Check console for details.');
} finally {
setReplayingEvent(null);
}
};
const handleDelete = async (eventId: string) => {
if (!projectPath) return;
try {
const api = getHttpApiClient();
const result = await api.eventHistory.delete(projectPath, eventId);
if (result.success) {
setEvents((prev) => prev.filter((e) => e.id !== eventId));
if (expandedEvent === eventId) {
setExpandedEvent(null);
setExpandedEventData(null);
}
}
} catch (error) {
console.error('Failed to delete event:', error);
}
};
const handleClearAll = async () => {
if (!projectPath) return;
try {
const api = getHttpApiClient();
const result = await api.eventHistory.clear(projectPath);
if (result.success) {
setEvents([]);
setExpandedEvent(null);
setExpandedEventData(null);
}
} catch (error) {
console.error('Failed to clear events:', error);
}
setClearDialogOpen(false);
};
const getTriggerIcon = (trigger: EventHookTrigger) => {
switch (trigger) {
case 'feature_created':
return <Clock className="w-4 h-4 text-blue-500" />;
case 'feature_success':
return <CheckCircle className="w-4 h-4 text-green-500" />;
case 'feature_error':
return <XCircle className="w-4 h-4 text-red-500" />;
case 'auto_mode_complete':
return <CheckCircle className="w-4 h-4 text-purple-500" />;
case 'auto_mode_error':
return <AlertCircle className="w-4 h-4 text-orange-500" />;
default:
return <History className="w-4 h-4 text-muted-foreground" />;
}
};
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
if (!projectPath) {
return (
<div className="text-center py-8 text-muted-foreground">
<History className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">Select a project to view event history</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Header with actions */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{events.length} event{events.length !== 1 ? 's' : ''} recorded
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={loadEvents} disabled={loading}>
<RefreshCw className={cn('w-4 h-4 mr-2', loading && 'animate-spin')} />
Refresh
</Button>
{events.length > 0 && (
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setClearDialogOpen(true)}
>
<Trash2 className="w-4 h-4 mr-2" />
Clear All
</Button>
)}
</div>
</div>
{/* Events list */}
{events.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<History className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">No events recorded yet</p>
<p className="text-xs mt-1">
Events will appear here when features are created or completed
</p>
</div>
) : (
<div className="space-y-2">
{events.map((event) => (
<div
key={event.id}
className={cn(
'rounded-lg border bg-background/50',
expandedEvent === event.id && 'ring-1 ring-brand-500/30'
)}
>
{/* Event header */}
<div
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/30 transition-colors"
onClick={() => handleExpand(event.id)}
>
<button className="p-0.5">
{expandedEvent === event.id ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
</button>
{getTriggerIcon(event.trigger)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{EVENT_HOOK_TRIGGER_LABELS[event.trigger]}
</p>
{event.featureName && (
<p className="text-xs text-muted-foreground truncate">{event.featureName}</p>
)}
</div>
<span className="text-xs text-muted-foreground">
{formatTimestamp(event.timestamp)}
</span>
{/* Actions */}
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleReplay(event.id)}
disabled={replayingEvent === event.id}
title="Replay event (trigger matching hooks)"
>
<Play
className={cn('w-3.5 h-3.5', replayingEvent === event.id && 'animate-pulse')}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(event.id)}
title="Delete event"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{/* Expanded details */}
{expandedEvent === event.id && expandedEventData && (
<div className="px-4 pb-4 pt-0 border-t border-border/50">
<div className="mt-3 space-y-2 text-xs">
<div className="grid grid-cols-2 gap-2">
<div>
<span className="text-muted-foreground">Event ID:</span>
<p className="font-mono text-[10px] truncate">{expandedEventData.id}</p>
</div>
<div>
<span className="text-muted-foreground">Timestamp:</span>
<p>{new Date(expandedEventData.timestamp).toLocaleString()}</p>
</div>
{expandedEventData.featureId && (
<div>
<span className="text-muted-foreground">Feature ID:</span>
<p className="font-mono text-[10px] truncate">
{expandedEventData.featureId}
</p>
</div>
)}
{expandedEventData.passes !== undefined && (
<div>
<span className="text-muted-foreground">Passed:</span>
<p>{expandedEventData.passes ? 'Yes' : 'No'}</p>
</div>
)}
</div>
{expandedEventData.error && (
<div>
<span className="text-muted-foreground">Error:</span>
<p className="text-red-400 mt-1 p-2 bg-red-500/10 rounded text-[10px] font-mono whitespace-pre-wrap">
{expandedEventData.error}
</p>
</div>
)}
<div>
<span className="text-muted-foreground">Project:</span>
<p className="font-mono text-[10px] truncate">
{expandedEventData.projectPath}
</p>
</div>
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Clear confirmation dialog */}
<ConfirmDialog
open={clearDialogOpen}
onOpenChange={setClearDialogOpen}
onConfirm={handleClearAll}
title="Clear Event History"
description={`This will permanently delete all ${events.length} recorded events. This action cannot be undone.`}
icon={Trash2}
iconClassName="text-destructive"
confirmText="Clear All"
confirmVariant="destructive"
/>
</div>
);
}

View File

@@ -39,6 +39,7 @@ interface EventHookDialogProps {
type ActionType = 'shell' | 'http'; type ActionType = 'shell' | 'http';
const TRIGGER_OPTIONS: EventHookTrigger[] = [ const TRIGGER_OPTIONS: EventHookTrigger[] = [
'feature_created',
'feature_success', 'feature_success',
'feature_error', 'feature_error',
'auto_mode_complete', 'auto_mode_complete',

View File

@@ -1,17 +1,20 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Webhook, Plus, Trash2, Pencil, Terminal, Globe } from 'lucide-react'; import { Webhook, Plus, Trash2, Pencil, Terminal, Globe, History } from 'lucide-react';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import type { EventHook, EventHookTrigger } from '@automaker/types'; import type { EventHook, EventHookTrigger } from '@automaker/types';
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types'; import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
import { EventHookDialog } from './event-hook-dialog'; import { EventHookDialog } from './event-hook-dialog';
import { EventHistoryView } from './event-history-view';
export function EventHooksSection() { export function EventHooksSection() {
const { eventHooks, setEventHooks } = useAppStore(); const { eventHooks, setEventHooks } = useAppStore();
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingHook, setEditingHook] = useState<EventHook | null>(null); const [editingHook, setEditingHook] = useState<EventHook | null>(null);
const [activeTab, setActiveTab] = useState<'hooks' | 'history'>('hooks');
const handleAddHook = () => { const handleAddHook = () => {
setEditingHook(null); setEditingHook(null);
@@ -78,58 +81,85 @@ export function EventHooksSection() {
</p> </p>
</div> </div>
</div> </div>
<Button onClick={handleAddHook} size="sm" className="gap-2"> {activeTab === 'hooks' && (
<Plus className="w-4 h-4" /> <Button onClick={handleAddHook} size="sm" className="gap-2">
Add Hook <Plus className="w-4 h-4" />
</Button> Add Hook
</Button>
)}
</div> </div>
</div> </div>
{/* Content */} {/* Tabs */}
<div className="p-6"> <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'hooks' | 'history')}>
{eventHooks.length === 0 ? ( <div className="px-6 pt-4">
<div className="text-center py-8 text-muted-foreground"> <TabsList className="grid w-full max-w-xs grid-cols-2">
<Webhook className="w-12 h-12 mx-auto mb-3 opacity-30" /> <TabsTrigger value="hooks" className="gap-2">
<p className="text-sm">No event hooks configured</p> <Webhook className="w-4 h-4" />
<p className="text-xs mt-1"> Hooks
Add hooks to run commands or send webhooks when features complete </TabsTrigger>
</p> <TabsTrigger value="history" className="gap-2">
</div> <History className="w-4 h-4" />
) : ( History
<div className="space-y-6"> </TabsTrigger>
{/* Group by trigger type */} </TabsList>
{Object.entries(hooksByTrigger).map(([trigger, hooks]) => ( </div>
<div key={trigger} className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground"> {/* Hooks Tab */}
{EVENT_HOOK_TRIGGER_LABELS[trigger as EventHookTrigger]} <TabsContent value="hooks" className="m-0">
</h3> <div className="p-6 pt-4">
<div className="space-y-2"> {eventHooks.length === 0 ? (
{hooks.map((hook) => ( <div className="text-center py-8 text-muted-foreground">
<HookCard <Webhook className="w-12 h-12 mx-auto mb-3 opacity-30" />
key={hook.id} <p className="text-sm">No event hooks configured</p>
hook={hook} <p className="text-xs mt-1">
onEdit={() => handleEditHook(hook)} Add hooks to run commands or send webhooks when features complete
onDelete={() => handleDeleteHook(hook.id)} </p>
onToggle={(enabled) => handleToggleHook(hook.id, enabled)}
/>
))}
</div>
</div> </div>
))} ) : (
<div className="space-y-6">
{/* Group by trigger type */}
{Object.entries(hooksByTrigger).map(([trigger, hooks]) => (
<div key={trigger} className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">
{EVENT_HOOK_TRIGGER_LABELS[trigger as EventHookTrigger]}
</h3>
<div className="space-y-2">
{hooks.map((hook) => (
<HookCard
key={hook.id}
hook={hook}
onEdit={() => handleEditHook(hook)}
onDelete={() => handleDeleteHook(hook.id)}
onToggle={(enabled) => handleToggleHook(hook.id, enabled)}
/>
))}
</div>
</div>
))}
</div>
)}
</div> </div>
)}
</div>
{/* Variable reference */} {/* Variable reference */}
<div className="px-6 pb-6"> <div className="px-6 pb-6">
<div className="rounded-lg bg-muted/30 p-4 text-xs text-muted-foreground"> <div className="rounded-lg bg-muted/30 p-4 text-xs text-muted-foreground">
<p className="font-medium mb-2">Available variables:</p> <p className="font-medium mb-2">Available variables:</p>
<code className="text-[10px] leading-relaxed"> <code className="text-[10px] leading-relaxed">
{'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '} {'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '}
{'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'} {'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'}
</code> </code>
</div> </div>
</div> </div>
</TabsContent>
{/* History Tab */}
<TabsContent value="history" className="m-0">
<div className="p-6 pt-4">
<EventHistoryView />
</div>
</TabsContent>
</Tabs>
{/* Dialog */} {/* Dialog */}
<EventHookDialog <EventHookDialog

View File

@@ -1,172 +1,14 @@
import { useState, useEffect, useCallback } from 'react';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button'; import { GitBranch } from 'lucide-react';
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
import {
GitBranch,
Terminal,
FileCode,
Save,
RotateCcw,
Trash2,
Loader2,
PanelBottomClose,
} from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
interface WorktreesSectionProps { interface WorktreesSectionProps {
useWorktrees: boolean; useWorktrees: boolean;
onUseWorktreesChange: (value: boolean) => void; onUseWorktreesChange: (value: boolean) => void;
} }
interface InitScriptResponse {
success: boolean;
exists: boolean;
content: string;
path: string;
error?: string;
}
export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) { export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) {
const currentProject = useAppStore((s) => s.currentProject);
const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator);
const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator);
const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch);
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
const [scriptContent, setScriptContent] = useState('');
const [originalContent, setOriginalContent] = useState('');
const [scriptExists, setScriptExists] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Get the current show indicator setting
const showIndicator = currentProject?.path
? getShowInitScriptIndicator(currentProject.path)
: true;
// Get the default delete branch setting
const defaultDeleteBranch = currentProject?.path
? getDefaultDeleteBranch(currentProject.path)
: false;
// Get the auto-dismiss setting
const autoDismiss = currentProject?.path
? getAutoDismissInitScriptIndicator(currentProject.path)
: true;
// Check if there are unsaved changes
const hasChanges = scriptContent !== originalContent;
// Load init script content when project changes
useEffect(() => {
if (!currentProject?.path) {
setScriptContent('');
setOriginalContent('');
setScriptExists(false);
setIsLoading(false);
return;
}
const loadInitScript = async () => {
setIsLoading(true);
try {
const response = await apiGet<InitScriptResponse>(
`/api/worktree/init-script?projectPath=${encodeURIComponent(currentProject.path)}`
);
if (response.success) {
const content = response.content || '';
setScriptContent(content);
setOriginalContent(content);
setScriptExists(response.exists);
}
} catch (error) {
console.error('Failed to load init script:', error);
} finally {
setIsLoading(false);
}
};
loadInitScript();
}, [currentProject?.path]);
// Save script
const handleSave = useCallback(async () => {
if (!currentProject?.path) return;
setIsSaving(true);
try {
const response = await apiPut<{ success: boolean; error?: string }>(
'/api/worktree/init-script',
{
projectPath: currentProject.path,
content: scriptContent,
}
);
if (response.success) {
setOriginalContent(scriptContent);
setScriptExists(true);
toast.success('Init script saved');
} else {
toast.error('Failed to save init script', {
description: response.error,
});
}
} catch (error) {
console.error('Failed to save init script:', error);
toast.error('Failed to save init script');
} finally {
setIsSaving(false);
}
}, [currentProject?.path, scriptContent]);
// Reset to original content
const handleReset = useCallback(() => {
setScriptContent(originalContent);
}, [originalContent]);
// Delete script
const handleDelete = useCallback(async () => {
if (!currentProject?.path) return;
setIsDeleting(true);
try {
const response = await apiDelete<{ success: boolean; error?: string }>(
'/api/worktree/init-script',
{
body: { projectPath: currentProject.path },
}
);
if (response.success) {
setScriptContent('');
setOriginalContent('');
setScriptExists(false);
toast.success('Init script deleted');
} else {
toast.error('Failed to delete init script', {
description: response.error,
});
}
} catch (error) {
console.error('Failed to delete init script:', error);
toast.error('Failed to delete init script');
} finally {
setIsDeleting(false);
}
}, [currentProject?.path]);
// Handle content change (no auto-save)
const handleContentChange = useCallback((value: string) => {
setScriptContent(value);
}, []);
return ( return (
<div <div
className={cn( className={cn(
@@ -184,7 +26,7 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
<h2 className="text-lg font-semibold text-foreground tracking-tight">Worktrees</h2> <h2 className="text-lg font-semibold text-foreground tracking-tight">Worktrees</h2>
</div> </div>
<p className="text-sm text-muted-foreground/80 ml-12"> <p className="text-sm text-muted-foreground/80 ml-12">
Configure git worktree isolation and initialization scripts. Configure git worktree isolation for feature development.
</p> </p>
</div> </div>
<div className="p-6 space-y-5"> <div className="p-6 space-y-5">
@@ -212,217 +54,12 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
</div> </div>
</div> </div>
{/* Show Init Script Indicator Toggle */} {/* Info about project-specific settings */}
{currentProject && ( <div className="rounded-xl border border-border/30 bg-muted/30 p-4">
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 mt-4"> <p className="text-xs text-muted-foreground">
<Checkbox Project-specific worktree preferences (init script, delete branch behavior) can be
id="show-init-script-indicator" configured in each project's settings via the sidebar.
checked={showIndicator}
onCheckedChange={async (checked) => {
if (currentProject?.path) {
const value = checked === true;
setShowInitScriptIndicator(currentProject.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(currentProject.path, {
showInitScriptIndicator: value,
});
} catch (error) {
console.error('Failed to persist showInitScriptIndicator:', error);
}
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="show-init-script-indicator"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<PanelBottomClose className="w-4 h-4 text-brand-500" />
Show Init Script Indicator
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Display a floating panel in the bottom-right corner showing init script execution
status and output when a worktree is created.
</p>
</div>
</div>
)}
{/* Auto-dismiss Init Script Indicator Toggle */}
{currentProject && showIndicator && (
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 ml-6">
<Checkbox
id="auto-dismiss-indicator"
checked={autoDismiss}
onCheckedChange={async (checked) => {
if (currentProject?.path) {
const value = checked === true;
setAutoDismissInitScriptIndicator(currentProject.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(currentProject.path, {
autoDismissInitScriptIndicator: value,
});
} catch (error) {
console.error('Failed to persist autoDismissInitScriptIndicator:', error);
}
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="auto-dismiss-indicator"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
Auto-dismiss After Completion
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Automatically hide the indicator 5 seconds after the script completes.
</p>
</div>
</div>
)}
{/* Default Delete Branch Toggle */}
{currentProject && (
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="default-delete-branch"
checked={defaultDeleteBranch}
onCheckedChange={async (checked) => {
if (currentProject?.path) {
const value = checked === true;
setDefaultDeleteBranch(currentProject.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(currentProject.path, {
defaultDeleteBranch: value,
});
} catch (error) {
console.error('Failed to persist defaultDeleteBranch:', error);
}
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="default-delete-branch"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Trash2 className="w-4 h-4 text-brand-500" />
Delete Branch by Default
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When deleting a worktree, automatically check the "Also delete the branch" option.
</p>
</div>
</div>
)}
{/* Separator */}
<div className="border-t border-border/30" />
{/* Init Script Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-brand-500" />
<Label className="text-foreground font-medium">Initialization Script</Label>
</div>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash
on Windows for cross-platform compatibility.
</p> </p>
{currentProject ? (
<>
{/* File path indicator */}
<div className="flex items-center gap-2 text-xs text-muted-foreground/60">
<FileCode className="w-3.5 h-3.5" />
<code className="font-mono">.automaker/worktree-init.sh</code>
{hasChanges && (
<span className="text-amber-500 font-medium">(unsaved changes)</span>
)}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : (
<>
<ShellSyntaxEditor
value={scriptContent}
onChange={handleContentChange}
placeholder={`# Example initialization commands
npm install
# Or use pnpm
# pnpm install
# Copy environment file
# cp .env.example .env`}
minHeight="200px"
maxHeight="500px"
data-testid="init-script-editor"
/>
{/* Action buttons */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDelete}
disabled={!scriptExists || isSaving || isDeleting}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
{isDeleting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
Delete
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
{isSaving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Save className="w-3.5 h-3.5" />
)}
Save
</Button>
</div>
</>
)}
</>
) : (
<div className="text-sm text-muted-foreground/60 py-4 text-center">
Select a project to configure the init script.
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
@@ -13,6 +14,9 @@ import { CreateSpecDialog, RegenerateSpecDialog } from './spec-view/dialogs';
export function SpecView() { export function SpecView() {
const { currentProject, appSpec } = useAppStore(); const { currentProject, appSpec } = useAppStore();
// Actions panel state (for tablet/mobile)
const [showActionsPanel, setShowActionsPanel] = useState(false);
// Loading state // Loading state
const { isLoading, specExists, isGenerationRunning, loadSpec } = useSpecLoading(); const { isLoading, specExists, isGenerationRunning, loadSpec } = useSpecLoading();
@@ -52,6 +56,9 @@ export function SpecView() {
// Feature generation // Feature generation
isGeneratingFeatures, isGeneratingFeatures,
// Sync
isSyncing,
// Status // Status
currentPhase, currentPhase,
errorMessage, errorMessage,
@@ -59,6 +66,8 @@ export function SpecView() {
// Handlers // Handlers
handleCreateSpec, handleCreateSpec,
handleRegenerate, handleRegenerate,
handleGenerateFeatures,
handleSync,
} = useSpecGeneration({ loadSpec }); } = useSpecGeneration({ loadSpec });
// Reset hasChanges when spec is reloaded // Reset hasChanges when spec is reloaded
@@ -82,10 +91,9 @@ export function SpecView() {
); );
} }
// Empty state - no spec exists or generation is running // Empty state - only show when spec doesn't exist AND no generation is running
// When generation is running, we skip loading the spec to avoid 500 errors, // If generation is running but no spec exists, show the generating UI
// so we show the empty state with generation indicator if (!specExists) {
if (!specExists || isGenerationRunning) {
// If generation is running (from loading hook check), ensure we show the generating UI // If generation is running (from loading hook check), ensure we show the generating UI
const showAsGenerating = isCreating || isGenerationRunning; const showAsGenerating = isCreating || isGenerationRunning;
@@ -123,15 +131,20 @@ export function SpecView() {
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="spec-view"> <div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="spec-view">
<SpecHeader <SpecHeader
projectPath={currentProject.path} projectPath={currentProject.path}
isRegenerating={isRegenerating} isRegenerating={isRegenerating || isGenerationRunning}
isCreating={isCreating} isCreating={isCreating}
isGeneratingFeatures={isGeneratingFeatures} isGeneratingFeatures={isGeneratingFeatures}
isSyncing={isSyncing}
isSaving={isSaving} isSaving={isSaving}
hasChanges={hasChanges} hasChanges={hasChanges}
currentPhase={currentPhase} currentPhase={currentPhase || (isGenerationRunning ? 'working' : '')}
errorMessage={errorMessage} errorMessage={errorMessage}
onRegenerateClick={() => setShowRegenerateDialog(true)} onRegenerateClick={() => setShowRegenerateDialog(true)}
onGenerateFeaturesClick={handleGenerateFeatures}
onSyncClick={handleSync}
onSaveClick={saveSpec} onSaveClick={saveSpec}
showActionsPanel={showActionsPanel}
onToggleActionsPanel={() => setShowActionsPanel(!showActionsPanel)}
/> />
<SpecEditor value={appSpec} onChange={handleChange} /> <SpecEditor value={appSpec} onChange={handleChange} />

View File

@@ -1,5 +1,9 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Save, Sparkles, Loader2, FileText, AlertCircle } from 'lucide-react'; import {
HeaderActionsPanel,
HeaderActionsPanelTrigger,
} from '@/components/ui/header-actions-panel';
import { Save, Sparkles, Loader2, FileText, AlertCircle, ListPlus, RefreshCcw } from 'lucide-react';
import { PHASE_LABELS } from '../constants'; import { PHASE_LABELS } from '../constants';
interface SpecHeaderProps { interface SpecHeaderProps {
@@ -7,12 +11,17 @@ interface SpecHeaderProps {
isRegenerating: boolean; isRegenerating: boolean;
isCreating: boolean; isCreating: boolean;
isGeneratingFeatures: boolean; isGeneratingFeatures: boolean;
isSyncing: boolean;
isSaving: boolean; isSaving: boolean;
hasChanges: boolean; hasChanges: boolean;
currentPhase: string; currentPhase: string;
errorMessage: string; errorMessage: string;
onRegenerateClick: () => void; onRegenerateClick: () => void;
onGenerateFeaturesClick: () => void;
onSyncClick: () => void;
onSaveClick: () => void; onSaveClick: () => void;
showActionsPanel: boolean;
onToggleActionsPanel: () => void;
} }
export function SpecHeader({ export function SpecHeader({
@@ -20,87 +29,200 @@ export function SpecHeader({
isRegenerating, isRegenerating,
isCreating, isCreating,
isGeneratingFeatures, isGeneratingFeatures,
isSyncing,
isSaving, isSaving,
hasChanges, hasChanges,
currentPhase, currentPhase,
errorMessage, errorMessage,
onRegenerateClick, onRegenerateClick,
onGenerateFeaturesClick,
onSyncClick,
onSaveClick, onSaveClick,
showActionsPanel,
onToggleActionsPanel,
}: SpecHeaderProps) { }: SpecHeaderProps) {
const isProcessing = isRegenerating || isCreating || isGeneratingFeatures; const isProcessing = isRegenerating || isCreating || isGeneratingFeatures || isSyncing;
const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase; const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase;
return ( return (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md"> <>
<div className="flex items-center gap-3"> <div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<FileText className="w-5 h-5 text-muted-foreground" /> <div className="flex items-center gap-3">
<div> <FileText className="w-5 h-5 text-muted-foreground" />
<h1 className="text-xl font-bold">App Specification</h1> <div>
<p className="text-sm text-muted-foreground">{projectPath}/.automaker/app_spec.txt</p> <h1 className="text-xl font-bold">App Specification</h1>
<p className="text-sm text-muted-foreground">{projectPath}/.automaker/app_spec.txt</p>
</div>
</div>
<div className="flex items-center gap-3">
{/* Status indicators - always visible */}
{isProcessing && (
<div className="hidden lg:flex items-center gap-3 px-6 py-3.5 rounded-xl bg-linear-to-r from-primary/15 to-primary/5 border border-primary/30 shadow-lg backdrop-blur-md">
<div className="relative">
<Loader2 className="w-5 h-5 animate-spin text-primary shrink-0" />
<div className="absolute inset-0 w-5 h-5 animate-ping text-primary/20" />
</div>
<div className="flex flex-col gap-1 min-w-0">
<span className="text-sm font-semibold text-primary leading-tight tracking-tight">
{isSyncing
? 'Syncing Specification'
: isGeneratingFeatures
? 'Generating Features'
: isCreating
? 'Generating Specification'
: 'Regenerating Specification'}
</span>
{currentPhase && (
<span className="text-xs text-muted-foreground/90 leading-tight font-medium">
{phaseLabel}
</span>
)}
</div>
</div>
)}
{/* Mobile processing indicator */}
{isProcessing && (
<div className="lg:hidden flex items-center gap-2 px-3 py-2 rounded-lg bg-primary/10 border border-primary/20">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="text-xs font-medium text-primary">Processing...</span>
</div>
)}
{errorMessage && (
<div className="hidden lg:flex items-center gap-3 px-6 py-3.5 rounded-xl bg-linear-to-r from-destructive/15 to-destructive/5 border border-destructive/30 shadow-lg backdrop-blur-md">
<AlertCircle className="w-5 h-5 text-destructive shrink-0" />
<div className="flex flex-col gap-1 min-w-0">
<span className="text-sm font-semibold text-destructive leading-tight tracking-tight">
Error
</span>
<span className="text-xs text-destructive/90 leading-tight font-medium">
{errorMessage}
</span>
</div>
</div>
)}
{/* Mobile error indicator */}
{errorMessage && (
<div className="lg:hidden flex items-center gap-2 px-3 py-2 rounded-lg bg-destructive/10 border border-destructive/20">
<AlertCircle className="w-4 h-4 text-destructive" />
<span className="text-xs font-medium text-destructive">Error</span>
</div>
)}
{/* Desktop: show actions inline - hidden when processing since status card shows progress */}
{!isProcessing && (
<div className="hidden lg:flex gap-2">
<Button size="sm" variant="outline" onClick={onSyncClick} data-testid="sync-spec">
<RefreshCcw className="w-4 h-4 mr-2" />
Sync
</Button>
<Button
size="sm"
variant="outline"
onClick={onRegenerateClick}
data-testid="regenerate-spec"
>
<Sparkles className="w-4 h-4 mr-2" />
Regenerate
</Button>
<Button
size="sm"
variant="outline"
onClick={onGenerateFeaturesClick}
data-testid="generate-features"
>
<ListPlus className="w-4 h-4 mr-2" />
Generate Features
</Button>
<Button
size="sm"
onClick={onSaveClick}
disabled={!hasChanges || isSaving}
data-testid="save-spec"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
</Button>
</div>
)}
{/* Tablet/Mobile: show trigger for actions panel */}
<HeaderActionsPanelTrigger isOpen={showActionsPanel} onToggle={onToggleActionsPanel} />
</div> </div>
</div> </div>
<div className="flex items-center gap-3">
{/* Actions Panel (tablet/mobile) */}
<HeaderActionsPanel
isOpen={showActionsPanel}
onClose={onToggleActionsPanel}
title="Specification Actions"
>
{/* Status messages in panel */}
{isProcessing && ( {isProcessing && (
<div className="flex items-center gap-3 px-6 py-3.5 rounded-xl bg-linear-to-r from-primary/15 to-primary/5 border border-primary/30 shadow-lg backdrop-blur-md"> <div className="flex items-center gap-3 p-3 rounded-lg bg-primary/10 border border-primary/20">
<div className="relative"> <Loader2 className="w-4 h-4 animate-spin text-primary shrink-0" />
<Loader2 className="w-5 h-5 animate-spin text-primary shrink-0" /> <div className="flex flex-col gap-0.5 min-w-0">
<div className="absolute inset-0 w-5 h-5 animate-ping text-primary/20" /> <span className="text-sm font-medium text-primary">
</div> {isSyncing
<div className="flex flex-col gap-1 min-w-0"> ? 'Syncing Specification'
<span className="text-sm font-semibold text-primary leading-tight tracking-tight"> : isGeneratingFeatures
{isGeneratingFeatures ? 'Generating Features'
? 'Generating Features' : isCreating
: isCreating ? 'Generating Specification'
? 'Generating Specification' : 'Regenerating Specification'}
: 'Regenerating Specification'}
</span> </span>
{currentPhase && ( {currentPhase && <span className="text-xs text-muted-foreground">{phaseLabel}</span>}
<span className="text-xs text-muted-foreground/90 leading-tight font-medium">
{phaseLabel}
</span>
)}
</div> </div>
</div> </div>
)} )}
{errorMessage && ( {errorMessage && (
<div className="flex items-center gap-3 px-6 py-3.5 rounded-xl bg-linear-to-r from-destructive/15 to-destructive/5 border border-destructive/30 shadow-lg backdrop-blur-md"> <div className="flex items-center gap-3 p-3 rounded-lg bg-destructive/10 border border-destructive/20">
<AlertCircle className="w-5 h-5 text-destructive shrink-0" /> <AlertCircle className="w-4 h-4 text-destructive shrink-0" />
<div className="flex flex-col gap-1 min-w-0"> <div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-semibold text-destructive leading-tight tracking-tight"> <span className="text-sm font-medium text-destructive">Error</span>
Error <span className="text-xs text-destructive/80">{errorMessage}</span>
</span>
<span className="text-xs text-destructive/90 leading-tight font-medium">
{errorMessage}
</span>
</div> </div>
</div> </div>
)} )}
<div className="flex gap-2"> {/* Hide action buttons when processing - status card shows progress */}
<Button {!isProcessing && (
size="sm" <>
variant="outline" <Button
onClick={onRegenerateClick} variant="outline"
disabled={isProcessing} className="w-full justify-start"
data-testid="regenerate-spec" onClick={onSyncClick}
> data-testid="sync-spec-mobile"
{isRegenerating ? ( >
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <RefreshCcw className="w-4 h-4 mr-2" />
) : ( Sync
</Button>
<Button
variant="outline"
className="w-full justify-start"
onClick={onRegenerateClick}
data-testid="regenerate-spec-mobile"
>
<Sparkles className="w-4 h-4 mr-2" /> <Sparkles className="w-4 h-4 mr-2" />
)} Regenerate
{isRegenerating ? 'Regenerating...' : 'Regenerate'} </Button>
</Button> <Button
<Button variant="outline"
size="sm" className="w-full justify-start"
onClick={onSaveClick} onClick={onGenerateFeaturesClick}
disabled={!hasChanges || isSaving || isProcessing} data-testid="generate-features-mobile"
data-testid="save-spec" >
> <ListPlus className="w-4 h-4 mr-2" />
<Save className="w-4 h-4 mr-2" /> Generate Features
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'} </Button>
</Button> <Button
</div> className="w-full justify-start"
</div> onClick={onSaveClick}
</div> disabled={!hasChanges || isSaving}
data-testid="save-spec-mobile"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
</Button>
</>
)}
</HeaderActionsPanel>
</>
); );
} }

View File

@@ -24,6 +24,7 @@ export const PHASE_LABELS: Record<string, string> = {
analysis: 'Analyzing project structure...', analysis: 'Analyzing project structure...',
spec_complete: 'Spec created! Generating features...', spec_complete: 'Spec created! Generating features...',
feature_generation: 'Creating features from roadmap...', feature_generation: 'Creating features from roadmap...',
working: 'Working...',
complete: 'Complete!', complete: 'Complete!',
error: 'Error occurred', error: 'Error occurred',
}; };

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