Compare commits

..

1 Commits

Author SHA1 Message Date
SuperComboGamer
2eb92a0402 feat: Introduce new UI layout with floating dock, visual effects, and expanded theme options. 2025-12-22 21:10:12 -05:00
197 changed files with 4341 additions and 13093 deletions

1
.gitignore vendored
View File

@@ -80,4 +80,3 @@ blob-report/
*.pem *.pem
docker-compose.override.yml docker-compose.override.yml
.claude/

412
README.md
View File

@@ -19,7 +19,7 @@
- [What Makes Automaker Different?](#what-makes-automaker-different) - [What Makes Automaker Different?](#what-makes-automaker-different)
- [The Workflow](#the-workflow) - [The Workflow](#the-workflow)
- [Powered by Claude Agent SDK](#powered-by-claude-agent-sdk) - [Powered by Claude Code](#powered-by-claude-code)
- [Why This Matters](#why-this-matters) - [Why This Matters](#why-this-matters)
- [Security Disclaimer](#security-disclaimer) - [Security Disclaimer](#security-disclaimer)
- [Community & Support](#community--support) - [Community & Support](#community--support)
@@ -28,36 +28,22 @@
- [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)
- [Electron Desktop App (Recommended)](#electron-desktop-app-recommended)
- [Web Browser Mode](#web-browser-mode)
- [Building for Production](#building-for-production) - [Building for Production](#building-for-production)
- [Running Production Build](#running-production-build)
- [Testing](#testing) - [Testing](#testing)
- [Linting](#linting) - [Linting](#linting)
- [Environment Configuration](#environment-configuration) - [Authentication Options](#authentication-options)
- [Authentication Setup](#authentication-setup) - [Persistent Setup (Optional)](#persistent-setup-optional)
- [Features](#features) - [Features](#features)
- [Core Workflow](#core-workflow)
- [AI & Planning](#ai--planning)
- [Project Management](#project-management)
- [Collaboration & Review](#collaboration--review)
- [Developer Tools](#developer-tools)
- [Advanced Features](#advanced-features)
- [Tech Stack](#tech-stack) - [Tech Stack](#tech-stack)
- [Frontend](#frontend)
- [Backend](#backend)
- [Testing & Quality](#testing--quality)
- [Shared Libraries](#shared-libraries)
- [Available Views](#available-views)
- [Architecture](#architecture)
- [Monorepo Structure](#monorepo-structure)
- [How It Works](#how-it-works)
- [Key Architectural Patterns](#key-architectural-patterns)
- [Security & Isolation](#security--isolation)
- [Data Storage](#data-storage)
- [Learn More](#learn-more) - [Learn More](#learn-more)
- [License](#license) - [License](#license)
</details> </details>
Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Agent SDK automatically implement them. Built with React, Vite, Electron, and Express, Automaker provides a complete workflow for managing AI agents through a desktop application (or web browser), with features like real-time streaming, git worktree isolation, plan approval, and multi-agent task execution. Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Code automatically implement them.
![Automaker UI](https://i.imgur.com/jdwKydM.png) ![Automaker UI](https://i.imgur.com/jdwKydM.png)
@@ -73,9 +59,9 @@ Traditional development tools help you write code. Automaker helps you **orchest
4. **Review & Verify** - Review the changes, run tests, and approve when ready 4. **Review & Verify** - Review the changes, run tests, and approve when ready
5. **Ship Faster** - Build entire applications in days, not weeks 5. **Ship Faster** - Build entire applications in days, not weeks
### Powered by Claude Agent SDK ### Powered by Claude Code
Automaker leverages the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe. The SDK provides autonomous AI agents that can use tools, make decisions, and complete complex multi-step tasks without constant human intervention. Automaker leverages the [Claude Agent SDK](https://docs.anthropic.com/en/docs/claude-code) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe.
### Why This Matters ### Why This Matters
@@ -109,7 +95,8 @@ In the Discord, you can:
- 🚀 Show off projects built with AI agents - 🚀 Show off projects built with AI agents
- 🤝 Collaborate with other developers and contributors - 🤝 Collaborate with other developers and contributors
👉 **Join the Discord:** [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU) 👉 **Join the Discord:**
https://discord.gg/jjem7aEDKU
--- ---
@@ -117,49 +104,28 @@ In the Discord, you can:
### Prerequisites ### Prerequisites
- **Node.js 18+** (tested with Node.js 22) - Node.js 18+
- **npm** (comes with Node.js) - npm
- **Authentication** (choose one): - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
- **[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
```bash ```bash
# 1. Clone the repository # 1. Clone the repo
git clone https://github.com/AutoMaker-Org/automaker.git git clone https://github.com/AutoMaker-Org/automaker.git
cd automaker cd automaker
# 2. Install dependencies # 2. Install dependencies
npm install npm install
# 3. Build shared packages (Now can be skipped npm install / run dev does it automaticly) # 3. Build local shared packages
npm run build:packages npm run build:packages
# 4. Set up authentication (skip if using Claude Code CLI) # 4. Run Automaker (pick your mode)
# If using Claude Code CLI: credentials are detected automatically
# If using API key directly, choose one method:
# 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
# 5. Start Automaker (interactive launcher)
npm run dev npm run dev
# Choose between: # Then choose your run mode when prompted, or use specific commands below
# 1. Web Application (browser at localhost:3007)
# 2. Desktop Application (Electron - recommended)
``` ```
**Note:** The `npm run dev` command will:
- Check for dependencies and install if needed
- Install Playwright browsers for E2E tests
- Kill any processes on ports 3007/3008
- Present an interactive menu to choose your run mode
## How to Run ## How to Run
### Development Mode ### Development Mode
@@ -197,65 +163,31 @@ npm run dev:web
### Building for Production ### Building for Production
#### Web Application
```bash ```bash
# Build for web deployment (uses Vite) # Build Next.js app
npm run build npm run build
# Run production build # Build Electron app for distribution
npm run start
```
#### Desktop Application
```bash
# Build for current platform (macOS/Windows/Linux)
npm run build:electron npm run build:electron
# Platform-specific builds
npm run build:electron:mac # macOS (DMG + ZIP, x64 + arm64)
npm run build:electron:win # Windows (NSIS installer, x64)
npm run build:electron:linux # Linux (AppImage + DEB, x64)
# Output directory: apps/ui/release/
``` ```
#### Docker Deployment ### Running Production Build
```bash ```bash
# Build and run with Docker Compose (recommended for security) # Start production Next.js server
docker-compose up -d npm run start
# Access at http://localhost:3007
# API at http://localhost:3008
``` ```
### Testing ### Testing
#### End-to-End Tests (Playwright)
```bash ```bash
npm run test # Headless E2E tests # Run tests headless
npm run test:headed # Browser visible E2E tests npm run test
# Run tests with browser visible
npm run test:headed
``` ```
#### Unit Tests (Vitest)
```bash
npm run test:server # Server unit tests
npm run test:server:coverage # Server tests with coverage
npm run test:packages # All shared package tests
npm run test:all # Packages + server tests
```
#### Test Configuration
- E2E tests run on ports 3007 (UI) and 3008 (server)
- Automatically starts test servers before running
- Uses Chromium browser via Playwright
- Mock agent mode available in CI with `AUTOMAKER_MOCK_AGENT=true`
### Linting ### Linting
```bash ```bash
@@ -263,283 +195,59 @@ npm run test:all # Packages + server tests
npm run lint npm run lint
``` ```
### Environment Configuration ### Authentication Options
#### Authentication (if not using Claude Code CLI) Automaker supports multiple authentication methods (in order of priority):
- `ANTHROPIC_API_KEY` - Your Anthropic API key for Claude Agent SDK (not needed if using Claude Code CLI) | Method | Environment Variable | Description |
| ---------------- | -------------------- | ------------------------------- |
| API Key (env) | `ANTHROPIC_API_KEY` | Anthropic API key |
| API Key (stored) | — | Anthropic API key stored in app |
#### Optional - Server ### Persistent Setup (Optional)
- `PORT` - Server port (default: 3008)
- `DATA_DIR` - Data storage directory (default: ./data)
- `ENABLE_REQUEST_LOGGING` - HTTP request logging (default: true)
#### Optional - Security
- `AUTOMAKER_API_KEY` - Optional API authentication for the server
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
- `CORS_ORIGIN` - CORS policy (default: \*)
#### Optional - Development
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
### Authentication Setup
#### Option 1: Claude Code CLI (Recommended)
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!
#### 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`: Add to your `~/.bashrc` or `~/.zshrc`:
```bash ```bash
export ANTHROPIC_API_KEY="sk-ant-..." export ANTHROPIC_API_KEY="YOUR_API_KEY_HERE"
``` ```
Then restart your terminal or run `source ~/.bashrc` (or `source ~/.zshrc`). Then restart your terminal or run `source ~/.bashrc`.
##### 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
- 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages - 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages
- 🤖 **AI Agent Integration** - Automatic AI agent assignment to implement features when moved to "In Progress" - 🤖 **AI Agent Integration** - Automatic AI agent assignment to implement features when moved to "In Progress"
- 🔀 **Git Worktree Isolation** - Each feature executes in isolated git worktrees to protect your main branch - 🧠 **Multi-Model Support** - Choose from multiple AI models including Claude Opus, Sonnet, and more
- 📡 **Real-time Streaming** - Watch AI agents work in real-time with live tool usage, progress updates, and task completion - 💭 **Extended Thinking** - Enable extended thinking modes for complex problem-solving
- 🔄 **Follow-up Instructions** - Send additional instructions to running agents without stopping them - 📡 **Real-time Agent Output** - View live agent output, logs, and file diffs as features are being implemented
- 🔍 **Project Analysis** - AI-powered project structure analysis to understand your codebase
### AI & Planning - 📁 **Context Management** - Add context files to help AI agents understand your project better
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on your project
- 🧠 **Multi-Model Support** - Choose from Claude Opus, Sonnet, and Haiku per feature - 🖼️ **Image Support** - Attach images and screenshots to feature descriptions
- 💭 **Extended Thinking** - Enable thinking modes (none, medium, deep, ultra) for complex problem-solving - **Concurrent Processing** - Configure concurrency to process multiple features simultaneously
- 📝 **Planning Modes** - Four planning levels: skip (direct implementation), lite (quick plan), spec (task breakdown), full (phased execution) - 🧪 **Test Integration** - Automatic test running and verification for implemented features
- **Plan Approval** - Review and approve AI-generated plans before implementation begins - 🔀 **Git Integration** - View git diffs and track changes made by AI agents
- 📊 **Multi-Agent Task Execution** - Spec mode spawns dedicated agents per task for focused implementation - 👤 **AI Profiles** - Create and manage different AI agent profiles for various tasks
- 💬 **Chat History** - Keep track of conversations and interactions with AI agents
### Project Management - ⌨️ **Keyboard Shortcuts** - Efficient navigation and actions via keyboard shortcuts
- 🎨 **Dark/Light Theme** - Beautiful UI with theme support
- 🔍 **Project Analysis** - AI-powered codebase analysis to understand your project structure - 🖥️ **Cross-Platform** - Desktop application built with Electron for Windows, macOS, and Linux
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on project analysis
- 📁 **Context Management** - Add markdown, images, and documentation files that agents automatically reference
- 🔗 **Dependency Blocking** - Features can depend on other features, enforcing execution order
- 🌳 **Graph View** - Visualize feature dependencies with interactive graph visualization
- 📋 **GitHub Integration** - Import issues, validate feasibility, and convert to tasks automatically
### Collaboration & Review
- 🧪 **Verification Workflow** - Features move to "Waiting Approval" for review and testing
- 💬 **Agent Chat** - Interactive chat sessions with AI agents for exploratory work
- 👤 **AI Profiles** - Create custom agent configurations with different prompts, models, and settings
- 📜 **Session History** - Persistent chat sessions across restarts with full conversation history
- 🔍 **Git Diff Viewer** - Review changes made by agents before approving
### Developer Tools
- 🖥️ **Integrated Terminal** - Full terminal access with tabs, splits, and persistent sessions
- 🖼️ **Image Support** - Attach screenshots and diagrams to feature descriptions for visual context
-**Concurrent Execution** - Configure how many features can run simultaneously (default: 3)
- ⌨️ **Keyboard Shortcuts** - Fully customizable shortcuts for navigation and actions
- 🎨 **Theme System** - 25+ themes including Dark, Light, Dracula, Nord, Catppuccin, and more
- 🖥️ **Cross-Platform** - Desktop app for macOS (x64, arm64), Windows (x64), and Linux (x64)
- 🌐 **Web Mode** - Run in browser or as Electron desktop app
### Advanced Features
- 🔐 **Docker Isolation** - Security-focused Docker deployment with no host filesystem access
- 🎯 **Worktree Management** - Create, switch, commit, and create PRs from worktrees
- 📊 **Usage Tracking** - Monitor Claude API usage with detailed metrics
- 🔊 **Audio Notifications** - Optional completion sounds (mutable in settings)
- 💾 **Auto-save** - All work automatically persisted to `.automaker/` directory
## Tech Stack ## Tech Stack
### Frontend - [Next.js](https://nextjs.org) - React framework
- [Electron](https://www.electronjs.org/) - Desktop application framework
- **React 19** - UI framework - [Tailwind CSS](https://tailwindcss.com/) - Styling
- **Vite 7** - Build tool and development server - [Zustand](https://zustand-demo.pmnd.rs/) - State management
- **Electron 39** - Desktop application framework - [dnd-kit](https://dndkit.com/) - Drag and drop functionality
- **TypeScript 5.9** - Type safety
- **TanStack Router** - File-based routing
- **Zustand 5** - State management with persistence
- **Tailwind CSS 4** - Utility-first styling with 25+ themes
- **Radix UI** - Accessible component primitives
- **dnd-kit** - Drag and drop for Kanban board
- **@xyflow/react** - Graph visualization for dependencies
- **xterm.js** - Integrated terminal emulator
- **CodeMirror 6** - Code editor for XML/syntax highlighting
- **Lucide Icons** - Icon library
### Backend
- **Node.js** - JavaScript runtime with ES modules
- **Express 5** - HTTP server framework
- **TypeScript 5.9** - Type safety
- **Claude Agent SDK** - AI agent integration (@anthropic-ai/claude-agent-sdk)
- **WebSocket (ws)** - Real-time event streaming
- **node-pty** - PTY terminal sessions
### Testing & Quality
- **Playwright** - End-to-end testing
- **Vitest** - Unit testing framework
- **ESLint 9** - Code linting
- **Prettier 3** - Code formatting
- **Husky** - Git hooks for pre-commit formatting
### Shared Libraries
- **@automaker/types** - Shared TypeScript definitions
- **@automaker/utils** - Logging, error handling, image processing
- **@automaker/prompts** - AI prompt templates
- **@automaker/platform** - Path management and security
- **@automaker/model-resolver** - Claude model alias resolution
- **@automaker/dependency-resolver** - Feature dependency ordering
- **@automaker/git-utils** - Git operations and worktree management
## Available Views
Automaker provides several specialized views accessible via the sidebar or keyboard shortcuts:
| View | Shortcut | Description |
| ------------------ | -------- | ------------------------------------------------------------------------------------------------ |
| **Board** | `K` | Kanban board for managing feature workflow (Backlog → In Progress → Waiting Approval → Verified) |
| **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 |
| **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 |
| **Terminal** | `T` | Integrated terminal with tabs, splits, and persistent sessions |
| **GitHub Issues** | - | Import and validate GitHub issues, convert to tasks |
| **Running Agents** | - | View all active agents across projects with status and progress |
### Keyboard Navigation
All shortcuts are customizable in Settings. Default shortcuts:
- **Navigation:** `K` (Board), `A` (Agent), `D` (Spec), `C` (Context), `S` (Settings), `M` (Profiles), `T` (Terminal)
- **UI:** `` ` `` (Toggle sidebar)
- **Actions:** `N` (New item in current view), `G` (Start next features), `O` (Open project), `P` (Project picker)
- **Projects:** `Q`/`E` (Cycle previous/next project)
## Architecture
### Monorepo Structure
Automaker is built as an npm workspace monorepo with two main applications and seven shared packages:
```text
automaker/
├── apps/
│ ├── ui/ # React + Vite + Electron frontend
│ └── server/ # Express + WebSocket backend
└── libs/ # Shared packages
├── types/ # Core TypeScript definitions
├── utils/ # Logging, errors, utilities
├── prompts/ # AI prompt templates
├── platform/ # Path management, security
├── model-resolver/ # Claude model aliasing
├── dependency-resolver/ # Feature dependency ordering
└── git-utils/ # Git operations & worktree management
```
### How It Works
1. **Feature Definition** - Users create feature cards on the Kanban board with descriptions, images, and configuration
2. **Git Worktree Creation** - When a feature starts, a git worktree is created for isolated development
3. **Agent Execution** - Claude Agent SDK executes in the worktree with full file system and command access
4. **Real-time Streaming** - Agent output streams via WebSocket to the frontend for live monitoring
5. **Plan Approval** (optional) - For spec/full planning modes, agents generate plans that require user approval
6. **Multi-Agent Tasks** (spec mode) - Each task in the spec gets a dedicated agent for focused implementation
7. **Verification** - Features move to "Waiting Approval" where changes can be reviewed via git diff
8. **Integration** - After approval, changes can be committed and PRs created from the worktree
### Key Architectural Patterns
- **Event-Driven Architecture** - All server operations emit events that stream to the frontend
- **Provider Pattern** - Extensible AI provider system (currently Claude, designed for future providers)
- **Service-Oriented Backend** - Modular services for agent management, features, terminals, settings
- **State Management** - Zustand with persistence for frontend state across restarts
- **File-Based Storage** - No database; features stored as JSON files in `.automaker/` directory
### Security & Isolation
- **Git Worktrees** - Each feature executes in an isolated git worktree, protecting your main branch
- **Path Sandboxing** - Optional `ALLOWED_ROOT_DIRECTORY` restricts file access
- **Docker Isolation** - Recommended deployment uses Docker with no host filesystem access
- **Plan Approval** - Optional plan review before implementation prevents unwanted changes
### Data Storage
Automaker uses a file-based storage system (no database required):
#### Per-Project Data
Stored in `{projectPath}/.automaker/`:
```text
.automaker/
├── features/ # Feature JSON files and images
│ └── {featureId}/
│ ├── feature.json # Feature metadata
│ ├── agent-output.md # AI agent output log
│ └── images/ # Attached images
├── context/ # Context files for AI agents
├── settings.json # Project-specific settings
├── spec.md # Project specification
├── analysis.json # Project structure analysis
└── feature-suggestions.json # AI-generated suggestions
```
#### Global Data
Stored in `DATA_DIR` (default `./data`):
```text
data/
├── settings.json # Global settings, profiles, shortcuts
├── credentials.json # API keys (encrypted)
├── sessions-metadata.json # Chat session metadata
└── agent-sessions/ # Conversation histories
└── {sessionId}.json
```
## Learn More ## Learn More
### Documentation To learn more about Next.js, take a look at the following resources:
- [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Docker Isolation Guide](./docs/docker-isolation.md) - Security-focused Docker deployment - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages
### Community
Join the **Agentic Jumpstart** Discord to connect with other builders exploring **agentic coding**:
👉 [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
## License ## License

1282
apps/app/server-bundle/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
{
"name": "@automaker/server-bundle",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.61",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"morgan": "^1.10.1",
"node-pty": "1.1.0-beta41",
"ws": "^8.18.0"
}
}

View File

@@ -48,8 +48,6 @@ import { createClaudeRoutes } from './routes/claude/index.js';
import { ClaudeUsageService } from './services/claude-usage-service.js'; import { ClaudeUsageService } from './services/claude-usage-service.js';
import { createGitHubRoutes } from './routes/github/index.js'; import { createGitHubRoutes } from './routes/github/index.js';
import { createContextRoutes } from './routes/context/index.js'; import { createContextRoutes } from './routes/context/index.js';
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
// Load environment variables // Load environment variables
dotenv.config(); dotenv.config();
@@ -113,11 +111,10 @@ app.use(express.json({ limit: '50mb' }));
const events: EventEmitter = createEventEmitter(); const events: EventEmitter = createEventEmitter();
// Create services // Create services
// Note: settingsService is created first so it can be injected into other services const agentService = new AgentService(DATA_DIR, events);
const settingsService = new SettingsService(DATA_DIR);
const agentService = new AgentService(DATA_DIR, events, settingsService);
const featureLoader = new FeatureLoader(); const featureLoader = new FeatureLoader();
const autoModeService = new AutoModeService(events, settingsService); const autoModeService = new AutoModeService(events);
const settingsService = new SettingsService(DATA_DIR);
const claudeUsageService = new ClaudeUsageService(); const claudeUsageService = new ClaudeUsageService();
// Initialize services // Initialize services
@@ -126,15 +123,6 @@ const claudeUsageService = new ClaudeUsageService();
console.log('[Server] Agent service initialized'); console.log('[Server] Agent service initialized');
})(); })();
// Run stale validation cleanup every hour to prevent memory leaks from crashed validations
const VALIDATION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
setInterval(() => {
const cleaned = cleanupStaleValidations();
if (cleaned > 0) {
console.log(`[Server] Cleaned up ${cleaned} stale validation entries`);
}
}, VALIDATION_CLEANUP_INTERVAL_MS);
// Mount API routes - health is unauthenticated for monitoring // Mount API routes - health is unauthenticated for monitoring
app.use('/api/health', createHealthRoutes()); app.use('/api/health', createHealthRoutes());
@@ -150,18 +138,17 @@ app.use('/api/enhance-prompt', createEnhancePromptRoutes());
app.use('/api/worktree', createWorktreeRoutes()); app.use('/api/worktree', createWorktreeRoutes());
app.use('/api/git', createGitRoutes()); app.use('/api/git', createGitRoutes());
app.use('/api/setup', createSetupRoutes()); app.use('/api/setup', createSetupRoutes());
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService)); app.use('/api/suggestions', createSuggestionsRoutes(events));
app.use('/api/models', createModelsRoutes()); app.use('/api/models', createModelsRoutes());
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService)); app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events));
app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService)); app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService));
app.use('/api/workspace', createWorkspaceRoutes()); app.use('/api/workspace', createWorkspaceRoutes());
app.use('/api/templates', createTemplatesRoutes()); app.use('/api/templates', createTemplatesRoutes());
app.use('/api/terminal', createTerminalRoutes()); app.use('/api/terminal', createTerminalRoutes());
app.use('/api/settings', createSettingsRoutes(settingsService)); app.use('/api/settings', createSettingsRoutes(settingsService));
app.use('/api/claude', createClaudeRoutes(claudeUsageService)); app.use('/api/claude', createClaudeRoutes(claudeUsageService));
app.use('/api/github', createGitHubRoutes(events, settingsService)); app.use('/api/github', createGitHubRoutes());
app.use('/api/context', createContextRoutes(settingsService)); app.use('/api/context', createContextRoutes());
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
// Create HTTP server // Create HTTP server
const server = createServer(app); const server = createServer(app);
@@ -190,31 +177,12 @@ server.on('upgrade', (request, socket, head) => {
// Events WebSocket connection handler // Events WebSocket connection handler
wss.on('connection', (ws: WebSocket) => { wss.on('connection', (ws: WebSocket) => {
console.log('[WebSocket] Client connected, ready state:', ws.readyState); console.log('[WebSocket] Client connected');
// Subscribe to all events and forward to this client // Subscribe to all events and forward to this client
const unsubscribe = events.subscribe((type, payload) => { const unsubscribe = events.subscribe((type, payload) => {
console.log('[WebSocket] Event received:', {
type,
hasPayload: !!payload,
payloadKeys: payload ? Object.keys(payload) : [],
wsReadyState: ws.readyState,
wsOpen: ws.readyState === WebSocket.OPEN,
});
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
const message = JSON.stringify({ type, payload }); ws.send(JSON.stringify({ type, payload }));
console.log('[WebSocket] Sending event to client:', {
type,
messageLength: message.length,
sessionId: (payload as any)?.sessionId,
});
ws.send(message);
} else {
console.log(
'[WebSocket] WARNING: Cannot send event, WebSocket not open. ReadyState:',
ws.readyState
);
} }
}); });
@@ -224,7 +192,7 @@ wss.on('connection', (ws: WebSocket) => {
}); });
ws.on('error', (error) => { ws.on('error', (error) => {
console.error('[WebSocket] ERROR:', error); console.error('[WebSocket] Error:', error);
unsubscribe(); unsubscribe();
}); });
}); });

View File

@@ -136,59 +136,6 @@ function getBaseOptions(): Partial<Options> {
}; };
} }
/**
* Build system prompt configuration based on autoLoadClaudeMd setting.
* When autoLoadClaudeMd is true:
* - Uses preset mode with 'claude_code' to enable CLAUDE.md auto-loading
* - If there's a custom systemPrompt, appends it to the preset
* - Sets settingSources to ['project'] for SDK to load CLAUDE.md files
*
* @param config - The SDK options config
* @returns Object with systemPrompt and settingSources for SDK options
*/
function buildClaudeMdOptions(config: CreateSdkOptionsConfig): {
systemPrompt?: string | SystemPromptConfig;
settingSources?: Array<'user' | 'project' | 'local'>;
} {
if (!config.autoLoadClaudeMd) {
// Standard mode - just pass through the system prompt as-is
return config.systemPrompt ? { systemPrompt: config.systemPrompt } : {};
}
// Auto-load CLAUDE.md mode - use preset with settingSources
const result: {
systemPrompt: SystemPromptConfig;
settingSources: Array<'user' | 'project' | 'local'>;
} = {
systemPrompt: {
type: 'preset',
preset: 'claude_code',
},
// Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings
settingSources: ['user', 'project'],
};
// If there's a custom system prompt, append it to the preset
if (config.systemPrompt) {
result.systemPrompt.append = config.systemPrompt;
}
return result;
}
/**
* System prompt configuration for SDK options
* When using preset mode with claude_code, CLAUDE.md files are automatically loaded
*/
export interface SystemPromptConfig {
/** Use preset mode with claude_code to enable CLAUDE.md auto-loading */
type: 'preset';
/** The preset to use - 'claude_code' enables CLAUDE.md loading */
preset: 'claude_code';
/** Optional additional prompt to append to the preset */
append?: string;
}
/** /**
* Options configuration for creating SDK options * Options configuration for creating SDK options
*/ */
@@ -213,12 +160,6 @@ export interface CreateSdkOptionsConfig {
type: 'json_schema'; type: 'json_schema';
schema: Record<string, unknown>; schema: Record<string, unknown>;
}; };
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
autoLoadClaudeMd?: boolean;
/** Enable sandbox mode for bash command isolation */
enableSandboxMode?: boolean;
} }
/** /**
@@ -228,15 +169,11 @@ export interface CreateSdkOptionsConfig {
* - Uses read-only tools for codebase analysis * - Uses read-only tools for codebase analysis
* - Extended turns for thorough exploration * - Extended turns for thorough exploration
* - Opus model by default (can be overridden) * - Opus model by default (can be overridden)
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/ */
export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Options { export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options // Validate working directory before creating options
validateWorkingDirectory(config.cwd); validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
return { return {
...getBaseOptions(), ...getBaseOptions(),
// Override permissionMode - spec generation only needs read-only tools // Override permissionMode - spec generation only needs read-only tools
@@ -247,7 +184,7 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt
maxTurns: MAX_TURNS.maximum, maxTurns: MAX_TURNS.maximum,
cwd: config.cwd, cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.specGeneration], allowedTools: [...TOOL_PRESETS.specGeneration],
...claudeMdOptions, ...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }), ...(config.abortController && { abortController: config.abortController }),
...(config.outputFormat && { outputFormat: config.outputFormat }), ...(config.outputFormat && { outputFormat: config.outputFormat }),
}; };
@@ -260,15 +197,11 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt
* - Uses read-only tools (just needs to read the spec) * - Uses read-only tools (just needs to read the spec)
* - Quick turns since it's mostly JSON generation * - Quick turns since it's mostly JSON generation
* - Sonnet model by default for speed * - Sonnet model by default for speed
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/ */
export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig): Options { export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options // Validate working directory before creating options
validateWorkingDirectory(config.cwd); validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
return { return {
...getBaseOptions(), ...getBaseOptions(),
// Override permissionMode - feature generation only needs read-only tools // Override permissionMode - feature generation only needs read-only tools
@@ -277,7 +210,7 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
maxTurns: MAX_TURNS.quick, maxTurns: MAX_TURNS.quick,
cwd: config.cwd, cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly], allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions, ...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }), ...(config.abortController && { abortController: config.abortController }),
}; };
} }
@@ -289,22 +222,18 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
* - Uses read-only tools for analysis * - Uses read-only tools for analysis
* - Standard turns to allow thorough codebase exploration and structured output generation * - Standard turns to allow thorough codebase exploration and structured output generation
* - Opus model by default for thorough analysis * - Opus model by default for thorough analysis
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/ */
export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Options { export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options // Validate working directory before creating options
validateWorkingDirectory(config.cwd); validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
return { return {
...getBaseOptions(), ...getBaseOptions(),
model: getModelForUseCase('suggestions', config.model), model: getModelForUseCase('suggestions', config.model),
maxTurns: MAX_TURNS.extended, maxTurns: MAX_TURNS.extended,
cwd: config.cwd, cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly], allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions, ...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }), ...(config.abortController && { abortController: config.abortController }),
...(config.outputFormat && { outputFormat: config.outputFormat }), ...(config.outputFormat && { outputFormat: config.outputFormat }),
}; };
@@ -317,8 +246,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
* - Full tool access for code modification * - Full tool access for code modification
* - Standard turns for interactive sessions * - Standard turns for interactive sessions
* - Model priority: explicit model > session model > chat default * - Model priority: explicit model > session model > chat default
* - Sandbox mode controlled by enableSandboxMode setting * - Sandbox enabled for bash safety
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/ */
export function createChatOptions(config: CreateSdkOptionsConfig): Options { export function createChatOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options // Validate working directory before creating options
@@ -327,22 +255,17 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
// Model priority: explicit model > session model > chat default // Model priority: explicit model > session model > chat default
const effectiveModel = config.model || config.sessionModel; const effectiveModel = config.model || config.sessionModel;
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
return { return {
...getBaseOptions(), ...getBaseOptions(),
model: getModelForUseCase('chat', effectiveModel), model: getModelForUseCase('chat', effectiveModel),
maxTurns: MAX_TURNS.standard, maxTurns: MAX_TURNS.standard,
cwd: config.cwd, cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.chat], allowedTools: [...TOOL_PRESETS.chat],
...(config.enableSandboxMode && {
sandbox: { sandbox: {
enabled: true, enabled: true,
autoAllowBashIfSandboxed: true, autoAllowBashIfSandboxed: true,
}, },
}), ...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...claudeMdOptions,
...(config.abortController && { abortController: config.abortController }), ...(config.abortController && { abortController: config.abortController }),
}; };
} }
@@ -354,29 +277,23 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
* - Full tool access for code modification and implementation * - Full tool access for code modification and implementation
* - Extended turns for thorough feature implementation * - Extended turns for thorough feature implementation
* - Uses default model (can be overridden) * - Uses default model (can be overridden)
* - Sandbox mode controlled by enableSandboxMode setting * - Sandbox enabled for bash safety
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/ */
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options // Validate working directory before creating options
validateWorkingDirectory(config.cwd); validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
return { return {
...getBaseOptions(), ...getBaseOptions(),
model: getModelForUseCase('auto', config.model), model: getModelForUseCase('auto', config.model),
maxTurns: MAX_TURNS.maximum, maxTurns: MAX_TURNS.maximum,
cwd: config.cwd, cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.fullAccess], allowedTools: [...TOOL_PRESETS.fullAccess],
...(config.enableSandboxMode && {
sandbox: { sandbox: {
enabled: true, enabled: true,
autoAllowBashIfSandboxed: true, autoAllowBashIfSandboxed: true,
}, },
}), ...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...claudeMdOptions,
...(config.abortController && { abortController: config.abortController }), ...(config.abortController && { abortController: config.abortController }),
}; };
} }
@@ -385,7 +302,6 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
* Create custom SDK options with explicit configuration * Create custom SDK options with explicit configuration
* *
* Use this when the preset options don't fit your use case. * Use this when the preset options don't fit your use case.
* When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/ */
export function createCustomOptions( export function createCustomOptions(
config: CreateSdkOptionsConfig & { config: CreateSdkOptionsConfig & {
@@ -397,9 +313,6 @@ export function createCustomOptions(
// Validate working directory before creating options // Validate working directory before creating options
validateWorkingDirectory(config.cwd); validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
return { return {
...getBaseOptions(), ...getBaseOptions(),
model: getModelForUseCase('default', config.model), model: getModelForUseCase('default', config.model),
@@ -407,7 +320,7 @@ export function createCustomOptions(
cwd: config.cwd, cwd: config.cwd,
allowedTools: config.allowedTools ? [...config.allowedTools] : [...TOOL_PRESETS.readOnly], allowedTools: config.allowedTools ? [...config.allowedTools] : [...TOOL_PRESETS.readOnly],
...(config.sandbox && { sandbox: config.sandbox }), ...(config.sandbox && { sandbox: config.sandbox }),
...claudeMdOptions, ...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }), ...(config.abortController && { abortController: config.abortController }),
}; };
} }

View File

@@ -20,9 +20,4 @@ export const {
lstat, lstat,
joinPath, joinPath,
resolvePath, resolvePath,
// Throttling configuration and monitoring
configureThrottling,
getThrottlingConfig,
getPendingOperations,
getActiveOperations,
} = secureFs; } = secureFs;

View File

@@ -1,138 +0,0 @@
/**
* Helper utilities for loading settings and context file handling across different parts of the server
*/
import type { SettingsService } from '../services/settings-service.js';
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
/**
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
* Returns false if settings service is not available.
*
* @param projectPath - Path to the project
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[DescribeImage]')
* @returns Promise resolving to the autoLoadClaudeMd setting value
*/
export async function getAutoLoadClaudeMdSetting(
projectPath: string,
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<boolean> {
if (!settingsService) {
console.log(`${logPrefix} SettingsService not available, autoLoadClaudeMd disabled`);
return false;
}
try {
// Check project settings first (takes precedence)
const projectSettings = await settingsService.getProjectSettings(projectPath);
if (projectSettings.autoLoadClaudeMd !== undefined) {
console.log(
`${logPrefix} autoLoadClaudeMd from project settings: ${projectSettings.autoLoadClaudeMd}`
);
return projectSettings.autoLoadClaudeMd;
}
// Fall back to global settings
const globalSettings = await settingsService.getGlobalSettings();
const result = globalSettings.autoLoadClaudeMd ?? false;
console.log(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
return result;
} catch (error) {
console.error(`${logPrefix} Failed to load autoLoadClaudeMd setting:`, error);
throw error;
}
}
/**
* Get the enableSandboxMode setting from global settings.
* Returns false if settings service is not available.
*
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
* @returns Promise resolving to the enableSandboxMode setting value
*/
export async function getEnableSandboxModeSetting(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<boolean> {
if (!settingsService) {
console.log(`${logPrefix} SettingsService not available, sandbox mode disabled`);
return false;
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const result = globalSettings.enableSandboxMode ?? true;
console.log(`${logPrefix} enableSandboxMode from global settings: ${result}`);
return result;
} catch (error) {
console.error(`${logPrefix} Failed to load enableSandboxMode setting:`, error);
throw error;
}
}
/**
* Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled
* and rebuilds the formatted prompt without it.
*
* When autoLoadClaudeMd is true, the SDK handles CLAUDE.md loading via settingSources,
* so we need to exclude it from the manual context loading to avoid duplication.
* Other context files (CODE_QUALITY.md, CONVENTIONS.md, etc.) are preserved.
*
* @param contextResult - Result from loadContextFiles
* @param autoLoadClaudeMd - Whether SDK auto-loading is enabled
* @returns Filtered context prompt (empty string if no non-CLAUDE.md files)
*/
export function filterClaudeMdFromContext(
contextResult: ContextFilesResult,
autoLoadClaudeMd: boolean
): string {
// If autoLoadClaudeMd is disabled, return the original prompt unchanged
if (!autoLoadClaudeMd || contextResult.files.length === 0) {
return contextResult.formattedPrompt;
}
// Filter out CLAUDE.md (case-insensitive)
const nonClaudeFiles = contextResult.files.filter((f) => f.name.toLowerCase() !== 'claude.md');
// If all files were CLAUDE.md, return empty string
if (nonClaudeFiles.length === 0) {
return '';
}
// Rebuild prompt without CLAUDE.md using the same format as loadContextFiles
const formattedFiles = nonClaudeFiles.map((file) => formatContextFileEntry(file));
return `# Project Context Files
The following context files provide project-specific rules, conventions, and guidelines.
Each file serves a specific purpose - use the description to understand when to reference it.
If you need more details about a context file, you can read the full file at the path provided.
**IMPORTANT**: You MUST follow the rules and conventions specified in these files.
- Follow ALL commands exactly as shown (e.g., if the project uses \`pnpm\`, NEVER use \`npm\` or \`npx\`)
- Follow ALL coding conventions, commit message formats, and architectural patterns specified
- Reference these rules before running ANY shell commands or making commits
---
${formattedFiles.join('\n\n---\n\n')}
---
**REMINDER**: Before taking any action, verify you are following the conventions specified above.
`;
}
/**
* Format a single context file entry for the prompt
* (Matches the format used in @automaker/utils/context-loader.ts)
*/
function formatContextFileEntry(file: ContextFileInfo): string {
const header = `## ${file.name}`;
const pathInfo = `**Path:** \`${file.path}\``;
const descriptionInfo = file.description ? `\n**Purpose:** ${file.description}` : '';
return `${header}\n${pathInfo}${descriptionInfo}\n\n${file.content}`;
}

View File

@@ -1,181 +0,0 @@
/**
* Validation Storage - CRUD operations for GitHub issue validation results
*
* Stores validation results in .automaker/validations/{issueNumber}/validation.json
* Results include the validation verdict, metadata, and timestamp for cache invalidation.
*/
import * as secureFs from './secure-fs.js';
import { getValidationsDir, getValidationDir, getValidationPath } from '@automaker/platform';
import type { StoredValidation } from '@automaker/types';
// Re-export StoredValidation for convenience
export type { StoredValidation };
/** Number of hours before a validation is considered stale */
const VALIDATION_CACHE_TTL_HOURS = 24;
/**
* Write validation result to storage
*
* Creates the validation directory if needed and stores the result as JSON.
*
* @param projectPath - Absolute path to project directory
* @param issueNumber - GitHub issue number
* @param data - Validation data to store
*/
export async function writeValidation(
projectPath: string,
issueNumber: number,
data: StoredValidation
): Promise<void> {
const validationDir = getValidationDir(projectPath, issueNumber);
const validationPath = getValidationPath(projectPath, issueNumber);
// Ensure directory exists
await secureFs.mkdir(validationDir, { recursive: true });
// Write validation result
await secureFs.writeFile(validationPath, JSON.stringify(data, null, 2), 'utf-8');
}
/**
* Read validation result from storage
*
* @param projectPath - Absolute path to project directory
* @param issueNumber - GitHub issue number
* @returns Stored validation or null if not found
*/
export async function readValidation(
projectPath: string,
issueNumber: number
): Promise<StoredValidation | null> {
try {
const validationPath = getValidationPath(projectPath, issueNumber);
const content = (await secureFs.readFile(validationPath, 'utf-8')) as string;
return JSON.parse(content) as StoredValidation;
} catch {
// File doesn't exist or can't be read
return null;
}
}
/**
* Get all stored validations for a project
*
* @param projectPath - Absolute path to project directory
* @returns Array of stored validations
*/
export async function getAllValidations(projectPath: string): Promise<StoredValidation[]> {
const validationsDir = getValidationsDir(projectPath);
try {
const dirs = await secureFs.readdir(validationsDir, { withFileTypes: true });
// Read all validation files in parallel for better performance
const promises = dirs
.filter((dir) => dir.isDirectory())
.map((dir) => {
const issueNumber = parseInt(dir.name, 10);
if (!isNaN(issueNumber)) {
return readValidation(projectPath, issueNumber);
}
return Promise.resolve(null);
});
const results = await Promise.all(promises);
const validations = results.filter((v): v is StoredValidation => v !== null);
// Sort by issue number
validations.sort((a, b) => a.issueNumber - b.issueNumber);
return validations;
} catch {
// Directory doesn't exist
return [];
}
}
/**
* Delete a validation from storage
*
* @param projectPath - Absolute path to project directory
* @param issueNumber - GitHub issue number
* @returns true if validation was deleted, false if not found
*/
export async function deleteValidation(projectPath: string, issueNumber: number): Promise<boolean> {
try {
const validationDir = getValidationDir(projectPath, issueNumber);
await secureFs.rm(validationDir, { recursive: true, force: true });
return true;
} catch {
return false;
}
}
/**
* Check if a validation is stale (older than TTL)
*
* @param validation - Stored validation to check
* @returns true if validation is older than 24 hours
*/
export function isValidationStale(validation: StoredValidation): boolean {
const validatedAt = new Date(validation.validatedAt);
const now = new Date();
const hoursDiff = (now.getTime() - validatedAt.getTime()) / (1000 * 60 * 60);
return hoursDiff > VALIDATION_CACHE_TTL_HOURS;
}
/**
* Get validation with freshness info
*
* @param projectPath - Absolute path to project directory
* @param issueNumber - GitHub issue number
* @returns Object with validation and isStale flag, or null if not found
*/
export async function getValidationWithFreshness(
projectPath: string,
issueNumber: number
): Promise<{ validation: StoredValidation; isStale: boolean } | null> {
const validation = await readValidation(projectPath, issueNumber);
if (!validation) {
return null;
}
return {
validation,
isStale: isValidationStale(validation),
};
}
/**
* Mark a validation as viewed by the user
*
* @param projectPath - Absolute path to project directory
* @param issueNumber - GitHub issue number
* @returns true if validation was marked as viewed, false if not found
*/
export async function markValidationViewed(
projectPath: string,
issueNumber: number
): Promise<boolean> {
const validation = await readValidation(projectPath, issueNumber);
if (!validation) {
return false;
}
validation.viewedAt = new Date().toISOString();
await writeValidation(projectPath, issueNumber, validation);
return true;
}
/**
* Get count of unviewed, non-stale validations for a project
*
* @param projectPath - Absolute path to project directory
* @returns Number of unviewed validations
*/
export async function getUnviewedValidationsCount(projectPath: string): Promise<number> {
const validations = await getAllValidations(projectPath);
return validations.filter((v) => !v.viewedAt && !isValidationStale(v)).length;
}

View File

@@ -45,16 +45,16 @@ export class ClaudeProvider extends BaseProvider {
maxTurns, maxTurns,
cwd, cwd,
allowedTools: toolsToUse, allowedTools: toolsToUse,
permissionMode: 'default', permissionMode: 'acceptEdits',
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
abortController, abortController,
// Resume existing SDK session if we have a session ID // Resume existing SDK session if we have a session ID
...(sdkSessionId && conversationHistory && conversationHistory.length > 0 ...(sdkSessionId && conversationHistory && conversationHistory.length > 0
? { resume: sdkSessionId } ? { resume: sdkSessionId }
: {}), : {}),
// Forward settingSources for CLAUDE.md file loading
...(options.settingSources && { settingSources: options.settingSources }),
// Forward sandbox configuration
...(options.sandbox && { sandbox: options.sandbox }),
}; };
// Build prompt payload // Build prompt payload
@@ -88,8 +88,7 @@ export class ClaudeProvider extends BaseProvider {
yield msg as ProviderMessage; yield msg as ProviderMessage;
} }
} catch (error) { } catch (error) {
console.error('[ClaudeProvider] ERROR: executeQuery() error during execution:', error); console.error('[ClaudeProvider] executeQuery() error during execution:', error);
console.error('[ClaudeProvider] ERROR stack:', (error as Error).stack);
throw error; throw error;
} }
} }

View File

@@ -26,15 +26,13 @@ export interface ExecuteOptions {
prompt: string | Array<{ type: string; text?: string; source?: object }>; prompt: string | Array<{ type: string; text?: string; source?: object }>;
model: string; model: string;
cwd: string; cwd: string;
systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: string }; systemPrompt?: string;
maxTurns?: number; maxTurns?: number;
allowedTools?: string[]; allowedTools?: string[];
mcpServers?: Record<string, unknown>; mcpServers?: Record<string, unknown>;
abortController?: AbortController; abortController?: AbortController;
conversationHistory?: ConversationMessage[]; // Previous messages for context conversationHistory?: ConversationMessage[]; // Previous messages for context
sdkSessionId?: string; // Claude SDK session ID for resuming conversations sdkSessionId?: string; // Claude SDK session ID for resuming conversations
settingSources?: Array<'user' | 'project' | 'local'>; // Claude filesystem settings to load
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean }; // Sandbox configuration
} }
/** /**

View File

@@ -12,10 +12,6 @@ import { createHistoryHandler } from './routes/history.js';
import { createStopHandler } from './routes/stop.js'; import { createStopHandler } from './routes/stop.js';
import { createClearHandler } from './routes/clear.js'; import { createClearHandler } from './routes/clear.js';
import { createModelHandler } from './routes/model.js'; import { createModelHandler } from './routes/model.js';
import { createQueueAddHandler } from './routes/queue-add.js';
import { createQueueListHandler } from './routes/queue-list.js';
import { createQueueRemoveHandler } from './routes/queue-remove.js';
import { createQueueClearHandler } from './routes/queue-clear.js';
export function createAgentRoutes(agentService: AgentService, _events: EventEmitter): Router { export function createAgentRoutes(agentService: AgentService, _events: EventEmitter): Router {
const router = Router(); const router = Router();
@@ -31,15 +27,5 @@ export function createAgentRoutes(agentService: AgentService, _events: EventEmit
router.post('/clear', createClearHandler(agentService)); router.post('/clear', createClearHandler(agentService));
router.post('/model', createModelHandler(agentService)); router.post('/model', createModelHandler(agentService));
// Queue routes
router.post(
'/queue/add',
validatePathParams('imagePaths[]'),
createQueueAddHandler(agentService)
);
router.post('/queue/list', createQueueListHandler(agentService));
router.post('/queue/remove', createQueueRemoveHandler(agentService));
router.post('/queue/clear', createQueueClearHandler(agentService));
return router; return router;
} }

View File

@@ -1,34 +0,0 @@
/**
* POST /queue/add endpoint - Add a prompt to the queue
*/
import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createQueueAddHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sessionId, message, imagePaths, model } = req.body as {
sessionId: string;
message: string;
imagePaths?: string[];
model?: string;
};
if (!sessionId || !message) {
res.status(400).json({
success: false,
error: 'sessionId and message are required',
});
return;
}
const result = await agentService.addToQueue(sessionId, { message, imagePaths, model });
res.json(result);
} catch (error) {
logError(error, 'Add to queue failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,29 +0,0 @@
/**
* POST /queue/clear endpoint - Clear all prompts from the queue
*/
import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createQueueClearHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sessionId } = req.body as { sessionId: string };
if (!sessionId) {
res.status(400).json({
success: false,
error: 'sessionId is required',
});
return;
}
const result = await agentService.clearQueue(sessionId);
res.json(result);
} catch (error) {
logError(error, 'Clear queue failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,29 +0,0 @@
/**
* POST /queue/list endpoint - List queued prompts
*/
import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createQueueListHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sessionId } = req.body as { sessionId: string };
if (!sessionId) {
res.status(400).json({
success: false,
error: 'sessionId is required',
});
return;
}
const result = agentService.getQueue(sessionId);
res.json(result);
} catch (error) {
logError(error, 'List queue failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,32 +0,0 @@
/**
* POST /queue/remove endpoint - Remove a prompt from the queue
*/
import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createQueueRemoveHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sessionId, promptId } = req.body as {
sessionId: string;
promptId: string;
};
if (!sessionId || !promptId) {
res.status(400).json({
success: false,
error: 'sessionId and promptId are required',
});
return;
}
const result = await agentService.removeFromQueue(sessionId, promptId);
res.json(result);
} catch (error) {
logError(error, 'Remove from queue failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -19,16 +19,7 @@ export function createSendHandler(agentService: AgentService) {
model?: string; model?: string;
}; };
console.log('[Send Handler] Received request:', {
sessionId,
messageLength: message?.length,
workingDirectory,
imageCount: imagePaths?.length || 0,
model,
});
if (!sessionId || !message) { if (!sessionId || !message) {
console.log('[Send Handler] ERROR: Validation failed - missing sessionId or message');
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: 'sessionId and message are required', error: 'sessionId and message are required',
@@ -36,8 +27,6 @@ export function createSendHandler(agentService: AgentService) {
return; return;
} }
console.log('[Send Handler] Validation passed, calling agentService.sendMessage()');
// Start the message processing (don't await - it streams via WebSocket) // Start the message processing (don't await - it streams via WebSocket)
agentService agentService
.sendMessage({ .sendMessage({
@@ -48,16 +37,12 @@ export function createSendHandler(agentService: AgentService) {
model, model,
}) })
.catch((error) => { .catch((error) => {
console.error('[Send Handler] ERROR: Background error in sendMessage():', error);
logError(error, 'Send message failed (background)'); logError(error, 'Send message failed (background)');
}); });
console.log('[Send Handler] Returning immediate response to client');
// Return immediately - responses come via WebSocket // Return immediately - responses come via WebSocket
res.json({ success: true, message: 'Message sent' }); res.json({ success: true, message: 'Message sent' });
} catch (error) { } catch (error) {
console.error('[Send Handler] ERROR: Synchronous error:', error);
logError(error, 'Send message failed'); logError(error, 'Send message failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }

View File

@@ -10,8 +10,6 @@ import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
import { logAuthStatus } from './common.js'; import { logAuthStatus } from './common.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js'; 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 { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
@@ -21,8 +19,7 @@ export async function generateFeaturesFromSpec(
projectPath: string, projectPath: string,
events: EventEmitter, events: EventEmitter,
abortController: AbortController, abortController: AbortController,
maxFeatures?: number, maxFeatures?: number
settingsService?: SettingsService
): Promise<void> { ): Promise<void> {
const featureCount = maxFeatures ?? DEFAULT_MAX_FEATURES; const featureCount = maxFeatures ?? DEFAULT_MAX_FEATURES;
logger.debug('========== generateFeaturesFromSpec() started =========='); logger.debug('========== generateFeaturesFromSpec() started ==========');
@@ -94,17 +91,9 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
projectPath: projectPath, projectPath: projectPath,
}); });
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[FeatureGeneration]'
);
const options = createFeatureGenerationOptions({ const options = createFeatureGenerationOptions({
cwd: projectPath, cwd: projectPath,
abortController, abortController,
autoLoadClaudeMd,
}); });
logger.debug('SDK Options:', JSON.stringify(options, null, 2)); logger.debug('SDK Options:', JSON.stringify(options, null, 2));

View File

@@ -17,8 +17,6 @@ import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
import { logAuthStatus } from './common.js'; import { logAuthStatus } from './common.js';
import { generateFeaturesFromSpec } from './generate-features-from-spec.js'; import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform'; import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
@@ -29,8 +27,7 @@ export async function generateSpec(
abortController: AbortController, abortController: AbortController,
generateFeatures?: boolean, generateFeatures?: boolean,
analyzeProject?: boolean, analyzeProject?: boolean,
maxFeatures?: number, maxFeatures?: number
settingsService?: SettingsService
): Promise<void> { ): Promise<void> {
logger.info('========== generateSpec() started =========='); logger.info('========== generateSpec() started ==========');
logger.info('projectPath:', projectPath); logger.info('projectPath:', projectPath);
@@ -86,17 +83,9 @@ ${getStructuredSpecPromptInstruction()}`;
content: 'Starting spec generation...\n', content: 'Starting spec generation...\n',
}); });
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[SpecRegeneration]'
);
const options = createSpecGenerationOptions({ const options = createSpecGenerationOptions({
cwd: projectPath, cwd: projectPath,
abortController, abortController,
autoLoadClaudeMd,
outputFormat: { outputFormat: {
type: 'json_schema', type: 'json_schema',
schema: specOutputSchema, schema: specOutputSchema,
@@ -280,13 +269,7 @@ ${getStructuredSpecPromptInstruction()}`;
// Create a new abort controller for feature generation // Create a new abort controller for feature generation
const featureAbortController = new AbortController(); const featureAbortController = new AbortController();
try { try {
await generateFeaturesFromSpec( await generateFeaturesFromSpec(projectPath, events, featureAbortController, maxFeatures);
projectPath,
events,
featureAbortController,
maxFeatures,
settingsService
);
// Final completion will be emitted by generateFeaturesFromSpec -> parseAndCreateFeatures // Final completion will be emitted by generateFeaturesFromSpec -> parseAndCreateFeatures
} catch (featureError) { } catch (featureError) {
logger.error('Feature generation failed:', featureError); logger.error('Feature generation failed:', featureError);

View File

@@ -9,17 +9,13 @@ import { createGenerateHandler } from './routes/generate.js';
import { createGenerateFeaturesHandler } from './routes/generate-features.js'; import { createGenerateFeaturesHandler } from './routes/generate-features.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';
export function createSpecRegenerationRoutes( export function createSpecRegenerationRoutes(events: EventEmitter): Router {
events: EventEmitter,
settingsService?: SettingsService
): Router {
const router = Router(); const router = Router();
router.post('/create', createCreateHandler(events)); router.post('/create', createCreateHandler(events));
router.post('/generate', createGenerateHandler(events, settingsService)); router.post('/generate', createGenerateHandler(events));
router.post('/generate-features', createGenerateFeaturesHandler(events, settingsService)); router.post('/generate-features', createGenerateFeaturesHandler(events));
router.post('/stop', createStopHandler()); router.post('/stop', createStopHandler());
router.get('/status', createStatusHandler()); router.get('/status', createStatusHandler());

View File

@@ -13,14 +13,10 @@ import {
getErrorMessage, getErrorMessage,
} from '../common.js'; } from '../common.js';
import { generateFeaturesFromSpec } from '../generate-features-from-spec.js'; import { generateFeaturesFromSpec } from '../generate-features-from-spec.js';
import type { SettingsService } from '../../../services/settings-service.js';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
export function createGenerateFeaturesHandler( export function createGenerateFeaturesHandler(events: EventEmitter) {
events: EventEmitter,
settingsService?: SettingsService
) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
logger.info('========== /generate-features endpoint called =========='); logger.info('========== /generate-features endpoint called ==========');
logger.debug('Request body:', JSON.stringify(req.body, null, 2)); logger.debug('Request body:', JSON.stringify(req.body, null, 2));
@@ -53,7 +49,7 @@ export function createGenerateFeaturesHandler(
setRunningState(true, abortController); setRunningState(true, abortController);
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)
.catch((error) => { .catch((error) => {
logError(error, 'Feature generation failed with error'); logError(error, 'Feature generation failed with error');
events.emit('spec-regeneration:event', { events.emit('spec-regeneration:event', {

View File

@@ -13,11 +13,10 @@ import {
getErrorMessage, getErrorMessage,
} from '../common.js'; } from '../common.js';
import { generateSpec } from '../generate-spec.js'; import { generateSpec } from '../generate-spec.js';
import type { SettingsService } from '../../../services/settings-service.js';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) { export function createGenerateHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
logger.info('========== /generate endpoint called =========='); logger.info('========== /generate endpoint called ==========');
logger.debug('Request body:', JSON.stringify(req.body, null, 2)); logger.debug('Request body:', JSON.stringify(req.body, null, 2));
@@ -68,8 +67,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
abortController, abortController,
generateFeatures, generateFeatures,
analyzeProject, analyzeProject,
maxFeatures, maxFeatures
settingsService
) )
.catch((error) => { .catch((error) => {
logError(error, 'Generation failed with error'); logError(error, 'Generation failed with error');

View File

@@ -1,39 +0,0 @@
/**
* Common utilities for backlog plan routes
*/
import { createLogger } from '@automaker/utils';
const logger = createLogger('BacklogPlan');
// State for tracking running generation
let isRunning = false;
let currentAbortController: AbortController | null = null;
export function getBacklogPlanStatus(): { isRunning: boolean } {
return { isRunning };
}
export function setRunningState(running: boolean, abortController?: AbortController | null): void {
isRunning = running;
if (abortController !== undefined) {
currentAbortController = abortController;
}
}
export function getAbortController(): AbortController | null {
return currentAbortController;
}
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function logError(error: unknown, context: string): void {
logger.error(`[BacklogPlan] ${context}:`, getErrorMessage(error));
}
export { logger };

View File

@@ -1,217 +0,0 @@
/**
* Generate backlog plan using Claude AI
*/
import type { EventEmitter } from '../../lib/events.js';
import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types';
import { FeatureLoader } from '../../services/feature-loader.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { logger, setRunningState, getErrorMessage } from './common.js';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
const featureLoader = new FeatureLoader();
/**
* Format features for the AI prompt
*/
function formatFeaturesForPrompt(features: Feature[]): string {
if (features.length === 0) {
return 'No features in backlog yet.';
}
return features
.map((f) => {
const deps = f.dependencies?.length ? `Dependencies: [${f.dependencies.join(', ')}]` : '';
const priority = f.priority !== undefined ? `Priority: ${f.priority}` : '';
return `- ID: ${f.id}
Title: ${f.title || 'Untitled'}
Description: ${f.description}
Category: ${f.category}
Status: ${f.status || 'backlog'}
${priority}
${deps}`.trim();
})
.join('\n\n');
}
/**
* Parse the AI response into a BacklogPlanResult
*/
function parsePlanResponse(response: string): BacklogPlanResult {
try {
// Try to extract JSON from the response
const jsonMatch = response.match(/```json\n?([\s\S]*?)\n?```/);
if (jsonMatch) {
return JSON.parse(jsonMatch[1]);
}
// Try to parse the whole response as JSON
return JSON.parse(response);
} catch {
// If parsing fails, return an empty result
logger.warn('[BacklogPlan] Failed to parse AI response as JSON');
return {
changes: [],
summary: 'Failed to parse AI response',
dependencyUpdates: [],
};
}
}
/**
* Generate a backlog modification plan based on user prompt
*/
export async function generateBacklogPlan(
projectPath: string,
prompt: string,
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService,
model?: string
): Promise<BacklogPlanResult> {
try {
// Load current features
const features = await featureLoader.getAll(projectPath);
events.emit('backlog-plan:event', {
type: 'backlog_plan_progress',
content: `Loaded ${features.length} features from backlog`,
});
// Build the system prompt
const systemPrompt = `You are an AI assistant helping to modify a software project's feature backlog.
You will be given the current list of features and a user request to modify the backlog.
IMPORTANT CONTEXT (automatically injected):
- Remember to update the dependency graph if deleting existing features
- Remember to define dependencies on new features hooked into relevant existing ones
- Maintain dependency graph integrity (no orphaned dependencies)
- When deleting a feature, identify which other features depend on it
Your task is to analyze the request and produce a structured JSON plan with:
1. Features to ADD (include title, description, category, and dependencies)
2. Features to UPDATE (specify featureId and the updates)
3. Features to DELETE (specify featureId)
4. A summary of the changes
5. Any dependency updates needed (removed dependencies due to deletions, new dependencies for new features)
Respond with ONLY a JSON object in this exact format:
\`\`\`json
{
"changes": [
{
"type": "add",
"feature": {
"title": "Feature title",
"description": "Feature description",
"category": "Category name",
"dependencies": ["existing-feature-id"],
"priority": 1
},
"reason": "Why this feature should be added"
},
{
"type": "update",
"featureId": "existing-feature-id",
"feature": {
"title": "Updated title"
},
"reason": "Why this feature should be updated"
},
{
"type": "delete",
"featureId": "feature-id-to-delete",
"reason": "Why this feature should be deleted"
}
],
"summary": "Brief overview of all proposed changes",
"dependencyUpdates": [
{
"featureId": "feature-that-depended-on-deleted",
"removedDependencies": ["deleted-feature-id"],
"addedDependencies": []
}
]
}
\`\`\``;
// Build the user prompt
const userPrompt = `Current Features in Backlog:
${formatFeaturesForPrompt(features)}
---
User Request: ${prompt}
Please analyze the current backlog and the user's request, then provide a JSON plan for the modifications.`;
events.emit('backlog-plan:event', {
type: 'backlog_plan_progress',
content: 'Generating plan with AI...',
});
// Get the model to use
const effectiveModel = model || 'sonnet';
const provider = ProviderFactory.getProviderForModel(effectiveModel);
// Get autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[BacklogPlan]'
);
// Execute the query
const stream = provider.executeQuery({
prompt: userPrompt,
model: effectiveModel,
cwd: projectPath,
systemPrompt,
maxTurns: 1,
allowedTools: [], // No tools needed for this
abortController,
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
});
let responseText = '';
for await (const msg of stream) {
if (abortController.signal.aborted) {
throw new Error('Generation aborted');
}
if (msg.type === 'assistant') {
if (msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
}
}
}
}
}
// Parse the response
const result = parsePlanResponse(responseText);
events.emit('backlog-plan:event', {
type: 'backlog_plan_complete',
result,
});
return result;
} catch (error) {
const errorMessage = getErrorMessage(error);
logger.error('[BacklogPlan] Generation failed:', errorMessage);
events.emit('backlog-plan:event', {
type: 'backlog_plan_error',
error: errorMessage,
});
throw error;
} finally {
setRunningState(false, null);
}
}

View File

@@ -1,30 +0,0 @@
/**
* Backlog Plan routes - HTTP API for AI-assisted backlog modification
*/
import { Router } from 'express';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createGenerateHandler } from './routes/generate.js';
import { createStopHandler } from './routes/stop.js';
import { createStatusHandler } from './routes/status.js';
import { createApplyHandler } from './routes/apply.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createBacklogPlanRoutes(
events: EventEmitter,
settingsService?: SettingsService
): Router {
const router = Router();
router.post(
'/generate',
validatePathParams('projectPath'),
createGenerateHandler(events, settingsService)
);
router.post('/stop', createStopHandler());
router.get('/status', createStatusHandler());
router.post('/apply', validatePathParams('projectPath'), createApplyHandler());
return router;
}

View File

@@ -1,147 +0,0 @@
/**
* POST /apply endpoint - Apply a backlog plan
*/
import type { Request, Response } from 'express';
import type { BacklogPlanResult, BacklogChange, Feature } from '@automaker/types';
import { FeatureLoader } from '../../../services/feature-loader.js';
import { getErrorMessage, logError, logger } from '../common.js';
const featureLoader = new FeatureLoader();
export function createApplyHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, plan } = req.body as {
projectPath: string;
plan: BacklogPlanResult;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
if (!plan || !plan.changes) {
res.status(400).json({ success: false, error: 'plan with changes required' });
return;
}
const appliedChanges: string[] = [];
// Load current features for dependency validation
const allFeatures = await featureLoader.getAll(projectPath);
const featureMap = new Map(allFeatures.map((f) => [f.id, f]));
// Process changes in order: deletes first, then adds, then updates
// This ensures we can remove dependencies before they cause issues
// 1. First pass: Handle deletes
const deletions = plan.changes.filter((c) => c.type === 'delete');
for (const change of deletions) {
if (!change.featureId) continue;
try {
// Before deleting, update any features that depend on this one
for (const feature of allFeatures) {
if (feature.dependencies?.includes(change.featureId)) {
const newDeps = feature.dependencies.filter((d) => d !== change.featureId);
await featureLoader.update(projectPath, feature.id, { dependencies: newDeps });
logger.info(
`[BacklogPlan] Removed dependency ${change.featureId} from ${feature.id}`
);
}
}
// Now delete the feature
const deleted = await featureLoader.delete(projectPath, change.featureId);
if (deleted) {
appliedChanges.push(`deleted:${change.featureId}`);
featureMap.delete(change.featureId);
logger.info(`[BacklogPlan] Deleted feature ${change.featureId}`);
}
} catch (error) {
logger.error(
`[BacklogPlan] Failed to delete ${change.featureId}:`,
getErrorMessage(error)
);
}
}
// 2. Second pass: Handle adds
const additions = plan.changes.filter((c) => c.type === 'add');
for (const change of additions) {
if (!change.feature) continue;
try {
// Create the new feature
const newFeature = await featureLoader.create(projectPath, {
title: change.feature.title,
description: change.feature.description || '',
category: change.feature.category || 'Uncategorized',
dependencies: change.feature.dependencies,
priority: change.feature.priority,
status: 'backlog',
});
appliedChanges.push(`added:${newFeature.id}`);
featureMap.set(newFeature.id, newFeature);
logger.info(`[BacklogPlan] Created feature ${newFeature.id}: ${newFeature.title}`);
} catch (error) {
logger.error(`[BacklogPlan] Failed to add feature:`, getErrorMessage(error));
}
}
// 3. Third pass: Handle updates
const updates = plan.changes.filter((c) => c.type === 'update');
for (const change of updates) {
if (!change.featureId || !change.feature) continue;
try {
const updated = await featureLoader.update(projectPath, change.featureId, change.feature);
appliedChanges.push(`updated:${change.featureId}`);
featureMap.set(change.featureId, updated);
logger.info(`[BacklogPlan] Updated feature ${change.featureId}`);
} catch (error) {
logger.error(
`[BacklogPlan] Failed to update ${change.featureId}:`,
getErrorMessage(error)
);
}
}
// 4. Apply dependency updates from the plan
if (plan.dependencyUpdates) {
for (const depUpdate of plan.dependencyUpdates) {
try {
const feature = featureMap.get(depUpdate.featureId);
if (feature) {
const currentDeps = feature.dependencies || [];
const newDeps = currentDeps
.filter((d) => !depUpdate.removedDependencies.includes(d))
.concat(depUpdate.addedDependencies.filter((d) => !currentDeps.includes(d)));
await featureLoader.update(projectPath, depUpdate.featureId, {
dependencies: newDeps,
});
logger.info(`[BacklogPlan] Updated dependencies for ${depUpdate.featureId}`);
}
} catch (error) {
logger.error(
`[BacklogPlan] Failed to update dependencies for ${depUpdate.featureId}:`,
getErrorMessage(error)
);
}
}
}
res.json({
success: true,
appliedChanges,
});
} catch (error) {
logError(error, 'Apply backlog plan failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,62 +0,0 @@
/**
* POST /generate endpoint - Generate a backlog plan
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { getBacklogPlanStatus, setRunningState, getErrorMessage, logError } from '../common.js';
import { generateBacklogPlan } from '../generate-plan.js';
import type { SettingsService } from '../../../services/settings-service.js';
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, prompt, model } = req.body as {
projectPath: string;
prompt: string;
model?: string;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
if (!prompt) {
res.status(400).json({ success: false, error: 'prompt required' });
return;
}
const { isRunning } = getBacklogPlanStatus();
if (isRunning) {
res.json({
success: false,
error: 'Backlog plan generation is already running',
});
return;
}
setRunningState(true);
const abortController = new AbortController();
setRunningState(true, abortController);
// Start generation in background
generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model)
.catch((error) => {
logError(error, 'Generate backlog plan failed (background)');
events.emit('backlog-plan:event', {
type: 'backlog_plan_error',
error: getErrorMessage(error),
});
})
.finally(() => {
setRunningState(false, null);
});
res.json({ success: true });
} catch (error) {
logError(error, 'Generate backlog plan failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,18 +0,0 @@
/**
* GET /status endpoint - Get backlog plan generation status
*/
import type { Request, Response } from 'express';
import { getBacklogPlanStatus, getErrorMessage, logError } from '../common.js';
export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const status = getBacklogPlanStatus();
res.json({ success: true, ...status });
} catch (error) {
logError(error, 'Get backlog plan status failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,22 +0,0 @@
/**
* POST /stop endpoint - Stop the current backlog plan generation
*/
import type { Request, Response } from 'express';
import { getAbortController, setRunningState, getErrorMessage, logError } from '../common.js';
export function createStopHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const abortController = getAbortController();
if (abortController) {
abortController.abort();
setRunningState(false, null);
}
res.json({ success: true });
} catch (error) {
logError(error, 'Stop backlog plan failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -8,19 +8,17 @@
import { Router } from 'express'; import { Router } from 'express';
import { createDescribeImageHandler } from './routes/describe-image.js'; import { createDescribeImageHandler } from './routes/describe-image.js';
import { createDescribeFileHandler } from './routes/describe-file.js'; import { createDescribeFileHandler } from './routes/describe-file.js';
import type { SettingsService } from '../../services/settings-service.js';
/** /**
* Create the context router * Create the context router
* *
* @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
* @returns Express router with context endpoints * @returns Express router with context endpoints
*/ */
export function createContextRoutes(settingsService?: SettingsService): Router { export function createContextRoutes(): Router {
const router = Router(); const router = Router();
router.post('/describe-image', createDescribeImageHandler(settingsService)); router.post('/describe-image', createDescribeImageHandler());
router.post('/describe-file', createDescribeFileHandler(settingsService)); router.post('/describe-file', createDescribeFileHandler());
return router; return router;
} }

View File

@@ -17,8 +17,6 @@ import { PathNotAllowedError } from '@automaker/platform';
import { createCustomOptions } from '../../../lib/sdk-options.js'; import { createCustomOptions } from '../../../lib/sdk-options.js';
import * as secureFs from '../../../lib/secure-fs.js'; import * as secureFs from '../../../lib/secure-fs.js';
import * as path from 'path'; import * as path from 'path';
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeFile'); const logger = createLogger('DescribeFile');
@@ -74,12 +72,9 @@ async function extractTextFromStream(
/** /**
* Create the describe-file request handler * Create the describe-file request handler
* *
* @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
* @returns Express request handler for file description * @returns Express request handler for file description
*/ */
export function createDescribeFileHandler( export function createDescribeFileHandler(): (req: Request, res: Response) => Promise<void> {
settingsService?: SettingsService
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { filePath } = req.body as DescribeFileRequestBody; const { filePath } = req.body as DescribeFileRequestBody;
@@ -170,13 +165,6 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
// Use the file's directory as the working directory // Use the file's directory as the working directory
const cwd = path.dirname(resolvedPath); const cwd = path.dirname(resolvedPath);
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
cwd,
settingsService,
'[DescribeFile]'
);
// Use centralized SDK options with proper cwd validation // Use centralized SDK options with proper cwd validation
// No tools needed since we're passing file content directly // No tools needed since we're passing file content directly
const sdkOptions = createCustomOptions({ const sdkOptions = createCustomOptions({
@@ -184,7 +172,6 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
model: CLAUDE_MODEL_MAP.haiku, model: CLAUDE_MODEL_MAP.haiku,
maxTurns: 1, maxTurns: 1,
allowedTools: [], allowedTools: [],
autoLoadClaudeMd,
sandbox: { enabled: true, autoAllowBashIfSandboxed: true }, sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
}); });

View File

@@ -17,8 +17,6 @@ import { CLAUDE_MODEL_MAP } from '@automaker/types';
import { createCustomOptions } from '../../../lib/sdk-options.js'; import { createCustomOptions } from '../../../lib/sdk-options.js';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeImage'); const logger = createLogger('DescribeImage');
@@ -228,12 +226,9 @@ async function extractTextFromStream(
* Uses Claude SDK query with multi-part content blocks to include the image (base64), * Uses Claude SDK query with multi-part content blocks to include the image (base64),
* matching the agent runner behavior. * matching the agent runner behavior.
* *
* @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
* @returns Express request handler for image description * @returns Express request handler for image description
*/ */
export function createDescribeImageHandler( export function createDescribeImageHandler(): (req: Request, res: Response) => Promise<void> {
settingsService?: SettingsService
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
const requestId = `describe-image-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; const requestId = `describe-image-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
const startedAt = Date.now(); const startedAt = Date.now();
@@ -330,20 +325,12 @@ export function createDescribeImageHandler(
const cwd = path.dirname(actualPath); const cwd = path.dirname(actualPath);
logger.info(`[${requestId}] Using cwd=${cwd}`); logger.info(`[${requestId}] Using cwd=${cwd}`);
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
cwd,
settingsService,
'[DescribeImage]'
);
// Use the same centralized option builder used across the server (validates cwd) // Use the same centralized option builder used across the server (validates cwd)
const sdkOptions = createCustomOptions({ const sdkOptions = createCustomOptions({
cwd, cwd,
model: CLAUDE_MODEL_MAP.haiku, model: CLAUDE_MODEL_MAP.haiku,
maxTurns: 1, maxTurns: 1,
allowedTools: [], allowedTools: [],
autoLoadClaudeMd,
sandbox: { enabled: true, autoAllowBashIfSandboxed: true }, sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
}); });

View File

@@ -3,54 +3,16 @@
*/ */
import { Router } from 'express'; import { Router } from 'express';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js'; import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js';
import { createListIssuesHandler } from './routes/list-issues.js'; import { createListIssuesHandler } from './routes/list-issues.js';
import { createListPRsHandler } from './routes/list-prs.js'; import { createListPRsHandler } from './routes/list-prs.js';
import { createValidateIssueHandler } from './routes/validate-issue.js';
import {
createValidationStatusHandler,
createValidationStopHandler,
createGetValidationsHandler,
createDeleteValidationHandler,
createMarkViewedHandler,
} from './routes/validation-endpoints.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createGitHubRoutes( export function createGitHubRoutes(): Router {
events: EventEmitter,
settingsService?: SettingsService
): Router {
const router = Router(); const router = Router();
router.post('/check-remote', validatePathParams('projectPath'), createCheckGitHubRemoteHandler()); router.post('/check-remote', createCheckGitHubRemoteHandler());
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler()); router.post('/issues', createListIssuesHandler());
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler()); router.post('/prs', createListPRsHandler());
router.post(
'/validate-issue',
validatePathParams('projectPath'),
createValidateIssueHandler(events, settingsService)
);
// Validation management endpoints
router.post(
'/validation-status',
validatePathParams('projectPath'),
createValidationStatusHandler()
);
router.post('/validation-stop', validatePathParams('projectPath'), createValidationStopHandler());
router.post('/validations', validatePathParams('projectPath'), createGetValidationsHandler());
router.post(
'/validation-delete',
validatePathParams('projectPath'),
createDeleteValidationHandler()
);
router.post(
'/validation-mark-viewed',
validatePathParams('projectPath'),
createMarkViewedHandler(events)
);
return router; return router;
} }

View File

@@ -2,7 +2,6 @@
* POST /list-issues endpoint - List GitHub issues for a project * POST /list-issues endpoint - List GitHub issues for a project
*/ */
import { spawn } from 'child_process';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { execAsync, execEnv, getErrorMessage, logError } from './common.js'; import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js'; import { checkGitHubRemote } from './check-github-remote.js';
@@ -14,19 +13,6 @@ export interface GitHubLabel {
export interface GitHubAuthor { export interface GitHubAuthor {
login: string; login: string;
avatarUrl?: string;
}
export interface GitHubAssignee {
login: string;
avatarUrl?: string;
}
export interface LinkedPullRequest {
number: number;
title: string;
state: string;
url: string;
} }
export interface GitHubIssue { export interface GitHubIssue {
@@ -38,8 +24,6 @@ export interface GitHubIssue {
labels: GitHubLabel[]; labels: GitHubLabel[];
url: string; url: string;
body: string; body: string;
assignees: GitHubAssignee[];
linkedPRs?: LinkedPullRequest[];
} }
export interface ListIssuesResult { export interface ListIssuesResult {
@@ -49,146 +33,6 @@ export interface ListIssuesResult {
error?: string; error?: string;
} }
/**
* Fetch linked PRs for a list of issues using GitHub GraphQL API
*/
async function fetchLinkedPRs(
projectPath: string,
owner: string,
repo: string,
issueNumbers: number[]
): Promise<Map<number, LinkedPullRequest[]>> {
const linkedPRsMap = new Map<number, LinkedPullRequest[]>();
if (issueNumbers.length === 0) {
return linkedPRsMap;
}
// Build GraphQL query for batch fetching linked PRs
// We fetch up to 20 issues at a time to avoid query limits
const batchSize = 20;
for (let i = 0; i < issueNumbers.length; i += batchSize) {
const batch = issueNumbers.slice(i, i + batchSize);
const issueQueries = batch
.map(
(num, idx) => `
issue${idx}: issue(number: ${num}) {
number
timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) {
nodes {
... on CrossReferencedEvent {
source {
... on PullRequest {
number
title
state
url
}
}
}
... on ConnectedEvent {
subject {
... on PullRequest {
number
title
state
url
}
}
}
}
}
}`
)
.join('\n');
const query = `{
repository(owner: "${owner}", name: "${repo}") {
${issueQueries}
}
}`;
try {
// Use spawn with stdin to avoid shell injection vulnerabilities
// --input - reads the JSON request body from stdin
const requestBody = JSON.stringify({ query });
const response = await new Promise<Record<string, unknown>>((resolve, reject) => {
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
cwd: projectPath,
env: execEnv,
});
let stdout = '';
let stderr = '';
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
gh.on('close', (code) => {
if (code !== 0) {
return reject(new Error(`gh process exited with code ${code}: ${stderr}`));
}
try {
resolve(JSON.parse(stdout));
} catch (e) {
reject(e);
}
});
gh.stdin.write(requestBody);
gh.stdin.end();
});
const repoData = (response?.data as Record<string, unknown>)?.repository as Record<
string,
unknown
> | null;
if (repoData) {
batch.forEach((issueNum, idx) => {
const issueData = repoData[`issue${idx}`] as {
timelineItems?: {
nodes?: Array<{
source?: { number?: number; title?: string; state?: string; url?: string };
subject?: { number?: number; title?: string; state?: string; url?: string };
}>;
};
} | null;
if (issueData?.timelineItems?.nodes) {
const linkedPRs: LinkedPullRequest[] = [];
const seenPRs = new Set<number>();
for (const node of issueData.timelineItems.nodes) {
const pr = node?.source || node?.subject;
if (pr?.number && !seenPRs.has(pr.number)) {
seenPRs.add(pr.number);
linkedPRs.push({
number: pr.number,
title: pr.title || '',
state: (pr.state || '').toLowerCase(),
url: pr.url || '',
});
}
}
if (linkedPRs.length > 0) {
linkedPRsMap.set(issueNum, linkedPRs);
}
}
});
}
} catch (error) {
// If GraphQL fails, continue without linked PRs
console.warn(
'Failed to fetch linked PRs via GraphQL:',
error instanceof Error ? error.message : error
);
}
}
return linkedPRsMap;
}
export function createListIssuesHandler() { export function createListIssuesHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
@@ -209,17 +53,17 @@ export function createListIssuesHandler() {
return; return;
} }
// Fetch open and closed issues in parallel (now including assignees) // Fetch open and closed issues in parallel
const [openResult, closedResult] = await Promise.all([ const [openResult, closedResult] = await Promise.all([
execAsync( execAsync(
'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body,assignees --limit 100', 'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body --limit 100',
{ {
cwd: projectPath, cwd: projectPath,
env: execEnv, env: execEnv,
} }
), ),
execAsync( execAsync(
'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body,assignees --limit 50', 'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body --limit 50',
{ {
cwd: projectPath, cwd: projectPath,
env: execEnv, env: execEnv,
@@ -233,24 +77,6 @@ export function createListIssuesHandler() {
const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]'); const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]');
const closedIssues: GitHubIssue[] = JSON.parse(closedStdout || '[]'); const closedIssues: GitHubIssue[] = JSON.parse(closedStdout || '[]');
// Fetch linked PRs for open issues (more relevant for active work)
if (remoteStatus.owner && remoteStatus.repo && openIssues.length > 0) {
const linkedPRsMap = await fetchLinkedPRs(
projectPath,
remoteStatus.owner,
remoteStatus.repo,
openIssues.map((i) => i.number)
);
// Attach linked PRs to issues
for (const issue of openIssues) {
const linkedPRs = linkedPRsMap.get(issue.number);
if (linkedPRs) {
issue.linkedPRs = linkedPRs;
}
}
}
res.json({ res.json({
success: true, success: true,
openIssues, openIssues,

View File

@@ -1,302 +0,0 @@
/**
* POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK (async)
*
* Scans the codebase to determine if an issue is valid, invalid, or needs clarification.
* Runs asynchronously and emits events for progress and completion.
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import type { EventEmitter } from '../../../lib/events.js';
import type { IssueValidationResult, IssueValidationEvent, AgentModel } from '@automaker/types';
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
import { writeValidation } from '../../../lib/validation-storage.js';
import {
issueValidationSchema,
ISSUE_VALIDATION_SYSTEM_PROMPT,
buildValidationPrompt,
} from './validation-schema.js';
import {
trySetValidationRunning,
clearValidationStatus,
getErrorMessage,
logError,
logger,
} from './validation-common.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
/** Valid model values for validation */
const VALID_MODELS: readonly AgentModel[] = ['opus', 'sonnet', 'haiku'] as const;
/**
* Request body for issue validation
*/
interface ValidateIssueRequestBody {
projectPath: string;
issueNumber: number;
issueTitle: string;
issueBody: string;
issueLabels?: string[];
/** Model to use for validation (opus, sonnet, haiku) */
model?: AgentModel;
}
/**
* Run the validation asynchronously
*
* Emits events for start, progress, complete, and error.
* Stores result on completion.
*/
async function runValidation(
projectPath: string,
issueNumber: number,
issueTitle: string,
issueBody: string,
issueLabels: string[] | undefined,
model: AgentModel,
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService
): Promise<void> {
// Emit start event
const startEvent: IssueValidationEvent = {
type: 'issue_validation_start',
issueNumber,
issueTitle,
projectPath,
};
events.emit('issue-validation:event', startEvent);
// Set up timeout (6 minutes)
const VALIDATION_TIMEOUT_MS = 360000;
const timeoutId = setTimeout(() => {
logger.warn(`Validation timeout reached after ${VALIDATION_TIMEOUT_MS}ms`);
abortController.abort();
}, VALIDATION_TIMEOUT_MS);
try {
// Build the prompt
const prompt = buildValidationPrompt(issueNumber, issueTitle, issueBody, issueLabels);
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[ValidateIssue]'
);
// Create SDK options with structured output and abort controller
const options = createSuggestionsOptions({
cwd: projectPath,
model,
systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT,
abortController,
autoLoadClaudeMd,
outputFormat: {
type: 'json_schema',
schema: issueValidationSchema as Record<string, unknown>,
},
});
// Execute the query
const stream = query({ prompt, options });
let validationResult: IssueValidationResult | null = null;
let responseText = '';
for await (const msg of stream) {
// Collect assistant text for debugging and emit progress
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
// Emit progress event
const progressEvent: IssueValidationEvent = {
type: 'issue_validation_progress',
issueNumber,
content: block.text,
projectPath,
};
events.emit('issue-validation:event', progressEvent);
}
}
}
// Extract structured output on success
if (msg.type === 'result' && msg.subtype === 'success') {
const resultMsg = msg as { structured_output?: IssueValidationResult };
if (resultMsg.structured_output) {
validationResult = resultMsg.structured_output;
logger.debug('Received structured output:', validationResult);
}
}
// Handle errors
if (msg.type === 'result') {
const resultMsg = msg as { subtype?: string };
if (resultMsg.subtype === 'error_max_structured_output_retries') {
logger.error('Failed to produce valid structured output after retries');
throw new Error('Could not produce valid validation output');
}
}
}
// Clear timeout
clearTimeout(timeoutId);
// Require structured output
if (!validationResult) {
logger.error('No structured output received from Claude SDK');
logger.debug('Raw response text:', responseText);
throw new Error('Validation failed: no structured output received');
}
logger.info(`Issue #${issueNumber} validation complete: ${validationResult.verdict}`);
// Store the result
await writeValidation(projectPath, issueNumber, {
issueNumber,
issueTitle,
validatedAt: new Date().toISOString(),
model,
result: validationResult,
});
// Emit completion event
const completeEvent: IssueValidationEvent = {
type: 'issue_validation_complete',
issueNumber,
issueTitle,
result: validationResult,
projectPath,
model,
};
events.emit('issue-validation:event', completeEvent);
} catch (error) {
clearTimeout(timeoutId);
const errorMessage = getErrorMessage(error);
logError(error, `Issue #${issueNumber} validation failed`);
// Emit error event
const errorEvent: IssueValidationEvent = {
type: 'issue_validation_error',
issueNumber,
error: errorMessage,
projectPath,
};
events.emit('issue-validation:event', errorEvent);
throw error;
}
}
/**
* Creates the handler for validating GitHub issues against the codebase.
*
* Uses Claude SDK with:
* - Read-only tools (Read, Glob, Grep) for codebase analysis
* - JSON schema structured output for reliable parsing
* - System prompt guiding the validation process
* - Async execution with event emission
*/
export function createValidateIssueHandler(
events: EventEmitter,
settingsService?: SettingsService
) {
return async (req: Request, res: Response): Promise<void> => {
try {
const {
projectPath,
issueNumber,
issueTitle,
issueBody,
issueLabels,
model = 'opus',
} = req.body as ValidateIssueRequestBody;
// Validate required fields
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!issueNumber || typeof issueNumber !== 'number') {
res
.status(400)
.json({ success: false, error: 'issueNumber is required and must be a number' });
return;
}
if (!issueTitle || typeof issueTitle !== 'string') {
res.status(400).json({ success: false, error: 'issueTitle is required' });
return;
}
if (typeof issueBody !== 'string') {
res.status(400).json({ success: false, error: 'issueBody must be a string' });
return;
}
// Validate model parameter at runtime
if (!VALID_MODELS.includes(model)) {
res.status(400).json({
success: false,
error: `Invalid model. Must be one of: ${VALID_MODELS.join(', ')}`,
});
return;
}
logger.info(`Starting async validation for issue #${issueNumber}: ${issueTitle}`);
// Create abort controller and atomically try to claim validation slot
// This prevents TOCTOU race conditions
const abortController = new AbortController();
if (!trySetValidationRunning(projectPath, issueNumber, abortController)) {
res.json({
success: false,
error: `Validation is already running for issue #${issueNumber}`,
});
return;
}
// Start validation in background (fire-and-forget)
runValidation(
projectPath,
issueNumber,
issueTitle,
issueBody,
issueLabels,
model,
events,
abortController,
settingsService
)
.catch((error) => {
// Error is already handled inside runValidation (event emitted)
logger.debug('Validation error caught in background handler:', error);
})
.finally(() => {
clearValidationStatus(projectPath, issueNumber);
});
// Return immediately
res.json({
success: true,
message: `Validation started for issue #${issueNumber}`,
issueNumber,
});
} catch (error) {
logError(error, `Issue validation failed`);
logger.error('Issue validation error:', error);
if (!res.headersSent) {
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
}
};
}

View File

@@ -1,174 +0,0 @@
/**
* Common utilities and state for issue validation routes
*
* Tracks running validation status per issue to support:
* - Checking if a validation is in progress
* - Cancelling a running validation
* - Preventing duplicate validations for the same issue
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../../common.js';
const logger = createLogger('IssueValidation');
/**
* Status of a validation in progress
*/
interface ValidationStatus {
isRunning: boolean;
abortController: AbortController;
startedAt: Date;
}
/**
* Map of issue number to validation status
* Key format: `${projectPath}||${issueNumber}` to support multiple projects
* Note: Using `||` as delimiter since `:` appears in Windows paths (e.g., C:\)
*/
const validationStatusMap = new Map<string, ValidationStatus>();
/** Maximum age for stale validation entries before cleanup (1 hour) */
const MAX_VALIDATION_AGE_MS = 60 * 60 * 1000;
/**
* Create a unique key for a validation
* Uses `||` as delimiter since `:` appears in Windows paths
*/
function getValidationKey(projectPath: string, issueNumber: number): string {
return `${projectPath}||${issueNumber}`;
}
/**
* Check if a validation is currently running for an issue
*/
export function isValidationRunning(projectPath: string, issueNumber: number): boolean {
const key = getValidationKey(projectPath, issueNumber);
const status = validationStatusMap.get(key);
return status?.isRunning ?? false;
}
/**
* Get validation status for an issue
*/
export function getValidationStatus(
projectPath: string,
issueNumber: number
): { isRunning: boolean; startedAt?: Date } | null {
const key = getValidationKey(projectPath, issueNumber);
const status = validationStatusMap.get(key);
if (!status) {
return null;
}
return {
isRunning: status.isRunning,
startedAt: status.startedAt,
};
}
/**
* Get all running validations for a project
*/
export function getRunningValidations(projectPath: string): number[] {
const runningIssues: number[] = [];
const prefix = `${projectPath}||`;
for (const [key, status] of validationStatusMap.entries()) {
if (status.isRunning && key.startsWith(prefix)) {
const issueNumber = parseInt(key.slice(prefix.length), 10);
if (!isNaN(issueNumber)) {
runningIssues.push(issueNumber);
}
}
}
return runningIssues;
}
/**
* Set a validation as running
*/
export function setValidationRunning(
projectPath: string,
issueNumber: number,
abortController: AbortController
): void {
const key = getValidationKey(projectPath, issueNumber);
validationStatusMap.set(key, {
isRunning: true,
abortController,
startedAt: new Date(),
});
}
/**
* Atomically try to set a validation as running (check-and-set)
* Prevents TOCTOU race conditions when starting validations
*
* @returns true if successfully claimed, false if already running
*/
export function trySetValidationRunning(
projectPath: string,
issueNumber: number,
abortController: AbortController
): boolean {
const key = getValidationKey(projectPath, issueNumber);
if (validationStatusMap.has(key)) {
return false; // Already running
}
validationStatusMap.set(key, {
isRunning: true,
abortController,
startedAt: new Date(),
});
return true; // Successfully claimed
}
/**
* Cleanup stale validation entries (e.g., from crashed validations)
* Should be called periodically to prevent memory leaks
*/
export function cleanupStaleValidations(): number {
const now = Date.now();
let cleanedCount = 0;
for (const [key, status] of validationStatusMap.entries()) {
if (now - status.startedAt.getTime() > MAX_VALIDATION_AGE_MS) {
status.abortController.abort();
validationStatusMap.delete(key);
cleanedCount++;
}
}
if (cleanedCount > 0) {
logger.info(`Cleaned up ${cleanedCount} stale validation entries`);
}
return cleanedCount;
}
/**
* Clear validation status (call when validation completes or errors)
*/
export function clearValidationStatus(projectPath: string, issueNumber: number): void {
const key = getValidationKey(projectPath, issueNumber);
validationStatusMap.delete(key);
}
/**
* Abort a running validation
*
* @returns true if validation was aborted, false if not running
*/
export function abortValidation(projectPath: string, issueNumber: number): boolean {
const key = getValidationKey(projectPath, issueNumber);
const status = validationStatusMap.get(key);
if (!status || !status.isRunning) {
return false;
}
status.abortController.abort();
validationStatusMap.delete(key);
return true;
}
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger);
export { logger };

View File

@@ -1,236 +0,0 @@
/**
* Additional validation endpoints for status, stop, and retrieving stored validations
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import type { IssueValidationEvent } from '@automaker/types';
import {
isValidationRunning,
getValidationStatus,
getRunningValidations,
abortValidation,
getErrorMessage,
logError,
logger,
} from './validation-common.js';
import {
readValidation,
getAllValidations,
getValidationWithFreshness,
deleteValidation,
markValidationViewed,
} from '../../../lib/validation-storage.js';
/**
* POST /validation-status - Check if validation is running for an issue
*/
export function createValidationStatusHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, issueNumber } = req.body as {
projectPath: string;
issueNumber?: number;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
// If issueNumber provided, check specific issue
if (issueNumber !== undefined) {
const status = getValidationStatus(projectPath, issueNumber);
res.json({
success: true,
isRunning: status?.isRunning ?? false,
startedAt: status?.startedAt?.toISOString(),
});
return;
}
// Otherwise, return all running validations for the project
const runningIssues = getRunningValidations(projectPath);
res.json({
success: true,
runningIssues,
});
} catch (error) {
logError(error, 'Validation status check failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* POST /validation-stop - Cancel a running validation
*/
export function createValidationStopHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, issueNumber } = req.body as {
projectPath: string;
issueNumber: number;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!issueNumber || typeof issueNumber !== 'number') {
res
.status(400)
.json({ success: false, error: 'issueNumber is required and must be a number' });
return;
}
const wasAborted = abortValidation(projectPath, issueNumber);
if (wasAborted) {
logger.info(`Validation for issue #${issueNumber} was stopped`);
res.json({
success: true,
message: `Validation for issue #${issueNumber} has been stopped`,
});
} else {
res.json({
success: false,
error: `No validation is running for issue #${issueNumber}`,
});
}
} catch (error) {
logError(error, 'Validation stop failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* POST /validations - Get stored validations for a project
*/
export function createGetValidationsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, issueNumber } = req.body as {
projectPath: string;
issueNumber?: number;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
// If issueNumber provided, get specific validation with freshness info
if (issueNumber !== undefined) {
const result = await getValidationWithFreshness(projectPath, issueNumber);
if (!result) {
res.json({
success: true,
validation: null,
});
return;
}
res.json({
success: true,
validation: result.validation,
isStale: result.isStale,
});
return;
}
// Otherwise, get all validations for the project
const validations = await getAllValidations(projectPath);
res.json({
success: true,
validations,
});
} catch (error) {
logError(error, 'Get validations failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* POST /validation-delete - Delete a stored validation
*/
export function createDeleteValidationHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, issueNumber } = req.body as {
projectPath: string;
issueNumber: number;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!issueNumber || typeof issueNumber !== 'number') {
res
.status(400)
.json({ success: false, error: 'issueNumber is required and must be a number' });
return;
}
const deleted = await deleteValidation(projectPath, issueNumber);
res.json({
success: true,
deleted,
});
} catch (error) {
logError(error, 'Delete validation failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* POST /validation-mark-viewed - Mark a validation as viewed by the user
*/
export function createMarkViewedHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, issueNumber } = req.body as {
projectPath: string;
issueNumber: number;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!issueNumber || typeof issueNumber !== 'number') {
res
.status(400)
.json({ success: false, error: 'issueNumber is required and must be a number' });
return;
}
const success = await markValidationViewed(projectPath, issueNumber);
if (success) {
// Emit event so UI can update the unviewed count
const viewedEvent: IssueValidationEvent = {
type: 'issue_validation_viewed',
issueNumber,
projectPath,
};
events.emit('issue-validation:event', viewedEvent);
}
res.json({ success });
} catch (error) {
logError(error, 'Mark validation viewed failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,138 +0,0 @@
/**
* Issue Validation Schema and System Prompt
*
* Defines the JSON schema for Claude's structured output and
* the system prompt that guides the validation process.
*/
/**
* JSON Schema for issue validation structured output.
* Used with Claude SDK's outputFormat option to ensure reliable parsing.
*/
export const issueValidationSchema = {
type: 'object',
properties: {
verdict: {
type: 'string',
enum: ['valid', 'invalid', 'needs_clarification'],
description: 'The validation verdict for the issue',
},
confidence: {
type: 'string',
enum: ['high', 'medium', 'low'],
description: 'How confident the AI is in its assessment',
},
reasoning: {
type: 'string',
description: 'Detailed explanation of the verdict',
},
bugConfirmed: {
type: 'boolean',
description: 'For bug reports: whether the bug was confirmed in the codebase',
},
relatedFiles: {
type: 'array',
items: { type: 'string' },
description: 'Files related to the issue found during analysis',
},
suggestedFix: {
type: 'string',
description: 'Suggested approach to fix or implement the issue',
},
missingInfo: {
type: 'array',
items: { type: 'string' },
description: 'Information needed when verdict is needs_clarification',
},
estimatedComplexity: {
type: 'string',
enum: ['trivial', 'simple', 'moderate', 'complex', 'very_complex'],
description: 'Estimated effort to address the issue',
},
},
required: ['verdict', 'confidence', 'reasoning'],
additionalProperties: false,
} as const;
/**
* System prompt that guides Claude in validating GitHub issues.
* Instructs the model to use read-only tools to analyze the codebase.
*/
export const ISSUE_VALIDATION_SYSTEM_PROMPT = `You are an expert code analyst validating GitHub issues against a codebase.
Your task is to analyze a GitHub issue and determine if it's valid by scanning the codebase.
## Validation Process
1. **Read the issue carefully** - Understand what is being reported or requested
2. **Search the codebase** - Use Glob to find relevant files by pattern, Grep to search for keywords
3. **Examine the code** - Use Read to look at the actual implementation in relevant files
4. **Form your verdict** - Based on your analysis, determine if the issue is valid
## Verdicts
- **valid**: The issue describes a real problem that exists in the codebase, or a clear feature request that can be implemented. The referenced files/components exist and the issue is actionable.
- **invalid**: The issue describes behavior that doesn't exist, references non-existent files or components, is based on a misunderstanding of the code, or the described "bug" is actually expected behavior.
- **needs_clarification**: The issue lacks sufficient detail to verify. Specify what additional information is needed in the missingInfo field.
## For Bug Reports, Check:
- Do the referenced files/components exist?
- Does the code match what the issue describes?
- Is the described behavior actually a bug or expected?
- Can you locate the code that would cause the reported issue?
## For Feature Requests, Check:
- Does the feature already exist?
- Is the implementation location clear?
- Is the request technically feasible given the codebase structure?
## Response Guidelines
- **Always include relatedFiles** when you find relevant code
- **Set bugConfirmed to true** only if you can definitively confirm a bug exists in the code
- **Provide a suggestedFix** when you have a clear idea of how to address the issue
- **Use missingInfo** when the verdict is needs_clarification to list what's needed
- **Set estimatedComplexity** to help prioritize:
- trivial: Simple text changes, one-line fixes
- simple: Small changes to one file
- moderate: Changes to multiple files or moderate logic changes
- complex: Significant refactoring or new feature implementation
- very_complex: Major architectural changes or cross-cutting concerns
Be thorough in your analysis but focus on files that are directly relevant to the issue.`;
/**
* Build the user prompt for issue validation.
*
* Creates a structured prompt that includes the issue details for Claude
* to analyze against the codebase.
*
* @param issueNumber - The GitHub issue number
* @param issueTitle - The issue title
* @param issueBody - The issue body/description
* @param issueLabels - Optional array of label names
* @returns Formatted prompt string for the validation request
*/
export function buildValidationPrompt(
issueNumber: number,
issueTitle: string,
issueBody: string,
issueLabels?: string[]
): string {
const labelsSection = issueLabels?.length ? `\n\n**Labels:** ${issueLabels.join(', ')}` : '';
return `Please validate the following GitHub issue by analyzing the codebase:
## Issue #${issueNumber}: ${issueTitle}
${labelsSection}
### Description
${issueBody || '(No description provided)'}
---
Scan the codebase to verify this issue. Look for the files, components, or functionality mentioned. Determine if this issue is valid, invalid, or needs clarification.`;
}

View File

@@ -6,94 +6,9 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
import type { EventEmitter } from '../../lib/events.js'; import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
import { createSuggestionsOptions } from '../../lib/sdk-options.js'; import { createSuggestionsOptions } from '../../lib/sdk-options.js';
import { FeatureLoader } from '../../services/feature-loader.js';
import { getAppSpecPath } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
const logger = createLogger('Suggestions'); const logger = createLogger('Suggestions');
/**
* Extract implemented features from app_spec.txt XML content
*
* Note: This uses regex-based parsing which is sufficient for our controlled
* XML structure. If more complex XML parsing is needed in the future, consider
* using a library like 'fast-xml-parser' or 'xml2js'.
*/
function extractImplementedFeatures(specContent: string): string[] {
const features: string[] = [];
// Match <implemented_features>...</implemented_features> section
const implementedMatch = specContent.match(
/<implemented_features>([\s\S]*?)<\/implemented_features>/
);
if (implementedMatch) {
const implementedSection = implementedMatch[1];
// Extract feature names from <name>...</name> tags using matchAll
const nameRegex = /<name>(.*?)<\/name>/g;
const matches = implementedSection.matchAll(nameRegex);
for (const match of matches) {
features.push(match[1].trim());
}
}
return features;
}
/**
* Load existing context (app spec and backlog features) to avoid duplicates
*/
async function loadExistingContext(projectPath: string): Promise<string> {
let context = '';
// 1. Read app_spec.txt for implemented features
try {
const appSpecPath = getAppSpecPath(projectPath);
const specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
if (specContent && specContent.trim().length > 0) {
const implementedFeatures = extractImplementedFeatures(specContent);
if (implementedFeatures.length > 0) {
context += '\n\n=== ALREADY IMPLEMENTED FEATURES ===\n';
context += 'These features are already implemented in the codebase:\n';
context += implementedFeatures.map((feature) => `- ${feature}`).join('\n') + '\n';
}
}
} catch (error) {
// app_spec.txt doesn't exist or can't be read - that's okay
logger.debug('No app_spec.txt found or error reading it:', error);
}
// 2. Load existing features from backlog
try {
const featureLoader = new FeatureLoader();
const features = await featureLoader.getAll(projectPath);
if (features.length > 0) {
context += '\n\n=== EXISTING FEATURES IN BACKLOG ===\n';
context += 'These features are already planned or in progress:\n';
context +=
features
.map((feature) => {
const status = feature.status || 'pending';
const title = feature.title || feature.description?.substring(0, 50) || 'Untitled';
return `- ${title} (${status})`;
})
.join('\n') + '\n';
}
} catch (error) {
// Features directory doesn't exist or can't be read - that's okay
logger.debug('No features found or error loading them:', error);
}
return context;
}
/** /**
* JSON Schema for suggestions output * JSON Schema for suggestions output
*/ */
@@ -127,8 +42,7 @@ export async function generateSuggestions(
projectPath: string, projectPath: string,
suggestionType: string, suggestionType: string,
events: EventEmitter, events: EventEmitter,
abortController: AbortController, abortController: AbortController
settingsService?: SettingsService
): Promise<void> { ): Promise<void> {
const typePrompts: Record<string, string> = { const typePrompts: Record<string, string> = {
features: 'Analyze this project and suggest new features that would add value.', features: 'Analyze this project and suggest new features that would add value.',
@@ -137,13 +51,8 @@ export async function generateSuggestions(
performance: 'Analyze this project for performance issues and suggest optimizations.', performance: 'Analyze this project for performance issues and suggest optimizations.',
}; };
// Load existing context to avoid duplicates
const existingContext = await loadExistingContext(projectPath);
const prompt = `${typePrompts[suggestionType] || typePrompts.features} const prompt = `${typePrompts[suggestionType] || typePrompts.features}
${existingContext}
${existingContext ? '\nIMPORTANT: Do NOT suggest features that are already implemented or already in the backlog above. Focus on NEW ideas that complement what already exists.\n' : ''}
Look at the codebase and provide 3-5 concrete suggestions. Look at the codebase and provide 3-5 concrete suggestions.
For each suggestion, provide: For each suggestion, provide:
@@ -154,20 +63,14 @@ For each suggestion, provide:
The response will be automatically formatted as structured JSON.`; The response will be automatically formatted as structured JSON.`;
// Don't send initial message - let the agent output speak for itself events.emit('suggestions:event', {
// The first agent message will be captured as an info entry type: 'suggestions_progress',
content: `Starting ${suggestionType} analysis...\n`,
// Load autoLoadClaudeMd setting });
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[Suggestions]'
);
const options = createSuggestionsOptions({ const options = createSuggestionsOptions({
cwd: projectPath, cwd: projectPath,
abortController, abortController,
autoLoadClaudeMd,
outputFormat: { outputFormat: {
type: 'json_schema', type: 'json_schema',
schema: suggestionsSchema, schema: suggestionsSchema,

View File

@@ -8,19 +8,11 @@ import { validatePathParams } from '../../middleware/validate-paths.js';
import { createGenerateHandler } from './routes/generate.js'; import { createGenerateHandler } from './routes/generate.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';
export function createSuggestionsRoutes( export function createSuggestionsRoutes(events: EventEmitter): Router {
events: EventEmitter,
settingsService?: SettingsService
): Router {
const router = Router(); const router = Router();
router.post( router.post('/generate', validatePathParams('projectPath'), createGenerateHandler(events));
'/generate',
validatePathParams('projectPath'),
createGenerateHandler(events, settingsService)
);
router.post('/stop', createStopHandler()); router.post('/stop', createStopHandler());
router.get('/status', createStatusHandler()); router.get('/status', createStatusHandler());

View File

@@ -7,11 +7,10 @@ import type { EventEmitter } from '../../../lib/events.js';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js'; import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js';
import { generateSuggestions } from '../generate-suggestions.js'; import { generateSuggestions } from '../generate-suggestions.js';
import type { SettingsService } from '../../../services/settings-service.js';
const logger = createLogger('Suggestions'); const logger = createLogger('Suggestions');
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) { export function createGenerateHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath, suggestionType = 'features' } = req.body as { const { projectPath, suggestionType = 'features' } = req.body as {
@@ -38,7 +37,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
setRunningState(true, abortController); setRunningState(true, abortController);
// Start generation in background // Start generation in background
generateSuggestions(projectPath, suggestionType, events, abortController, settingsService) generateSuggestions(projectPath, suggestionType, events, abortController)
.catch((error) => { .catch((error) => {
logError(error, 'Generate suggestions failed (background)'); logError(error, 'Generate suggestions failed (background)');
events.emit('suggestions:event', { events.emit('suggestions:event', {

View File

@@ -1,162 +0,0 @@
/**
* Common utilities for update routes
*/
import { createLogger } from '@automaker/utils';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { fileURLToPath } from 'url';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
const logger = createLogger('Updates');
export const execAsync = promisify(exec);
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger);
// ============================================================================
// Extended PATH configuration for Electron apps
// ============================================================================
const pathSeparator = process.platform === 'win32' ? ';' : ':';
const additionalPaths: string[] = [];
if (process.platform === 'win32') {
// Windows paths
if (process.env.LOCALAPPDATA) {
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
}
if (process.env.PROGRAMFILES) {
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
}
if (process.env['ProgramFiles(x86)']) {
additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`);
}
} else {
// Unix/Mac paths
additionalPaths.push(
'/opt/homebrew/bin', // Homebrew on Apple Silicon
'/usr/local/bin', // Homebrew on Intel Mac, common Linux location
'/home/linuxbrew/.linuxbrew/bin' // Linuxbrew
);
// pipx, other user installs - only add if HOME is defined
if (process.env.HOME) {
additionalPaths.push(`${process.env.HOME}/.local/bin`);
}
}
const extendedPath = [process.env.PATH, ...additionalPaths.filter(Boolean)]
.filter(Boolean)
.join(pathSeparator);
/**
* Environment variables with extended PATH for executing shell commands.
*/
export const execEnv = {
...process.env,
PATH: extendedPath,
};
// ============================================================================
// Automaker installation path
// ============================================================================
/**
* Locate the Automaker monorepo root directory.
*
* @returns Absolute path to the monorepo root directory (the directory containing the top-level `package.json`)
*/
export function getAutomakerRoot(): string {
// In ESM, we use import.meta.url to get the current file path
// This file is at: apps/server/src/routes/updates/common.ts
// So we need to go up 5 levels to get to the monorepo root
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Go up from: updates -> routes -> src -> server -> apps -> root
return path.resolve(__dirname, '..', '..', '..', '..', '..');
}
/**
* Determines whether Git is available on the system.
*
* @returns `true` if the `git` command is executable in the current environment, `false` otherwise.
*/
export async function isGitAvailable(): Promise<boolean> {
try {
await execAsync('git --version', { env: execEnv });
return true;
} catch {
return false;
}
}
/**
* Determine whether the given filesystem path is a Git repository.
*
* @param repoPath - Filesystem path to check
* @returns `true` if the path is inside a Git working tree, `false` otherwise.
*/
export async function isGitRepo(repoPath: string): Promise<boolean> {
try {
await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath, env: execEnv });
return true;
} catch {
return false;
}
}
/**
* Retrieves the full commit hash pointed to by HEAD in the given repository.
*
* @param repoPath - Filesystem path of the Git repository to query
* @returns The full commit hash for HEAD as a trimmed string
*/
export async function getCurrentCommit(repoPath: string): Promise<string> {
const { stdout } = await execAsync('git rev-parse HEAD', { cwd: repoPath, env: execEnv });
return stdout.trim();
}
/**
* Retrieve the short commit hash of HEAD for the repository at the given path.
*
* @param repoPath - Filesystem path to the git repository
* @returns The short commit hash for `HEAD`
*/
export async function getShortCommit(repoPath: string): Promise<string> {
const { stdout } = await execAsync('git rev-parse --short HEAD', { cwd: repoPath, env: execEnv });
return stdout.trim();
}
/**
* Determine whether the repository contains uncommitted local changes.
*
* @param repoPath - Filesystem path to the Git repository to check
* @returns `true` if the repository has any uncommitted changes, `false` otherwise
*/
export async function hasLocalChanges(repoPath: string): Promise<boolean> {
const { stdout } = await execAsync('git status --porcelain', { cwd: repoPath, env: execEnv });
return stdout.trim().length > 0;
}
/**
* Determine whether a string is a well-formed git remote URL and contains no shell metacharacters.
*
* @param url - The URL to validate
* @returns `true` if `url` starts with a common git protocol (`https://`, `git@`, `git://`, `ssh://`) and does not contain shell metacharacters, `false` otherwise.
*/
export function isValidGitUrl(url: string): boolean {
// Allow HTTPS, SSH, and git protocols
const startsWithValidProtocol =
url.startsWith('https://') ||
url.startsWith('git@') ||
url.startsWith('git://') ||
url.startsWith('ssh://');
// Block shell metacharacters to prevent command injection
const hasShellChars = /[;`|&<>()$!\\[\] ]/.test(url);
return startsWithValidProtocol && !hasShellChars;
}

View File

@@ -1,37 +0,0 @@
/**
* Update routes - HTTP API for checking and applying updates
*
* Provides endpoints for:
* - Checking if updates are available from upstream
* - Pulling updates from upstream
* - Getting current installation info
*/
import { Router } from 'express';
import type { SettingsService } from '../../services/settings-service.js';
import { createCheckHandler } from './routes/check.js';
import { createPullHandler } from './routes/pull.js';
import { createInfoHandler } from './routes/info.js';
/**
* Create an Express Router that exposes API endpoints for update operations.
*
* @returns An Express Router with the routes:
* - GET `/check` — checks for available updates
* - POST `/pull` — pulls updates from upstream
* - GET `/info` — returns current installation info
*/
export function createUpdatesRoutes(settingsService: SettingsService): Router {
const router = Router();
// GET /api/updates/check - Check if updates are available
router.get('/check', createCheckHandler(settingsService));
// POST /api/updates/pull - Pull updates from upstream
router.post('/pull', createPullHandler(settingsService));
// GET /api/updates/info - Get current installation info
router.get('/info', createInfoHandler(settingsService));
return router;
}

View File

@@ -1,177 +0,0 @@
/**
* GET /check endpoint - Check if updates are available
*
* Compares local version with the remote upstream version.
*/
import type { Request, Response } from 'express';
import type { SettingsService } from '../../../services/settings-service.js';
import type { UpdateCheckResult } from '@automaker/types';
import crypto from 'crypto';
import {
execAsync,
execEnv,
getAutomakerRoot,
getCurrentCommit,
getShortCommit,
isGitRepo,
isGitAvailable,
isValidGitUrl,
getErrorMessage,
logError,
} from '../common.js';
/**
* Create an Express handler for the update check endpoint that compares the local Git commit
* against a configured upstream to determine whether an update is available.
*
* The handler validates Git availability and repository state, reads the upstream URL from
* global settings (with a default), attempts to fetch the upstream main branch using a
* temporary remote, and returns a structured result describing local and remote commits and
* whether the remote is ahead.
*
* @param settingsService - Service used to read global settings (used to obtain `autoUpdate.upstreamUrl`)
* @returns An Express request handler that responds with JSON. On success the response is
* `{ success: true, result }` where `result` is an `UpdateCheckResult`. On error the response
* is `{ success: false, error }`. If fetching the upstream fails the handler still responds
* with `{ success: true, result }` where `result` indicates no update and includes an `error` message.
*/
export function createCheckHandler(settingsService: SettingsService) {
return async (_req: Request, res: Response): Promise<void> => {
try {
const installPath = getAutomakerRoot();
// Check if git is available
if (!(await isGitAvailable())) {
res.status(500).json({
success: false,
error: 'Git is not installed or not available in PATH',
});
return;
}
// Check if automaker directory is a git repo
if (!(await isGitRepo(installPath))) {
res.status(500).json({
success: false,
error: 'Automaker installation is not a git repository',
});
return;
}
// Get settings for upstream URL
const settings = await settingsService.getGlobalSettings();
const sourceUrl =
settings.autoUpdate?.upstreamUrl || 'https://github.com/AutoMaker-Org/automaker.git';
// Validate URL to prevent command injection
if (!isValidGitUrl(sourceUrl)) {
res.status(400).json({
success: false,
error: 'Invalid upstream URL format',
});
return;
}
// Get local version
const localVersion = await getCurrentCommit(installPath);
const localVersionShort = await getShortCommit(installPath);
// Use a random remote name to avoid conflicts with concurrent checks
const tempRemoteName = `automaker-update-check-${crypto.randomBytes(8).toString('hex')}`;
try {
// Add temporary remote
await execAsync(`git remote add ${tempRemoteName} "${sourceUrl}"`, {
cwd: installPath,
env: execEnv,
});
// Fetch from the temporary remote
await execAsync(`git fetch ${tempRemoteName} main`, {
cwd: installPath,
env: execEnv,
});
// Get remote version
const { stdout: remoteVersionOutput } = await execAsync(
`git rev-parse ${tempRemoteName}/main`,
{ cwd: installPath, env: execEnv }
);
const remoteVersion = remoteVersionOutput.trim();
// Get short remote version
const { stdout: remoteVersionShortOutput } = await execAsync(
`git rev-parse --short ${tempRemoteName}/main`,
{ cwd: installPath, env: execEnv }
);
const remoteVersionShort = remoteVersionShortOutput.trim();
// Check if remote is ahead of local (update available)
// git merge-base --is-ancestor <commit1> <commit2> returns 0 if commit1 is ancestor of commit2
let updateAvailable = false;
if (localVersion !== remoteVersion) {
try {
// Check if local is already an ancestor of remote (remote is ahead)
await execAsync(`git merge-base --is-ancestor ${localVersion} ${remoteVersion}`, {
cwd: installPath,
env: execEnv,
});
// If we get here (exit code 0), local is ancestor of remote, so update is available
updateAvailable = true;
} catch {
// Exit code 1 means local is NOT an ancestor of remote
// This means either local is ahead, or branches have diverged
// In either case, we don't show "update available"
updateAvailable = false;
}
}
const result: UpdateCheckResult = {
updateAvailable,
localVersion,
localVersionShort,
remoteVersion,
remoteVersionShort,
sourceUrl,
installPath,
};
res.json({
success: true,
result,
});
} catch (fetchError) {
const errorMsg = getErrorMessage(fetchError);
logError(fetchError, 'Failed to fetch from upstream');
res.json({
success: true,
result: {
updateAvailable: false,
localVersion,
localVersionShort,
remoteVersion: null,
remoteVersionShort: null,
sourceUrl,
installPath,
error: `Could not fetch from upstream: ${errorMsg}`,
} satisfies UpdateCheckResult,
});
} finally {
// Always clean up temp remote
try {
await execAsync(`git remote remove ${tempRemoteName}`, {
cwd: installPath,
env: execEnv,
});
} catch {
// Ignore cleanup errors
}
}
} catch (error) {
logError(error, 'Update check failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,136 +0,0 @@
/**
* GET /info endpoint - Get current installation info
*
* Returns current version, branch, and configuration info.
*/
import type { Request, Response } from 'express';
import type { SettingsService } from '../../../services/settings-service.js';
import { DEFAULT_AUTO_UPDATE_SETTINGS, type UpdateInfo } from '@automaker/types';
import {
execAsync,
execEnv,
getAutomakerRoot,
getCurrentCommit,
getShortCommit,
isGitRepo,
isGitAvailable,
hasLocalChanges,
getErrorMessage,
logError,
} from '../common.js';
/**
* Creates an Express handler that returns update information for the application installation.
*
* The produced handler responds with a JSON payload containing an UpdateInfo result describing
* installation path, git-based version and branch data (when available), local change status,
* and configured auto-update settings. On failure the handler responds with HTTP 500 and a JSON
* error message.
*
* @returns An Express request handler that sends `{ success: true, result: UpdateInfo }` on success
* or `{ success: false, error: string }` with HTTP 500 on error.
*/
export function createInfoHandler(settingsService: SettingsService) {
return async (_req: Request, res: Response): Promise<void> => {
try {
const installPath = getAutomakerRoot();
// Get settings
const settings = await settingsService.getGlobalSettings();
const autoUpdateSettings = settings.autoUpdate || DEFAULT_AUTO_UPDATE_SETTINGS;
// Check if git is available
const gitAvailable = await isGitAvailable();
if (!gitAvailable) {
const result: UpdateInfo = {
installPath,
currentVersion: null,
currentVersionShort: null,
currentBranch: null,
hasLocalChanges: false,
sourceUrl: autoUpdateSettings.upstreamUrl,
autoUpdateEnabled: autoUpdateSettings.enabled,
checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes,
updateType: 'git',
mechanismInfo: {
isGitRepo: false,
gitAvailable: false,
},
};
res.json({
success: true,
result,
});
return;
}
// Check if it's a git repo
const isRepo = await isGitRepo(installPath);
if (!isRepo) {
const result: UpdateInfo = {
installPath,
currentVersion: null,
currentVersionShort: null,
currentBranch: null,
hasLocalChanges: false,
sourceUrl: autoUpdateSettings.upstreamUrl,
autoUpdateEnabled: autoUpdateSettings.enabled,
checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes,
updateType: 'git',
mechanismInfo: {
isGitRepo: false,
gitAvailable: true,
},
};
res.json({
success: true,
result,
});
return;
}
// Get git info
const currentVersion = await getCurrentCommit(installPath);
const currentVersionShort = await getShortCommit(installPath);
// Get current branch
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: installPath,
env: execEnv,
});
const currentBranch = branchOutput.trim();
// Check for local changes
const localChanges = await hasLocalChanges(installPath);
const result: UpdateInfo = {
installPath,
currentVersion,
currentVersionShort,
currentBranch,
hasLocalChanges: localChanges,
sourceUrl: autoUpdateSettings.upstreamUrl,
autoUpdateEnabled: autoUpdateSettings.enabled,
checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes,
updateType: 'git',
mechanismInfo: {
isGitRepo: true,
gitAvailable: true,
},
};
res.json({
success: true,
result,
});
} catch (error) {
logError(error, 'Failed to get update info');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,170 +0,0 @@
/**
* POST /pull endpoint - Pull updates from upstream
*
* Executes git pull from the configured upstream repository.
*/
import type { Request, Response } from 'express';
import type { SettingsService } from '../../../services/settings-service.js';
import type { UpdatePullResult } from '@automaker/types';
import crypto from 'crypto';
import {
execAsync,
execEnv,
getAutomakerRoot,
getCurrentCommit,
getShortCommit,
isGitRepo,
isGitAvailable,
isValidGitUrl,
hasLocalChanges,
getErrorMessage,
logError,
} from '../common.js';
/**
* Create an Express handler for POST /pull that updates the local Automaker installation by pulling from the configured upstream Git repository.
*
* The handler validates Git availability and that the install directory is a git repository, ensures there are no local uncommitted changes, validates the upstream URL from global settings, and performs a fast-forward-only pull using a temporary remote. It returns a JSON UpdatePullResult on success, or an error JSON with appropriate HTTP status codes for invalid input, merge conflicts, non-fast-forward divergence, or unexpected failures.
*
* @param settingsService - Service used to read global settings (used to obtain the upstream URL)
* @returns An Express request handler that performs the safe fast-forward pull and sends a JSON response describing the result or error
*/
export function createPullHandler(settingsService: SettingsService) {
return async (_req: Request, res: Response): Promise<void> => {
try {
const installPath = getAutomakerRoot();
// Check if git is available
if (!(await isGitAvailable())) {
res.status(500).json({
success: false,
error: 'Git is not installed or not available in PATH',
});
return;
}
// Check if automaker directory is a git repo
if (!(await isGitRepo(installPath))) {
res.status(500).json({
success: false,
error: 'Automaker installation is not a git repository',
});
return;
}
// Check for local changes
if (await hasLocalChanges(installPath)) {
res.status(400).json({
success: false,
error: 'You have local uncommitted changes. Please commit or stash them before updating.',
});
return;
}
// Get settings for upstream URL
const settings = await settingsService.getGlobalSettings();
const sourceUrl =
settings.autoUpdate?.upstreamUrl || 'https://github.com/AutoMaker-Org/automaker.git';
// Validate URL to prevent command injection
if (!isValidGitUrl(sourceUrl)) {
res.status(400).json({
success: false,
error: 'Invalid upstream URL format',
});
return;
}
// Get current version before pull
const previousVersion = await getCurrentCommit(installPath);
const previousVersionShort = await getShortCommit(installPath);
// Use a random remote name to avoid conflicts with concurrent pulls
const tempRemoteName = `automaker-update-pull-${crypto.randomBytes(8).toString('hex')}`;
try {
// Add temporary remote
await execAsync(`git remote add ${tempRemoteName} "${sourceUrl}"`, {
cwd: installPath,
env: execEnv,
});
// Fetch first
await execAsync(`git fetch ${tempRemoteName} main`, {
cwd: installPath,
env: execEnv,
});
// Merge the fetched changes
const { stdout: mergeOutput } = await execAsync(
`git merge ${tempRemoteName}/main --ff-only`,
{ cwd: installPath, env: execEnv }
);
// Get new version after merge
const newVersion = await getCurrentCommit(installPath);
const newVersionShort = await getShortCommit(installPath);
const alreadyUpToDate =
mergeOutput.includes('Already up to date') || previousVersion === newVersion;
const result: UpdatePullResult = {
success: true,
previousVersion,
previousVersionShort,
newVersion,
newVersionShort,
alreadyUpToDate,
message: alreadyUpToDate
? 'Already up to date'
: `Updated from ${previousVersionShort} to ${newVersionShort}`,
};
res.json({
success: true,
result,
});
} catch (pullError) {
const errorMsg = getErrorMessage(pullError);
logError(pullError, 'Failed to pull updates');
// Check for common errors
if (errorMsg.includes('not possible to fast-forward')) {
res.status(400).json({
success: false,
error:
'Cannot fast-forward merge. Your local branch has diverged from upstream. Please resolve manually.',
});
return;
}
if (errorMsg.includes('CONFLICT')) {
res.status(400).json({
success: false,
error: 'Merge conflict detected. Please resolve conflicts manually.',
});
return;
}
res.status(500).json({
success: false,
error: `Failed to pull updates: ${errorMsg}`,
});
} finally {
// Always clean up temp remote
try {
await execAsync(`git remote remove ${tempRemoteName}`, {
cwd: installPath,
env: execEnv,
});
} catch {
// Ignore cleanup errors
}
}
} catch (error) {
logError(error, 'Update pull failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -111,19 +111,6 @@ export async function isGitRepo(repoPath: string): Promise<boolean> {
} }
} }
/**
* Check if a git repository has at least one commit (i.e., HEAD exists)
* Returns false for freshly initialized repos with no commits
*/
export async function hasCommits(repoPath: string): Promise<boolean> {
try {
await execAsync('git rev-parse --verify HEAD', { cwd: repoPath });
return true;
} catch {
return false;
}
}
/** /**
* Check if an error is ENOENT (file/path not found or spawn failed) * Check if an error is ENOENT (file/path not found or spawn failed)
* These are expected in test environments with mock paths * These are expected in test environments with mock paths

View File

@@ -4,7 +4,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { validatePathParams } from '../../middleware/validate-paths.js'; import { validatePathParams } from '../../middleware/validate-paths.js';
import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js';
import { createInfoHandler } from './routes/info.js'; import { createInfoHandler } from './routes/info.js';
import { createStatusHandler } from './routes/status.js'; import { createStatusHandler } from './routes/status.js';
import { createListHandler } from './routes/list.js'; import { createListHandler } from './routes/list.js';
@@ -39,42 +38,17 @@ export function createWorktreeRoutes(): Router {
router.post('/list', createListHandler()); router.post('/list', createListHandler());
router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler()); router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler());
router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler()); router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler());
router.post( router.post('/merge', validatePathParams('projectPath'), createMergeHandler());
'/merge',
validatePathParams('projectPath'),
requireValidProject,
createMergeHandler()
);
router.post('/create', validatePathParams('projectPath'), createCreateHandler()); router.post('/create', validatePathParams('projectPath'), createCreateHandler());
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler()); router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
router.post('/create-pr', createCreatePRHandler()); router.post('/create-pr', createCreatePRHandler());
router.post('/pr-info', createPRInfoHandler()); router.post('/pr-info', createPRInfoHandler());
router.post( router.post('/commit', validatePathParams('worktreePath'), createCommitHandler());
'/commit', router.post('/push', validatePathParams('worktreePath'), createPushHandler());
validatePathParams('worktreePath'), router.post('/pull', validatePathParams('worktreePath'), createPullHandler());
requireGitRepoOnly, router.post('/checkout-branch', createCheckoutBranchHandler());
createCommitHandler() router.post('/list-branches', validatePathParams('worktreePath'), createListBranchesHandler());
); router.post('/switch-branch', createSwitchBranchHandler());
router.post(
'/push',
validatePathParams('worktreePath'),
requireValidWorktree,
createPushHandler()
);
router.post(
'/pull',
validatePathParams('worktreePath'),
requireValidWorktree,
createPullHandler()
);
router.post('/checkout-branch', requireValidWorktree, createCheckoutBranchHandler());
router.post(
'/list-branches',
validatePathParams('worktreePath'),
requireValidWorktree,
createListBranchesHandler()
);
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler()); router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
router.get('/default-editor', createGetDefaultEditorHandler()); router.get('/default-editor', createGetDefaultEditorHandler());
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler()); router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());

View File

@@ -1,74 +0,0 @@
/**
* Middleware for worktree route validation
*/
import type { Request, Response, NextFunction } from 'express';
import { isGitRepo, hasCommits } from './common.js';
interface ValidationOptions {
/** Check if the path is a git repository (default: true) */
requireGitRepo?: boolean;
/** Check if the repository has at least one commit (default: true) */
requireCommits?: boolean;
/** The name of the request body field containing the path (default: 'worktreePath') */
pathField?: 'worktreePath' | 'projectPath';
}
/**
* Middleware factory to validate that a path is a valid git repository with commits.
* This reduces code duplication across route handlers.
*
* @param options - Validation options
* @returns Express middleware function
*/
export function requireValidGitRepo(options: ValidationOptions = {}) {
const { requireGitRepo = true, requireCommits = true, pathField = 'worktreePath' } = options;
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const repoPath = req.body[pathField] as string | undefined;
if (!repoPath) {
// Let the route handler deal with missing path validation
next();
return;
}
if (requireGitRepo && !(await isGitRepo(repoPath))) {
res.status(400).json({
success: false,
error: 'Not a git repository',
code: 'NOT_GIT_REPO',
});
return;
}
if (requireCommits && !(await hasCommits(repoPath))) {
res.status(400).json({
success: false,
error: 'Repository has no commits yet',
code: 'NO_COMMITS',
});
return;
}
next();
};
}
/**
* Middleware to validate git repo for worktreePath field
*/
export const requireValidWorktree = requireValidGitRepo({ pathField: 'worktreePath' });
/**
* Middleware to validate git repo for projectPath field
*/
export const requireValidProject = requireValidGitRepo({ pathField: 'projectPath' });
/**
* Middleware to validate git repo without requiring commits (for commit route)
*/
export const requireGitRepoOnly = requireValidGitRepo({
pathField: 'worktreePath',
requireCommits: false,
});

View File

@@ -1,8 +1,5 @@
/** /**
* POST /checkout-branch endpoint - Create and checkout a new branch * POST /checkout-branch endpoint - Create and checkout a new branch
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';

View File

@@ -1,8 +1,5 @@
/** /**
* POST /commit endpoint - Commit changes in a worktree * POST /commit endpoint - Commit changes in a worktree
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';

View File

@@ -56,31 +56,21 @@ export function createCreatePRHandler() {
} }
// Check for uncommitted changes // Check for uncommitted changes
console.log(`[CreatePR] Checking for uncommitted changes in: ${worktreePath}`);
const { stdout: status } = await execAsync('git status --porcelain', { const { stdout: status } = await execAsync('git status --porcelain', {
cwd: worktreePath, cwd: worktreePath,
env: execEnv, env: execEnv,
}); });
const hasChanges = status.trim().length > 0; const hasChanges = status.trim().length > 0;
console.log(`[CreatePR] Has uncommitted changes: ${hasChanges}`);
if (hasChanges) {
console.log(`[CreatePR] Changed files:\n${status}`);
}
// If there are changes, commit them // If there are changes, commit them
let commitHash: string | null = null; let commitHash: string | null = null;
let commitError: string | null = null;
if (hasChanges) { if (hasChanges) {
const message = commitMessage || `Changes from ${branchName}`; const message = commitMessage || `Changes from ${branchName}`;
console.log(`[CreatePR] Committing changes with message: ${message}`);
try {
// Stage all changes // Stage all changes
console.log(`[CreatePR] Running: git add -A`);
await execAsync('git add -A', { cwd: worktreePath, env: execEnv }); await execAsync('git add -A', { cwd: worktreePath, env: execEnv });
// Create commit // Create commit
console.log(`[CreatePR] Running: git commit`);
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
cwd: worktreePath, cwd: worktreePath,
env: execEnv, env: execEnv,
@@ -92,20 +82,6 @@ export function createCreatePRHandler() {
env: execEnv, env: execEnv,
}); });
commitHash = hashOutput.trim().substring(0, 8); commitHash = hashOutput.trim().substring(0, 8);
console.log(`[CreatePR] Commit successful: ${commitHash}`);
} catch (commitErr: unknown) {
const err = commitErr as { stderr?: string; message?: string };
commitError = err.stderr || err.message || 'Commit failed';
console.error(`[CreatePR] Commit failed: ${commitError}`);
// Return error immediately - don't proceed with push/PR if commit fails
res.status(500).json({
success: false,
error: `Failed to commit changes: ${commitError}`,
commitError,
});
return;
}
} }
// Push the branch to remote // Push the branch to remote
@@ -384,9 +360,8 @@ export function createCreatePRHandler() {
success: true, success: true,
result: { result: {
branch: branchName, branch: branchName,
committed: hasChanges && !commitError, committed: hasChanges,
commitHash, commitHash,
commitError: commitError || undefined,
pushed: true, pushed: true,
prUrl, prUrl,
prNumber, prNumber,

View File

@@ -1,8 +1,5 @@
/** /**
* POST /list-branches endpoint - List all local branches * POST /list-branches endpoint - List all local branches
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';

View File

@@ -1,8 +1,5 @@
/** /**
* POST /merge endpoint - Merge feature (merge worktree branch into main) * POST /merge endpoint - Merge feature (merge worktree branch into main)
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidProject middleware in index.ts
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';

View File

@@ -1,8 +1,5 @@
/** /**
* POST /pull endpoint - Pull latest changes for a worktree/branch * POST /pull endpoint - Pull latest changes for a worktree/branch
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';

View File

@@ -1,8 +1,5 @@
/** /**
* POST /push endpoint - Push a worktree branch to remote * POST /push endpoint - Push a worktree branch to remote
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';

View File

@@ -4,9 +4,6 @@
* Simple branch switching. * Simple branch switching.
* If there are uncommitted changes, the switch will fail and * If there are uncommitted changes, the switch will fail and
* the user should commit first. * the user should commit first.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';

View File

@@ -16,12 +16,6 @@ import {
import { ProviderFactory } from '../providers/provider-factory.js'; import { ProviderFactory } from '../providers/provider-factory.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { PathNotAllowedError } from '@automaker/platform'; import { PathNotAllowedError } from '@automaker/platform';
import type { SettingsService } from './settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getEnableSandboxModeSetting,
filterClaudeMdFromContext,
} from '../lib/settings-helpers.js';
interface Message { interface Message {
id: string; id: string;
@@ -36,14 +30,6 @@ interface Message {
isError?: boolean; isError?: boolean;
} }
interface QueuedPrompt {
id: string;
message: string;
imagePaths?: string[];
model?: string;
addedAt: string;
}
interface Session { interface Session {
messages: Message[]; messages: Message[];
isRunning: boolean; isRunning: boolean;
@@ -51,7 +37,6 @@ interface Session {
workingDirectory: string; workingDirectory: string;
model?: string; model?: string;
sdkSessionId?: string; // Claude SDK session ID for conversation continuity sdkSessionId?: string; // Claude SDK session ID for conversation continuity
promptQueue: QueuedPrompt[]; // Queue of prompts to auto-run after current task
} }
interface SessionMetadata { interface SessionMetadata {
@@ -72,13 +57,11 @@ export class AgentService {
private stateDir: string; private stateDir: string;
private metadataFile: string; private metadataFile: string;
private events: EventEmitter; private events: EventEmitter;
private settingsService: SettingsService | null = null;
constructor(dataDir: string, events: EventEmitter, settingsService?: SettingsService) { constructor(dataDir: string, events: EventEmitter) {
this.stateDir = path.join(dataDir, 'agent-sessions'); this.stateDir = path.join(dataDir, 'agent-sessions');
this.metadataFile = path.join(dataDir, 'sessions-metadata.json'); this.metadataFile = path.join(dataDir, 'sessions-metadata.json');
this.events = events; this.events = events;
this.settingsService = settingsService ?? null;
} }
async initialize(): Promise<void> { async initialize(): Promise<void> {
@@ -107,16 +90,12 @@ export class AgentService {
// Validate that the working directory is allowed using centralized validation // Validate that the working directory is allowed using centralized validation
validateWorkingDirectory(resolvedWorkingDirectory); validateWorkingDirectory(resolvedWorkingDirectory);
// Load persisted queue
const promptQueue = await this.loadQueueState(sessionId);
this.sessions.set(sessionId, { this.sessions.set(sessionId, {
messages, messages,
isRunning: false, isRunning: false,
abortController: null, abortController: null,
workingDirectory: resolvedWorkingDirectory, workingDirectory: resolvedWorkingDirectory,
sdkSessionId: sessionMetadata?.sdkSessionId, // Load persisted SDK session ID sdkSessionId: sessionMetadata?.sdkSessionId, // Load persisted SDK session ID
promptQueue,
}); });
} }
@@ -146,12 +125,10 @@ export class AgentService {
}) { }) {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) { if (!session) {
console.error('[AgentService] ERROR: Session not found:', sessionId);
throw new Error(`Session ${sessionId} not found`); throw new Error(`Session ${sessionId} not found`);
} }
if (session.isRunning) { if (session.isRunning) {
console.error('[AgentService] ERROR: Agent already running for session:', sessionId);
throw new Error('Agent is already processing a message'); throw new Error('Agent is already processing a message');
} }
@@ -197,11 +174,6 @@ export class AgentService {
session.isRunning = true; session.isRunning = true;
session.abortController = new AbortController(); session.abortController = new AbortController();
// Emit started event so UI can show thinking indicator
this.emitAgentEvent(sessionId, {
type: 'started',
});
// Emit user message event // Emit user message event
this.emitAgentEvent(sessionId, { this.emitAgentEvent(sessionId, {
type: 'message', type: 'message',
@@ -214,29 +186,12 @@ export class AgentService {
// Determine the effective working directory for context loading // Determine the effective working directory for context loading
const effectiveWorkDir = workingDirectory || session.workingDirectory; const effectiveWorkDir = workingDirectory || session.workingDirectory;
// Load autoLoadClaudeMd setting (project setting takes precedence over global)
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
effectiveWorkDir,
this.settingsService,
'[AgentService]'
);
// Load enableSandboxMode setting (global setting only)
const enableSandboxMode = await getEnableSandboxModeSetting(
this.settingsService,
'[AgentService]'
);
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
const contextResult = await loadContextFiles({ const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
projectPath: effectiveWorkDir, projectPath: effectiveWorkDir,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'], fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
}); });
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
// (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
// Build combined system prompt with base prompt and context files // Build combined system prompt with base prompt and context files
const baseSystemPrompt = this.getSystemPrompt(); const baseSystemPrompt = this.getSystemPrompt();
const combinedSystemPrompt = contextFilesPrompt const combinedSystemPrompt = contextFilesPrompt
@@ -250,8 +205,6 @@ export class AgentService {
sessionModel: session.model, sessionModel: session.model,
systemPrompt: combinedSystemPrompt, systemPrompt: combinedSystemPrompt,
abortController: session.abortController!, abortController: session.abortController!,
autoLoadClaudeMd,
enableSandboxMode,
}); });
// Extract model, maxTurns, and allowedTools from SDK options // Extract model, maxTurns, and allowedTools from SDK options
@@ -262,18 +215,20 @@ export class AgentService {
// Get provider for this model // Get provider for this model
const provider = ProviderFactory.getProviderForModel(effectiveModel); const provider = ProviderFactory.getProviderForModel(effectiveModel);
console.log(
`[AgentService] Using provider "${provider.getName()}" for model "${effectiveModel}"`
);
// Build options for provider // Build options for provider
const options: ExecuteOptions = { const options: ExecuteOptions = {
prompt: '', // Will be set below based on images prompt: '', // Will be set below based on images
model: effectiveModel, model: effectiveModel,
cwd: effectiveWorkDir, cwd: effectiveWorkDir,
systemPrompt: sdkOptions.systemPrompt, systemPrompt: combinedSystemPrompt,
maxTurns: maxTurns, maxTurns: maxTurns,
allowedTools: allowedTools, allowedTools: allowedTools,
abortController: session.abortController!, abortController: session.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
}; };
@@ -299,6 +254,7 @@ export class AgentService {
// Capture SDK session ID from any message and persist it // Capture SDK session ID from any message and persist it
if (msg.session_id && !session.sdkSessionId) { if (msg.session_id && !session.sdkSessionId) {
session.sdkSessionId = msg.session_id; session.sdkSessionId = msg.session_id;
console.log(`[AgentService] Captured SDK session ID: ${msg.session_id}`);
// Persist the SDK session ID to ensure conversation continuity across server restarts // Persist the SDK session ID to ensure conversation continuity across server restarts
await this.updateSession(sessionId, { sdkSessionId: msg.session_id }); await this.updateSession(sessionId, { sdkSessionId: msg.session_id });
} }
@@ -363,9 +319,6 @@ export class AgentService {
session.isRunning = false; session.isRunning = false;
session.abortController = null; session.abortController = null;
// Process next item in queue after completion
setImmediate(() => this.processNextInQueue(sessionId));
return { return {
success: true, success: true,
message: currentAssistantMessage, message: currentAssistantMessage,
@@ -604,165 +557,6 @@ export class AgentService {
return true; return true;
} }
// Queue management methods
/**
* Add a prompt to the queue for later execution
*/
async addToQueue(
sessionId: string,
prompt: { message: string; imagePaths?: string[]; model?: string }
): Promise<{ success: boolean; queuedPrompt?: QueuedPrompt; error?: string }> {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
const queuedPrompt: QueuedPrompt = {
id: this.generateId(),
message: prompt.message,
imagePaths: prompt.imagePaths,
model: prompt.model,
addedAt: new Date().toISOString(),
};
session.promptQueue.push(queuedPrompt);
await this.saveQueueState(sessionId, session.promptQueue);
// Emit queue update event
this.emitAgentEvent(sessionId, {
type: 'queue_updated',
queue: session.promptQueue,
});
return { success: true, queuedPrompt };
}
/**
* Get the current queue for a session
*/
getQueue(sessionId: string): { success: boolean; queue?: QueuedPrompt[]; error?: string } {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
return { success: true, queue: session.promptQueue };
}
/**
* Remove a specific prompt from the queue
*/
async removeFromQueue(
sessionId: string,
promptId: string
): Promise<{ success: boolean; error?: string }> {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
const index = session.promptQueue.findIndex((p) => p.id === promptId);
if (index === -1) {
return { success: false, error: 'Prompt not found in queue' };
}
session.promptQueue.splice(index, 1);
await this.saveQueueState(sessionId, session.promptQueue);
this.emitAgentEvent(sessionId, {
type: 'queue_updated',
queue: session.promptQueue,
});
return { success: true };
}
/**
* Clear all prompts from the queue
*/
async clearQueue(sessionId: string): Promise<{ success: boolean; error?: string }> {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
session.promptQueue = [];
await this.saveQueueState(sessionId, []);
this.emitAgentEvent(sessionId, {
type: 'queue_updated',
queue: [],
});
return { success: true };
}
/**
* Save queue state to disk for persistence
*/
private async saveQueueState(sessionId: string, queue: QueuedPrompt[]): Promise<void> {
const queueFile = path.join(this.stateDir, `${sessionId}-queue.json`);
try {
await secureFs.writeFile(queueFile, JSON.stringify(queue, null, 2), 'utf-8');
} catch (error) {
console.error('[AgentService] Failed to save queue state:', error);
}
}
/**
* Load queue state from disk
*/
private async loadQueueState(sessionId: string): Promise<QueuedPrompt[]> {
const queueFile = path.join(this.stateDir, `${sessionId}-queue.json`);
try {
const data = (await secureFs.readFile(queueFile, 'utf-8')) as string;
return JSON.parse(data);
} catch {
return [];
}
}
/**
* Process the next item in the queue (called after task completion)
*/
private async processNextInQueue(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session || session.promptQueue.length === 0) {
return;
}
// Don't process if already running
if (session.isRunning) {
return;
}
const nextPrompt = session.promptQueue.shift();
if (!nextPrompt) return;
await this.saveQueueState(sessionId, session.promptQueue);
this.emitAgentEvent(sessionId, {
type: 'queue_updated',
queue: session.promptQueue,
});
try {
await this.sendMessage({
sessionId,
message: nextPrompt.message,
imagePaths: nextPrompt.imagePaths,
model: nextPrompt.model,
});
} catch (error) {
console.error('[AgentService] Failed to process queued prompt:', error);
this.emitAgentEvent(sessionId, {
type: 'queue_error',
error: (error as Error).message,
promptId: nextPrompt.id,
});
}
}
private emitAgentEvent(sessionId: string, data: Record<string, unknown>): void { private emitAgentEvent(sessionId: string, data: Record<string, unknown>): void {
this.events.emit('agent:stream', { sessionId, ...data }); this.events.emit('agent:stream', { sessionId, ...data });
} }

View File

@@ -25,18 +25,8 @@ import { promisify } from 'util';
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 { import { createAutoModeOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
createAutoModeOptions,
createCustomOptions,
validateWorkingDirectory,
} from '../lib/sdk-options.js';
import { FeatureLoader } from './feature-loader.js'; import { FeatureLoader } from './feature-loader.js';
import type { SettingsService } from './settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getEnableSandboxModeSetting,
filterClaudeMdFromContext,
} from '../lib/settings-helpers.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -351,11 +341,9 @@ export class AutoModeService {
private autoLoopAbortController: AbortController | null = null; private autoLoopAbortController: AbortController | null = null;
private config: AutoModeConfig | null = null; private config: AutoModeConfig | null = null;
private pendingApprovals = new Map<string, PendingApproval>(); private pendingApprovals = new Map<string, PendingApproval>();
private settingsService: SettingsService | null = null;
constructor(events: EventEmitter, settingsService?: SettingsService) { constructor(events: EventEmitter) {
this.events = events; this.events = events;
this.settingsService = settingsService ?? null;
} }
/** /**
@@ -563,25 +551,14 @@ export class AutoModeService {
// Update feature status to in_progress // Update feature status to in_progress
await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); await this.updateFeatureStatus(projectPath, featureId, 'in_progress');
// Load autoLoadClaudeMd setting to determine context loading strategy
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
this.settingsService,
'[AutoMode]'
);
// Build the prompt - use continuation prompt if provided (for recovery after plan approval) // Build the prompt - use continuation prompt if provided (for recovery after plan approval)
let prompt: string; let prompt: string;
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt
const contextResult = await loadContextFiles({ const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
projectPath, projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'], fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
}); });
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
// (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
if (options?.continuationPrompt) { if (options?.continuationPrompt) {
// Continuation prompt is used when recovering from a plan approval // Continuation prompt is used when recovering from a plan approval
// The plan was already approved, so skip the planning phase // The plan was already approved, so skip the planning phase
@@ -627,7 +604,6 @@ export class AutoModeService {
planningMode: feature.planningMode, planningMode: feature.planningMode,
requirePlanApproval: feature.requirePlanApproval, requirePlanApproval: feature.requirePlanApproval,
systemPrompt: contextFilesPrompt || undefined, systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd,
} }
); );
@@ -770,23 +746,12 @@ export class AutoModeService {
// No previous context // No previous context
} }
// Load autoLoadClaudeMd setting to determine context loading strategy
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
this.settingsService,
'[AutoMode]'
);
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt
const contextResult = await loadContextFiles({ const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
projectPath, projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'], fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
}); });
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
// (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
// Build complete prompt with feature info, previous context, and follow-up instructions // Build complete prompt with feature info, previous context, and follow-up instructions
let fullPrompt = `## Follow-up on Feature Implementation let fullPrompt = `## Follow-up on Feature Implementation
@@ -914,7 +879,6 @@ Address the follow-up instructions above. Review the previous work and make the
planningMode: 'skip', // Follow-ups don't require approval planningMode: 'skip', // Follow-ups don't require approval
previousContent: previousContext || undefined, previousContent: previousContext || undefined,
systemPrompt: contextFilesPrompt || undefined, systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd,
} }
); );
@@ -1101,6 +1065,11 @@ Address the follow-up instructions above. Review the previous work and make the
* Analyze project to gather context * Analyze project to gather context
*/ */
async analyzeProject(projectPath: string): Promise<void> { async analyzeProject(projectPath: string): Promise<void> {
// Validate project path before proceeding
// This is called here because analyzeProject builds ExecuteOptions directly
// without using a factory function from sdk-options.ts
validateWorkingDirectory(projectPath);
const abortController = new AbortController(); const abortController = new AbortController();
const analysisFeatureId = `analysis-${Date.now()}`; const analysisFeatureId = `analysis-${Date.now()}`;
@@ -1128,32 +1097,13 @@ Format your response as a structured markdown document.`;
const analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude); const analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude);
const provider = ProviderFactory.getProviderForModel(analysisModel); const provider = ProviderFactory.getProviderForModel(analysisModel);
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
this.settingsService,
'[AutoMode]'
);
// Use createCustomOptions for centralized SDK configuration with CLAUDE.md support
const sdkOptions = createCustomOptions({
cwd: projectPath,
model: analysisModel,
maxTurns: 5,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
autoLoadClaudeMd,
});
const options: ExecuteOptions = { const options: ExecuteOptions = {
prompt, prompt,
model: sdkOptions.model ?? analysisModel, model: analysisModel,
cwd: sdkOptions.cwd ?? projectPath, maxTurns: 5,
maxTurns: sdkOptions.maxTurns, cwd: projectPath,
allowedTools: sdkOptions.allowedTools as string[], allowedTools: ['Read', 'Glob', 'Grep'],
abortController, abortController,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
}; };
const stream = provider.executeQuery(options); const stream = provider.executeQuery(options);
@@ -1758,7 +1708,6 @@ This helps parse your summary correctly in the output logs.`;
requirePlanApproval?: boolean; requirePlanApproval?: boolean;
previousContent?: string; previousContent?: string;
systemPrompt?: string; systemPrompt?: string;
autoLoadClaudeMd?: boolean;
} }
): Promise<void> { ): Promise<void> {
const finalProjectPath = options?.projectPath || projectPath; const finalProjectPath = options?.projectPath || projectPath;
@@ -1831,23 +1780,11 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
return; return;
} }
// Load autoLoadClaudeMd setting (project setting takes precedence over global)
// Use provided value if available, otherwise load from settings
const autoLoadClaudeMd =
options?.autoLoadClaudeMd !== undefined
? options.autoLoadClaudeMd
: await getAutoLoadClaudeMdSetting(finalProjectPath, this.settingsService, '[AutoMode]');
// Load enableSandboxMode setting (global setting only)
const enableSandboxMode = await getEnableSandboxModeSetting(this.settingsService, '[AutoMode]');
// Build SDK options using centralized configuration for feature implementation // Build SDK options using centralized configuration for feature implementation
const sdkOptions = createAutoModeOptions({ const sdkOptions = createAutoModeOptions({
cwd: workDir, cwd: workDir,
model: model, model: model,
abortController, abortController,
autoLoadClaudeMd,
enableSandboxMode,
}); });
// Extract model, maxTurns, and allowedTools from SDK options // Extract model, maxTurns, and allowedTools from SDK options
@@ -1886,9 +1823,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
cwd: workDir, cwd: workDir,
allowedTools: allowedTools, allowedTools: allowedTools,
abortController, abortController,
systemPrompt: sdkOptions.systemPrompt, systemPrompt: options?.systemPrompt,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
}; };
// Execute via provider // Execute via provider

View File

@@ -179,7 +179,7 @@ describe('sdk-options.ts', () => {
it('should create options with chat settings', async () => { it('should create options with chat settings', async () => {
const { createChatOptions, TOOL_PRESETS, MAX_TURNS } = await import('@/lib/sdk-options.js'); const { createChatOptions, TOOL_PRESETS, MAX_TURNS } = await import('@/lib/sdk-options.js');
const options = createChatOptions({ cwd: '/test/path', enableSandboxMode: true }); const options = createChatOptions({ cwd: '/test/path' });
expect(options.cwd).toBe('/test/path'); expect(options.cwd).toBe('/test/path');
expect(options.maxTurns).toBe(MAX_TURNS.standard); expect(options.maxTurns).toBe(MAX_TURNS.standard);
@@ -212,27 +212,6 @@ describe('sdk-options.ts', () => {
expect(options.model).toBe('claude-sonnet-4-20250514'); expect(options.model).toBe('claude-sonnet-4-20250514');
}); });
it('should not set sandbox when enableSandboxMode is false', async () => {
const { createChatOptions } = await import('@/lib/sdk-options.js');
const options = createChatOptions({
cwd: '/test/path',
enableSandboxMode: false,
});
expect(options.sandbox).toBeUndefined();
});
it('should not set sandbox when enableSandboxMode is not provided', async () => {
const { createChatOptions } = await import('@/lib/sdk-options.js');
const options = createChatOptions({
cwd: '/test/path',
});
expect(options.sandbox).toBeUndefined();
});
}); });
describe('createAutoModeOptions', () => { describe('createAutoModeOptions', () => {
@@ -240,7 +219,7 @@ describe('sdk-options.ts', () => {
const { createAutoModeOptions, TOOL_PRESETS, MAX_TURNS } = const { createAutoModeOptions, TOOL_PRESETS, MAX_TURNS } =
await import('@/lib/sdk-options.js'); await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({ cwd: '/test/path', enableSandboxMode: true }); const options = createAutoModeOptions({ cwd: '/test/path' });
expect(options.cwd).toBe('/test/path'); expect(options.cwd).toBe('/test/path');
expect(options.maxTurns).toBe(MAX_TURNS.maximum); expect(options.maxTurns).toBe(MAX_TURNS.maximum);
@@ -273,27 +252,6 @@ describe('sdk-options.ts', () => {
expect(options.abortController).toBe(abortController); expect(options.abortController).toBe(abortController);
}); });
it('should not set sandbox when enableSandboxMode is false', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/test/path',
enableSandboxMode: false,
});
expect(options.sandbox).toBeUndefined();
});
it('should not set sandbox when enableSandboxMode is not provided', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/test/path',
});
expect(options.sandbox).toBeUndefined();
});
}); });
describe('createCustomOptions', () => { describe('createCustomOptions', () => {

View File

@@ -1,307 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
writeValidation,
readValidation,
getAllValidations,
deleteValidation,
isValidationStale,
getValidationWithFreshness,
markValidationViewed,
getUnviewedValidationsCount,
type StoredValidation,
} from '@/lib/validation-storage.js';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
describe('validation-storage.ts', () => {
let testProjectPath: string;
beforeEach(async () => {
testProjectPath = path.join(os.tmpdir(), `validation-storage-test-${Date.now()}`);
await fs.mkdir(testProjectPath, { recursive: true });
});
afterEach(async () => {
try {
await fs.rm(testProjectPath, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
const createMockValidation = (overrides: Partial<StoredValidation> = {}): StoredValidation => ({
issueNumber: 123,
issueTitle: 'Test Issue',
validatedAt: new Date().toISOString(),
model: 'haiku',
result: {
verdict: 'valid',
confidence: 'high',
reasoning: 'Test reasoning',
},
...overrides,
});
describe('writeValidation', () => {
it('should write validation to storage', async () => {
const validation = createMockValidation();
await writeValidation(testProjectPath, 123, validation);
// Verify file was created
const validationPath = path.join(
testProjectPath,
'.automaker',
'validations',
'123',
'validation.json'
);
const content = await fs.readFile(validationPath, 'utf-8');
expect(JSON.parse(content)).toEqual(validation);
});
it('should create nested directories if they do not exist', async () => {
const validation = createMockValidation({ issueNumber: 456 });
await writeValidation(testProjectPath, 456, validation);
const validationPath = path.join(
testProjectPath,
'.automaker',
'validations',
'456',
'validation.json'
);
const content = await fs.readFile(validationPath, 'utf-8');
expect(JSON.parse(content)).toEqual(validation);
});
});
describe('readValidation', () => {
it('should read validation from storage', async () => {
const validation = createMockValidation();
await writeValidation(testProjectPath, 123, validation);
const result = await readValidation(testProjectPath, 123);
expect(result).toEqual(validation);
});
it('should return null when validation does not exist', async () => {
const result = await readValidation(testProjectPath, 999);
expect(result).toBeNull();
});
});
describe('getAllValidations', () => {
it('should return all validations for a project', async () => {
const validation1 = createMockValidation({ issueNumber: 1, issueTitle: 'Issue 1' });
const validation2 = createMockValidation({ issueNumber: 2, issueTitle: 'Issue 2' });
const validation3 = createMockValidation({ issueNumber: 3, issueTitle: 'Issue 3' });
await writeValidation(testProjectPath, 1, validation1);
await writeValidation(testProjectPath, 2, validation2);
await writeValidation(testProjectPath, 3, validation3);
const result = await getAllValidations(testProjectPath);
expect(result).toHaveLength(3);
expect(result[0]).toEqual(validation1);
expect(result[1]).toEqual(validation2);
expect(result[2]).toEqual(validation3);
});
it('should return empty array when no validations exist', async () => {
const result = await getAllValidations(testProjectPath);
expect(result).toEqual([]);
});
it('should skip non-numeric directories', async () => {
const validation = createMockValidation({ issueNumber: 1 });
await writeValidation(testProjectPath, 1, validation);
// Create a non-numeric directory
const invalidDir = path.join(testProjectPath, '.automaker', 'validations', 'invalid');
await fs.mkdir(invalidDir, { recursive: true });
const result = await getAllValidations(testProjectPath);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(validation);
});
});
describe('deleteValidation', () => {
it('should delete validation from storage', async () => {
const validation = createMockValidation();
await writeValidation(testProjectPath, 123, validation);
const result = await deleteValidation(testProjectPath, 123);
expect(result).toBe(true);
const readResult = await readValidation(testProjectPath, 123);
expect(readResult).toBeNull();
});
it('should return true even when validation does not exist', async () => {
const result = await deleteValidation(testProjectPath, 999);
expect(result).toBe(true);
});
});
describe('isValidationStale', () => {
it('should return false for recent validation', () => {
const validation = createMockValidation({
validatedAt: new Date().toISOString(),
});
const result = isValidationStale(validation);
expect(result).toBe(false);
});
it('should return true for validation older than 24 hours', () => {
const oldDate = new Date();
oldDate.setHours(oldDate.getHours() - 25); // 25 hours ago
const validation = createMockValidation({
validatedAt: oldDate.toISOString(),
});
const result = isValidationStale(validation);
expect(result).toBe(true);
});
it('should return false for validation exactly at 24 hours', () => {
const exactDate = new Date();
exactDate.setHours(exactDate.getHours() - 24);
const validation = createMockValidation({
validatedAt: exactDate.toISOString(),
});
const result = isValidationStale(validation);
expect(result).toBe(false);
});
});
describe('getValidationWithFreshness', () => {
it('should return validation with isStale false for recent validation', async () => {
const validation = createMockValidation({
validatedAt: new Date().toISOString(),
});
await writeValidation(testProjectPath, 123, validation);
const result = await getValidationWithFreshness(testProjectPath, 123);
expect(result).not.toBeNull();
expect(result!.validation).toEqual(validation);
expect(result!.isStale).toBe(false);
});
it('should return validation with isStale true for old validation', async () => {
const oldDate = new Date();
oldDate.setHours(oldDate.getHours() - 25);
const validation = createMockValidation({
validatedAt: oldDate.toISOString(),
});
await writeValidation(testProjectPath, 123, validation);
const result = await getValidationWithFreshness(testProjectPath, 123);
expect(result).not.toBeNull();
expect(result!.isStale).toBe(true);
});
it('should return null when validation does not exist', async () => {
const result = await getValidationWithFreshness(testProjectPath, 999);
expect(result).toBeNull();
});
});
describe('markValidationViewed', () => {
it('should mark validation as viewed', async () => {
const validation = createMockValidation();
await writeValidation(testProjectPath, 123, validation);
const result = await markValidationViewed(testProjectPath, 123);
expect(result).toBe(true);
const updated = await readValidation(testProjectPath, 123);
expect(updated).not.toBeNull();
expect(updated!.viewedAt).toBeDefined();
});
it('should return false when validation does not exist', async () => {
const result = await markValidationViewed(testProjectPath, 999);
expect(result).toBe(false);
});
});
describe('getUnviewedValidationsCount', () => {
it('should return count of unviewed non-stale validations', async () => {
const validation1 = createMockValidation({ issueNumber: 1 });
const validation2 = createMockValidation({ issueNumber: 2 });
const validation3 = createMockValidation({
issueNumber: 3,
viewedAt: new Date().toISOString(),
});
await writeValidation(testProjectPath, 1, validation1);
await writeValidation(testProjectPath, 2, validation2);
await writeValidation(testProjectPath, 3, validation3);
const result = await getUnviewedValidationsCount(testProjectPath);
expect(result).toBe(2);
});
it('should not count stale validations', async () => {
const oldDate = new Date();
oldDate.setHours(oldDate.getHours() - 25);
const validation1 = createMockValidation({ issueNumber: 1 });
const validation2 = createMockValidation({
issueNumber: 2,
validatedAt: oldDate.toISOString(),
});
await writeValidation(testProjectPath, 1, validation1);
await writeValidation(testProjectPath, 2, validation2);
const result = await getUnviewedValidationsCount(testProjectPath);
expect(result).toBe(1);
});
it('should return 0 when no validations exist', async () => {
const result = await getUnviewedValidationsCount(testProjectPath);
expect(result).toBe(0);
});
it('should return 0 when all validations are viewed', async () => {
const validation = createMockValidation({
issueNumber: 1,
viewedAt: new Date().toISOString(),
});
await writeValidation(testProjectPath, 1, validation);
const result = await getUnviewedValidationsCount(testProjectPath);
expect(result).toBe(0);
});
});
});

View File

@@ -73,7 +73,7 @@ describe('claude-provider.ts', () => {
maxTurns: 10, maxTurns: 10,
cwd: '/test/dir', cwd: '/test/dir',
allowedTools: ['Read', 'Write'], allowedTools: ['Read', 'Write'],
permissionMode: 'default', permissionMode: 'acceptEdits',
}), }),
}); });
}); });
@@ -100,7 +100,7 @@ describe('claude-provider.ts', () => {
}); });
}); });
it('should pass sandbox configuration when provided', async () => { it('should enable sandbox by default', async () => {
vi.mocked(sdk.query).mockReturnValue( vi.mocked(sdk.query).mockReturnValue(
(async function* () { (async function* () {
yield { type: 'text', text: 'test' }; yield { type: 'text', text: 'test' };
@@ -110,10 +110,6 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({ const generator = provider.executeQuery({
prompt: 'Test', prompt: 'Test',
cwd: '/test', cwd: '/test',
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
}); });
await collectAsyncGenerator(generator); await collectAsyncGenerator(generator);
@@ -246,21 +242,11 @@ describe('claude-provider.ts', () => {
}); });
await expect(collectAsyncGenerator(generator)).rejects.toThrow('SDK execution failed'); await expect(collectAsyncGenerator(generator)).rejects.toThrow('SDK execution failed');
expect(consoleErrorSpy).toHaveBeenCalledWith(
// Should log error message '[ClaudeProvider] executeQuery() error during execution:',
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
1,
'[ClaudeProvider] ERROR: executeQuery() error during execution:',
testError testError
); );
// Should log stack trace
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
2,
'[ClaudeProvider] ERROR stack:',
testError.stack
);
consoleErrorSpy.mockRestore(); consoleErrorSpy.mockRestore();
}); });
}); });

View File

@@ -106,9 +106,9 @@ describe('agent-service.ts', () => {
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
// First call reads session file, metadata file, and queue state file (3 calls) // First call reads session file and metadata file (2 calls)
// Second call should reuse in-memory session (no additional calls) // Second call should reuse in-memory session (no additional calls)
expect(fs.readFile).toHaveBeenCalledTimes(3); expect(fs.readFile).toHaveBeenCalledTimes(2);
}); });
}); });

View File

@@ -44,7 +44,6 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
@@ -70,6 +69,7 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"framer-motion": "^12.23.26",
"geist": "^1.5.1", "geist": "^1.5.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"react": "19.2.3", "react": "19.2.3",
@@ -77,9 +77,9 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"usehooks-ts": "^3.1.1",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"optionalDependencies": { "optionalDependencies": {

View File

@@ -6,6 +6,8 @@ import { useSettingsMigration } from './hooks/use-settings-migration';
import './styles/global.css'; import './styles/global.css';
import './styles/theme-imports'; import './styles/theme-imports';
import { Shell } from './components/layout/shell';
export default function App() { export default function App() {
const [showSplash, setShowSplash] = useState(() => { const [showSplash, setShowSplash] = useState(() => {
// Only show splash once per session // Only show splash once per session
@@ -27,9 +29,9 @@ export default function App() {
}, []); }, []);
return ( return (
<> <Shell>
<RouterProvider router={router} /> <RouterProvider router={router} />
{showSplash && <SplashScreen onComplete={handleSplashComplete} />} {showSplash && <SplashScreen onComplete={handleSplashComplete} />}
</> </Shell>
); );
} }

View File

@@ -1,5 +1,15 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { FolderOpen, Folder, ChevronRight, HardDrive, Clock, X } from 'lucide-react'; import {
FolderOpen,
Folder,
ChevronRight,
Home,
ArrowLeft,
HardDrive,
CornerDownLeft,
Clock,
X,
} from 'lucide-react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -9,11 +19,9 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PathInput } from '@/components/ui/path-input'; import { Input } from '@/components/ui/input';
import { Kbd, KbdGroup } from '@/components/ui/kbd';
import { getJSON, setJSON } from '@/lib/storage'; import { getJSON, setJSON } from '@/lib/storage';
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config'; import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
import { useOSDetection } from '@/hooks';
interface DirectoryEntry { interface DirectoryEntry {
name: string; name: string;
@@ -69,8 +77,8 @@ export function FileBrowserDialog({
description = 'Navigate to your project folder or paste a path directly', description = 'Navigate to your project folder or paste a path directly',
initialPath, initialPath,
}: FileBrowserDialogProps) { }: FileBrowserDialogProps) {
const { isMac } = useOSDetection();
const [currentPath, setCurrentPath] = useState<string>(''); const [currentPath, setCurrentPath] = useState<string>('');
const [pathInput, setPathInput] = useState<string>('');
const [parentPath, setParentPath] = useState<string | null>(null); const [parentPath, setParentPath] = useState<string | null>(null);
const [directories, setDirectories] = useState<DirectoryEntry[]>([]); const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
const [drives, setDrives] = useState<string[]>([]); const [drives, setDrives] = useState<string[]>([]);
@@ -78,6 +86,7 @@ export function FileBrowserDialog({
const [error, setError] = useState(''); const [error, setError] = useState('');
const [warning, setWarning] = useState(''); const [warning, setWarning] = useState('');
const [recentFolders, setRecentFolders] = useState<string[]>([]); const [recentFolders, setRecentFolders] = useState<string[]>([]);
const pathInputRef = useRef<HTMLInputElement>(null);
// Load recent folders when dialog opens // Load recent folders when dialog opens
useEffect(() => { useEffect(() => {
@@ -111,6 +120,7 @@ export function FileBrowserDialog({
if (result.success) { if (result.success) {
setCurrentPath(result.currentPath); setCurrentPath(result.currentPath);
setPathInput(result.currentPath);
setParentPath(result.parentPath); setParentPath(result.parentPath);
setDirectories(result.directories); setDirectories(result.directories);
setDrives(result.drives || []); setDrives(result.drives || []);
@@ -132,10 +142,11 @@ export function FileBrowserDialog({
[browseDirectory] [browseDirectory]
); );
// Reset state when dialog closes // Reset current path when dialog closes
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setCurrentPath(''); setCurrentPath('');
setPathInput('');
setParentPath(null); setParentPath(null);
setDirectories([]); setDirectories([]);
setError(''); setError('');
@@ -161,6 +172,9 @@ export function FileBrowserDialog({
const pathToUse = initialPath || defaultDir; const pathToUse = initialPath || defaultDir;
if (pathToUse) { if (pathToUse) {
// Pre-fill the path input immediately
setPathInput(pathToUse);
// Then browse to that directory
browseDirectory(pathToUse); browseDirectory(pathToUse);
} else { } else {
// No default directory, browse home directory // No default directory, browse home directory
@@ -169,6 +183,7 @@ export function FileBrowserDialog({
} catch { } catch {
// If config fetch fails, try initialPath or fall back to home directory // If config fetch fails, try initialPath or fall back to home directory
if (initialPath) { if (initialPath) {
setPathInput(initialPath);
browseDirectory(initialPath); browseDirectory(initialPath);
} else { } else {
browseDirectory(); browseDirectory();
@@ -184,21 +199,34 @@ export function FileBrowserDialog({
browseDirectory(dir.path); browseDirectory(dir.path);
}; };
const handleGoHome = useCallback(() => { const handleGoToParent = () => {
browseDirectory(); if (parentPath) {
}, [browseDirectory]); browseDirectory(parentPath);
}
};
const handleNavigate = useCallback( const handleGoHome = () => {
(path: string) => { browseDirectory();
browseDirectory(path); };
},
[browseDirectory]
);
const handleSelectDrive = (drivePath: string) => { const handleSelectDrive = (drivePath: string) => {
browseDirectory(drivePath); browseDirectory(drivePath);
}; };
const handleGoToPath = () => {
const trimmedPath = pathInput.trim();
if (trimmedPath) {
browseDirectory(trimmedPath);
}
};
const handlePathInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleGoToPath();
}
};
const handleSelect = useCallback(() => { const handleSelect = useCallback(() => {
if (currentPath) { if (currentPath) {
addRecentFolder(currentPath); addRecentFolder(currentPath);
@@ -235,7 +263,7 @@ export function FileBrowserDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-3xl max-h-[85vh] overflow-hidden flex flex-col p-4 focus:outline-none focus-visible:outline-none"> <DialogContent className="bg-popover border-border max-w-3xl max-h-[85vh] overflow-hidden flex flex-col p-4">
<DialogHeader className="pb-1"> <DialogHeader className="pb-1">
<DialogTitle className="flex items-center gap-2 text-base"> <DialogTitle className="flex items-center gap-2 text-base">
<FolderOpen className="w-4 h-4 text-brand-500" /> <FolderOpen className="w-4 h-4 text-brand-500" />
@@ -247,21 +275,31 @@ export function FileBrowserDialog({
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-2 min-h-[350px] flex-1 overflow-hidden py-1"> <div className="flex flex-col gap-2 min-h-[350px] flex-1 overflow-hidden py-1">
{/* Path navigation */} {/* Direct path input */}
<PathInput <div className="flex items-center gap-1.5">
currentPath={currentPath} <Input
parentPath={parentPath} ref={pathInputRef}
loading={loading} type="text"
error={!!error} placeholder="Paste or type a full path (e.g., /home/user/projects/myapp)"
onNavigate={handleNavigate} value={pathInput}
onHome={handleGoHome} onChange={(e) => setPathInput(e.target.value)}
entries={directories.map((dir) => ({ ...dir, isDirectory: true }))} onKeyDown={handlePathInputKeyDown}
onSelectEntry={(entry) => { className="flex-1 font-mono text-xs h-8"
if (entry.isDirectory) { data-testid="path-input"
handleSelectDirectory(entry); disabled={loading}
}
}}
/> />
<Button
variant="secondary"
size="sm"
onClick={handleGoToPath}
disabled={loading || !pathInput.trim()}
data-testid="go-to-path-button"
className="h-8 px-2"
>
<CornerDownLeft className="w-3.5 h-3.5 mr-1" />
Go
</Button>
</div>
{/* Recent folders */} {/* Recent folders */}
{recentFolders.length > 0 && ( {recentFolders.length > 0 && (
@@ -314,8 +352,35 @@ export function FileBrowserDialog({
</div> </div>
)} )}
{/* Current path breadcrumb */}
<div className="flex items-center gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
<Button
variant="ghost"
size="sm"
onClick={handleGoHome}
className="h-6 px-1.5"
disabled={loading}
>
<Home className="w-3.5 h-3.5" />
</Button>
{parentPath && (
<Button
variant="ghost"
size="sm"
onClick={handleGoToParent}
className="h-6 px-1.5"
disabled={loading}
>
<ArrowLeft className="w-3.5 h-3.5" />
</Button>
)}
<div className="flex-1 font-mono text-xs truncate text-muted-foreground">
{currentPath || 'Loading...'}
</div>
</div>
{/* Directory list */} {/* Directory list */}
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md scrollbar-styled"> <div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md">
{loading && ( {loading && (
<div className="flex items-center justify-center h-full p-4"> <div className="flex items-center justify-center h-full p-4">
<div className="text-xs text-muted-foreground">Loading directories...</div> <div className="text-xs text-muted-foreground">Loading directories...</div>
@@ -358,8 +423,8 @@ export function FileBrowserDialog({
</div> </div>
<div className="text-[10px] text-muted-foreground"> <div className="text-[10px] text-muted-foreground">
Paste a full path above, or click on folders to navigate. Press Enter or click to jump Paste a full path above, or click on folders to navigate. Press Enter or click Go to
to a path. jump to a path.
</div> </div>
</div> </div>
@@ -375,10 +440,12 @@ export function FileBrowserDialog({
> >
<FolderOpen className="w-3.5 h-3.5 mr-1.5" /> <FolderOpen className="w-3.5 h-3.5 mr-1.5" />
Select Current Folder Select Current Folder
<KbdGroup className="ml-1"> <kbd className="ml-2 px-1.5 py-0.5 text-[10px] bg-background/50 rounded border border-border">
<Kbd>{isMac ? '⌘' : 'Ctrl'}</Kbd> {typeof navigator !== 'undefined' && navigator.platform?.includes('Mac')
<Kbd></Kbd> ? '⌘'
</KbdGroup> : 'Ctrl'}
+
</kbd>
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -245,21 +245,18 @@ export function NewProjectModal({
{/* Workspace Directory Display */} {/* Workspace Directory Display */}
<div <div
className={cn( className={cn(
'flex items-start gap-2 text-sm', 'flex items-center gap-2 text-sm',
errors.workspaceDir ? 'text-red-500' : 'text-muted-foreground' errors.workspaceDir ? 'text-red-500' : 'text-muted-foreground'
)} )}
> >
<Folder className="w-4 h-4 shrink-0 mt-0.5" /> <Folder className="w-4 h-4 shrink-0" />
<span className="flex-1 min-w-0 flex flex-col gap-1"> <span className="flex-1 min-w-0">
{isLoadingWorkspace ? ( {isLoadingWorkspace ? (
'Loading workspace...' 'Loading workspace...'
) : workspaceDir ? ( ) : workspaceDir ? (
<> <>
<span>Will be created at:</span> Will be created at:{' '}
<code <code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">
className="text-xs bg-muted px-1.5 py-0.5 rounded truncate block max-w-full"
title={projectPath || workspaceDir}
>
{projectPath || workspaceDir} {projectPath || workspaceDir}
</code> </code>
</> </>

View File

@@ -0,0 +1,118 @@
import { useRef } from 'react';
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion';
import { useNavigate, useLocation } from '@tanstack/react-router';
import {
LayoutDashboard,
Bot,
FileText,
Database,
Terminal,
Settings,
Users,
type LucideIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
export function FloatingDock() {
const mouseX = useMotionValue(Infinity);
const navigate = useNavigate();
const location = useLocation();
const { currentProject } = useAppStore();
const navItems = [
{ id: 'board', icon: LayoutDashboard, label: 'Board', path: '/board' },
{ id: 'agent', icon: Bot, label: 'Agent', path: '/agent' },
{ id: 'spec', icon: FileText, label: 'Spec', path: '/spec' },
{ id: 'context', icon: Database, label: 'Context', path: '/context' },
{ id: 'profiles', icon: Users, label: 'Profiles', path: '/profiles' },
{ id: 'terminal', icon: Terminal, label: 'Terminal', path: '/terminal' },
{ id: 'settings', icon: Settings, label: 'Settings', path: '/settings' },
];
if (!currentProject) return null;
return (
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50">
<motion.div
onMouseMove={(e) => mouseX.set(e.pageX)}
onMouseLeave={() => mouseX.set(Infinity)}
className={cn(
'flex h-16 items-end gap-4 rounded-2xl px-4 pb-3',
'bg-white/5 backdrop-blur-2xl border border-white/10 shadow-2xl'
)}
>
{navItems.map((item) => (
<DockIcon
key={item.id}
mouseX={mouseX}
icon={item.icon}
path={item.path}
label={item.label}
isActive={location.pathname.startsWith(item.path)}
onClick={() => navigate({ to: item.path })}
/>
))}
</motion.div>
</div>
);
}
function DockIcon({
mouseX,
icon: Icon,
path,
label,
isActive,
onClick,
}: {
mouseX: any;
icon: LucideIcon;
path: string;
label: string;
isActive: boolean;
onClick: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const distance = useTransform(mouseX, (val: number) => {
const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 };
return val - bounds.x - bounds.width / 2;
});
const widthSync = useTransform(distance, [-150, 0, 150], [40, 80, 40]);
const width = useSpring(widthSync, { mass: 0.1, stiffness: 150, damping: 12 });
return (
<motion.div
ref={ref}
style={{ width }}
className="aspect-square cursor-pointer group relative"
onClick={onClick}
>
{/* Tooltip */}
<div className="absolute -top-10 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity text-xs font-mono bg-black/80 text-white px-2 py-1 rounded backdrop-blur-md border border-white/10 pointer-events-none whitespace-nowrap">
{label}
</div>
<div
className={cn(
'flex h-full w-full items-center justify-center rounded-full transition-colors',
isActive
? 'bg-primary text-primary-foreground shadow-[0_0_20px_rgba(34,211,238,0.3)]'
: 'bg-white/5 text-muted-foreground hover:bg-white/10'
)}
>
<Icon className="h-[40%] w-[40%]" />
</div>
{/* Active Dot */}
{isActive && (
<motion.div
layoutId="activeDockDot"
className="absolute -bottom-2 left-1/2 w-1 h-1 bg-primary rounded-full -translate-x-1/2"
/>
)}
</motion.div>
);
}

View File

@@ -0,0 +1,70 @@
import { ChevronDown, Command, Folder } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface HudProps {
onOpenProjectPicker: () => void;
onOpenFolder: () => void;
}
export function Hud({ onOpenProjectPicker, onOpenFolder }: HudProps) {
const { currentProject, projects, setCurrentProject } = useAppStore();
if (!currentProject) return null;
return (
<div className="fixed top-4 left-4 z-50 flex items-center gap-3">
{/* Project Pill */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div
className={cn(
'group flex items-center gap-3 px-4 py-2 rounded-full cursor-pointer',
'bg-white/5 backdrop-blur-md border border-white/10',
'hover:bg-white/10 transition-colors'
)}
>
<div className="w-2 h-2 rounded-full bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.4)] animate-pulse" />
<span className="font-mono text-sm font-medium tracking-tight">
{currentProject.name}
</span>
<ChevronDown className="w-3 h-3 text-muted-foreground group-hover:text-foreground transition-colors" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 glass border-white/10" align="start">
<DropdownMenuLabel>Switch Project</DropdownMenuLabel>
<DropdownMenuSeparator />
{projects.slice(0, 5).map((p) => (
<DropdownMenuItem
key={p.id}
onClick={() => setCurrentProject(p)}
className="font-mono text-xs"
>
{p.name}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onOpenProjectPicker}>
<Command className="mr-2 w-3 h-3" />
All Projects...
</DropdownMenuItem>
<DropdownMenuItem onClick={onOpenFolder}>
<Folder className="mr-2 w-3 h-3" />
Open Local Folder...
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Dynamic Status / Breadcrumbs could go here */}
</div>
);
}

View File

@@ -0,0 +1,17 @@
export function NoiseOverlay() {
return (
<div className="fixed inset-0 z-50 pointer-events-none opacity-[0.015] mix-blend-overlay">
<svg className="w-full h-full">
<filter id="noiseFilter">
<feTurbulence
type="fractalNoise"
baseFrequency="0.80"
numOctaves="3"
stitchTiles="stitch"
/>
</filter>
<rect width="100%" height="100%" filter="url(#noiseFilter)" />
</svg>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
interface PageShellProps {
children: ReactNode;
className?: string;
fullWidth?: boolean;
}
export function PageShell({ children, className, fullWidth = false }: PageShellProps) {
return (
<div className="relative w-full h-full pt-16 pb-24 px-6 overflow-hidden">
<motion.div
initial={{ opacity: 0, scale: 0.98, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.4, ease: [0.2, 0, 0, 1] }}
className={cn(
'w-full h-full rounded-3xl overflow-hidden',
'bg-black/20 backdrop-blur-2xl border border-white/5 shadow-2xl',
'flex flex-col',
!fullWidth && 'max-w-7xl mx-auto',
className
)}
>
{children}
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { motion } from 'framer-motion';
import { useEffect, useState } from 'react';
export function PrismField() {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setMousePosition({
x: e.clientX,
y: e.clientY,
});
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
return (
<div className="fixed inset-0 z-0 overflow-hidden pointer-events-none bg-[#0b101a]">
{/* Deep Space Base */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(17,24,39,1)_0%,rgba(11,16,26,1)_100%)]" />
{/* Animated Orbs */}
<motion.div
animate={{
x: mousePosition.x * 0.02,
y: mousePosition.y * 0.02,
}}
transition={{ type: 'spring', damping: 50, stiffness: 400 }}
className="absolute top-[-20%] left-[-10%] w-[70vw] h-[70vw] rounded-full bg-cyan-500/5 blur-[120px] mix-blend-screen"
/>
<motion.div
animate={{
x: mousePosition.x * -0.03,
y: mousePosition.y * -0.03,
}}
transition={{ type: 'spring', damping: 50, stiffness: 400 }}
className="absolute bottom-[-20%] right-[-10%] w-[60vw] h-[60vw] rounded-full bg-violet-600/5 blur-[120px] mix-blend-screen"
/>
<motion.div
animate={{
scale: [1, 1.1, 1],
opacity: [0.3, 0.5, 0.3],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: 'easeInOut',
}}
className="absolute top-[30%] left-[50%] transform -translate-x-1/2 -translate-y-1/2 w-[40vw] h-[40vw] rounded-full bg-blue-500/5 blur-[100px] mix-blend-screen"
/>
{/* Grid Overlay */}
<div
className="absolute inset-0 z-10 opacity-[0.03]"
style={{
backgroundImage: `linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '50px 50px',
}}
/>
{/* Vignette */}
<div className="absolute inset-0 z-20 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(11,16,26,0.8)_100%)]" />
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { ReactNode } from 'react';
import { cn } from '../../lib/utils';
import { PrismField } from './prism-field';
import { NoiseOverlay } from './noise-overlay';
interface ShellProps {
children: ReactNode;
className?: string;
showBackgroundElements?: boolean;
}
export function Shell({ children, className, showBackgroundElements = true }: ShellProps) {
return (
<div
className={cn(
'relative min-h-screen w-full overflow-hidden bg-background text-foreground transition-colors duration-500',
className
)}
>
{/* Animated Background Layers */}
{showBackgroundElements && (
<>
<PrismField />
<NoiseOverlay />
</>
)}
{/* Content wrapper */}
<div className="relative z-10 flex h-screen flex-col">{children}</div>
</div>
);
}

View File

@@ -17,8 +17,9 @@ import {
ProjectActions, ProjectActions,
SidebarNavigation, SidebarNavigation,
ProjectSelectorWithOptions, ProjectSelectorWithOptions,
SidebarFooter,
} from './sidebar/components'; } from './sidebar/components';
import { Hud } from './hud';
import { FloatingDock } from './floating-dock';
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 {
@@ -28,9 +29,8 @@ import {
useNavigation, useNavigation,
useProjectCreation, useProjectCreation,
useSetupDialog, useSetupDialog,
useTrashOperations, useTrashDialog,
useProjectTheme, useProjectTheme,
useUnviewedValidations,
} from './sidebar/hooks'; } from './sidebar/hooks';
export function Sidebar() { export function Sidebar() {
@@ -68,9 +68,6 @@ export function Sidebar() {
// State for delete project confirmation dialog // State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
// State for trash dialog
const [showTrashDialog, setShowTrashDialog] = useState(false);
// Project theme management (must come before useProjectCreation which uses globalTheme) // Project theme management (must come before useProjectCreation which uses globalTheme)
const { globalTheme } = useProjectTheme(); const { globalTheme } = useProjectTheme();
@@ -131,20 +128,20 @@ export function Sidebar() {
// Running agents count // Running agents count
const { runningAgentsCount } = useRunningAgents(); const { runningAgentsCount } = useRunningAgents();
// Unviewed validations count // Trash dialog and operations
const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject);
// Trash operations
const { const {
showTrashDialog,
setShowTrashDialog,
activeTrashId, activeTrashId,
isEmptyingTrash, isEmptyingTrash,
handleRestoreProject, handleRestoreProject,
handleDeleteProjectFromDisk, handleDeleteProjectFromDisk,
handleEmptyTrash, handleEmptyTrash,
} = useTrashOperations({ } = useTrashDialog({
restoreTrashedProject, restoreTrashedProject,
deleteTrashedProject, deleteTrashedProject,
emptyTrash, emptyTrash,
trashedProjects,
}); });
// Spec regeneration events // Spec regeneration events
@@ -239,7 +236,6 @@ export function Sidebar() {
setIsProjectPickerOpen, setIsProjectPickerOpen,
cyclePrevProject, cyclePrevProject,
cycleNextProject, cycleNextProject,
unviewedValidationsCount,
}); });
// Register keyboard shortcuts // Register keyboard shortcuts
@@ -252,64 +248,27 @@ export function Sidebar() {
}; };
return ( return (
<aside <>
className={cn( {/* Heads-Up Display (Top Bar) */}
'flex-shrink-0 flex flex-col z-30 relative', <Hud
// Glass morphism background with gradient onOpenProjectPicker={() => setIsProjectPickerOpen(true)}
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl', onOpenFolder={handleOpenFolder}
// Premium border with subtle glow
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
// Smooth width transition
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
sidebarOpen ? 'w-16 lg:w-72' : 'w-16'
)}
data-testid="sidebar"
>
<CollapseToggleButton
sidebarOpen={sidebarOpen}
toggleSidebar={toggleSidebar}
shortcut={shortcuts.toggleSidebar}
/> />
<div className="flex-1 flex flex-col overflow-hidden"> {/* Floating Navigation Dock */}
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} /> <FloatingDock />
{/* Project Actions - Moved above project selector */}
{sidebarOpen && (
<ProjectActions
setShowNewProjectModal={setShowNewProjectModal}
handleOpenFolder={handleOpenFolder}
setShowTrashDialog={setShowTrashDialog}
trashedProjects={trashedProjects}
shortcuts={{ openProject: shortcuts.openProject }}
/>
)}
{/* Project Selector Dialog (Hidden logic, controlled by state) */}
<div className="hidden">
<ProjectSelectorWithOptions <ProjectSelectorWithOptions
sidebarOpen={sidebarOpen} sidebarOpen={true}
isProjectPickerOpen={isProjectPickerOpen} isProjectPickerOpen={isProjectPickerOpen}
setIsProjectPickerOpen={setIsProjectPickerOpen} setIsProjectPickerOpen={setIsProjectPickerOpen}
setShowDeleteProjectDialog={setShowDeleteProjectDialog} setShowDeleteProjectDialog={setShowDeleteProjectDialog}
/> />
<SidebarNavigation
currentProject={currentProject}
sidebarOpen={sidebarOpen}
navSections={navSections}
isActiveRoute={isActiveRoute}
navigate={navigate}
/>
</div> </div>
<SidebarFooter {/* Dialogs & Modals - Preservation of Logic */}
sidebarOpen={sidebarOpen}
isActiveRoute={isActiveRoute}
navigate={navigate}
hideWiki={hideWiki}
hideRunningAgents={hideRunningAgents}
runningAgentsCount={runningAgentsCount}
shortcuts={{ settings: shortcuts.settings }}
/>
<TrashDialog <TrashDialog
open={showTrashDialog} open={showTrashDialog}
onOpenChange={setShowTrashDialog} onOpenChange={setShowTrashDialog}
@@ -322,7 +281,6 @@ export function Sidebar() {
isEmptyingTrash={isEmptyingTrash} isEmptyingTrash={isEmptyingTrash}
/> />
{/* New Project Setup Dialog */}
<CreateSpecDialog <CreateSpecDialog
open={showSetupDialog} open={showSetupDialog}
onOpenChange={setShowSetupDialog} onOpenChange={setShowSetupDialog}
@@ -350,7 +308,6 @@ export function Sidebar() {
onGenerateSpec={handleOnboardingGenerateSpec} onGenerateSpec={handleOnboardingGenerateSpec}
/> />
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog <DeleteProjectDialog
open={showDeleteProjectDialog} open={showDeleteProjectDialog}
onOpenChange={setShowDeleteProjectDialog} onOpenChange={setShowDeleteProjectDialog}
@@ -358,7 +315,6 @@ export function Sidebar() {
onConfirm={moveProjectToTrash} onConfirm={moveProjectToTrash}
/> />
{/* New Project Modal */}
<NewProjectModal <NewProjectModal
open={showNewProjectModal} open={showNewProjectModal}
onOpenChange={setShowNewProjectModal} onOpenChange={setShowNewProjectModal}
@@ -367,6 +323,6 @@ export function Sidebar() {
onCreateFromCustomUrl={handleCreateFromCustomUrl} onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreatingProject} isCreating={isCreatingProject}
/> />
</aside> </>
); );
} }

View File

@@ -47,6 +47,7 @@ export function ProjectSelectorWithOptions({
setIsProjectPickerOpen, setIsProjectPickerOpen,
setShowDeleteProjectDialog, setShowDeleteProjectDialog,
}: ProjectSelectorWithOptionsProps) { }: ProjectSelectorWithOptionsProps) {
// Get data from store
const { const {
projects, projects,
currentProject, currentProject,
@@ -58,24 +59,25 @@ export function ProjectSelectorWithOptions({
clearProjectHistory, clearProjectHistory,
} = useAppStore(); } = useAppStore();
// Get keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig(); const shortcuts = useKeyboardShortcutsConfig();
const { const {
projectSearchQuery, projectSearchQuery,
setProjectSearchQuery, setProjectSearchQuery,
selectedProjectIndex, selectedProjectIndex,
projectSearchInputRef, projectSearchInputRef,
scrollContainerRef,
filteredProjects, filteredProjects,
} = useProjectPicker({ } = useProjectPicker({
projects, projects,
currentProject,
isProjectPickerOpen, isProjectPickerOpen,
setIsProjectPickerOpen, setIsProjectPickerOpen,
setCurrentProject, setCurrentProject,
}); });
// Drag-and-drop handlers
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects }); const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
// Theme management
const { const {
globalTheme, globalTheme,
setTheme, setTheme,
@@ -104,6 +106,7 @@ export function ProjectSelectorWithOptions({
'shadow-sm shadow-black/5', 'shadow-sm shadow-black/5',
'text-foreground titlebar-no-drag min-w-0', 'text-foreground titlebar-no-drag min-w-0',
'transition-all duration-200 ease-out', 'transition-all duration-200 ease-out',
'hover:scale-[1.01] active:scale-[0.99]',
isProjectPickerOpen && isProjectPickerOpen &&
'from-brand-500/10 to-brand-600/5 border-brand-500/30 ring-2 ring-brand-500/20 shadow-lg shadow-brand-500/5' 'from-brand-500/10 to-brand-600/5 border-brand-500/30 ring-2 ring-brand-500/20 shadow-lg shadow-brand-500/5'
)} )}
@@ -136,7 +139,7 @@ export function ProjectSelectorWithOptions({
align="start" align="start"
data-testid="project-picker-dropdown" data-testid="project-picker-dropdown"
> >
{/* Search input */} {/* Search input for type-ahead filtering */}
<div className="px-1 pb-2"> <div className="px-1 pb-2">
<div className="relative"> <div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
@@ -147,10 +150,10 @@ export function ProjectSelectorWithOptions({
value={projectSearchQuery} value={projectSearchQuery}
onChange={(e) => setProjectSearchQuery(e.target.value)} onChange={(e) => setProjectSearchQuery(e.target.value)}
className={cn( className={cn(
'w-full h-8 pl-8 pr-3 text-sm rounded-lg', 'w-full h-9 pl-8 pr-3 text-sm rounded-lg',
'border border-border bg-background/50', 'border border-border bg-background/50',
'text-foreground placeholder:text-muted-foreground', 'text-foreground placeholder:text-muted-foreground',
'focus:outline-none focus:ring-1 focus:ring-brand-500/30 focus:border-brand-500/50', 'focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/50',
'transition-all duration-200' 'transition-all duration-200'
)} )}
data-testid="project-search-input" data-testid="project-search-input"
@@ -172,10 +175,7 @@ export function ProjectSelectorWithOptions({
items={filteredProjects.map((p) => p.id)} items={filteredProjects.map((p) => p.id)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div <div className="space-y-0.5 max-h-64 overflow-y-auto">
ref={scrollContainerRef}
className="space-y-0.5 max-h-64 overflow-y-auto overflow-x-hidden scroll-smooth scrollbar-styled"
>
{filteredProjects.map((project, index) => ( {filteredProjects.map((project, index) => (
<SortableProjectItem <SortableProjectItem
key={project.id} key={project.id}
@@ -196,9 +196,9 @@ export function ProjectSelectorWithOptions({
{/* Keyboard hint */} {/* Keyboard hint */}
<div className="px-2 pt-2 mt-1.5 border-t border-border/50"> <div className="px-2 pt-2 mt-1.5 border-t border-border/50">
<p className="text-[10px] text-muted-foreground text-center tracking-wide"> <p className="text-[10px] text-muted-foreground text-center tracking-wide">
<span className="text-foreground/60"></span> navigate{' '} <span className="text-foreground/60">arrow</span> navigate{' '}
<span className="mx-1 text-foreground/30">|</span>{' '} <span className="mx-1 text-foreground/30">|</span>{' '}
<span className="text-foreground/60"></span> select{' '} <span className="text-foreground/60">enter</span> select{' '}
<span className="mx-1 text-foreground/30">|</span>{' '} <span className="mx-1 text-foreground/30">|</span>{' '}
<span className="text-foreground/60">esc</span> close <span className="text-foreground/60">esc</span> close
</p> </p>
@@ -206,7 +206,7 @@ export function ProjectSelectorWithOptions({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* Project Options Menu */} {/* Project Options Menu - theme and history */}
{currentProject && ( {currentProject && (
<DropdownMenu <DropdownMenu
onOpenChange={(open) => { onOpenChange={(open) => {
@@ -223,7 +223,8 @@ export function ProjectSelectorWithOptions({
'text-muted-foreground hover:text-foreground', 'text-muted-foreground hover:text-foreground',
'bg-transparent hover:bg-accent/60', 'bg-transparent hover:bg-accent/60',
'border border-border/50 hover:border-border', 'border border-border/50 hover:border-border',
'transition-all duration-200 ease-out titlebar-no-drag' 'transition-all duration-200 ease-out titlebar-no-drag',
'hover:scale-[1.02] active:scale-[0.98]'
)} )}
title="Project options" title="Project options"
data-testid="project-options-menu" data-testid="project-options-menu"
@@ -251,6 +252,7 @@ export function ProjectSelectorWithOptions({
setPreviewTheme(null); setPreviewTheme(null);
}} }}
> >
{/* Use Global Option */}
<DropdownMenuRadioGroup <DropdownMenuRadioGroup
value={currentProject.theme || ''} value={currentProject.theme || ''}
onValueChange={(value) => { onValueChange={(value) => {
@@ -326,7 +328,7 @@ export function ProjectSelectorWithOptions({
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
{/* Project History Section */} {/* Project History Section - only show when there's history */}
{projectHistory.length > 1 && ( {projectHistory.length > 1 && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />

View File

@@ -78,7 +78,6 @@ export function SidebarNavigation({
title={!sidebarOpen ? item.label : undefined} title={!sidebarOpen ? item.label : undefined}
data-testid={`nav-${item.id}`} data-testid={`nav-${item.id}`}
> >
<div className="relative">
<Icon <Icon
className={cn( className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200', 'w-[18px] h-[18px] shrink-0 transition-all duration-200',
@@ -87,20 +86,6 @@ export function SidebarNavigation({
: 'group-hover:text-brand-400 group-hover:scale-110' : 'group-hover:text-brand-400 group-hover:scale-110'
)} )}
/> />
{/* Count badge for collapsed state */}
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
<span
className={cn(
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
'bg-primary text-primary-foreground shadow-sm',
'animate-in fade-in zoom-in duration-200'
)}
>
{item.count > 99 ? '99' : item.count}
</span>
)}
</div>
<span <span
className={cn( className={cn(
'ml-3 font-medium text-sm flex-1 text-left', 'ml-3 font-medium text-sm flex-1 text-left',
@@ -109,21 +94,7 @@ export function SidebarNavigation({
> >
{item.label} {item.label}
</span> </span>
{/* Count badge */} {item.shortcut && sidebarOpen && (
{item.count !== undefined && item.count > 0 && sidebarOpen && (
<span
className={cn(
'hidden lg:flex items-center justify-center',
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
'bg-primary text-primary-foreground shadow-sm',
'animate-in fade-in zoom-in duration-200'
)}
data-testid={`count-${item.id}`}
>
{item.count > 99 ? '99+' : item.count}
</span>
)}
{item.shortcut && sidebarOpen && !item.count && (
<span <span
className={cn( className={cn(
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200', 'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',

View File

@@ -31,7 +31,6 @@ export function SortableProjectItem({
isHighlighted && 'bg-brand-500/10 text-foreground ring-1 ring-brand-500/20' isHighlighted && 'bg-brand-500/10 text-foreground ring-1 ring-brand-500/20'
)} )}
data-testid={`project-option-${project.id}`} data-testid={`project-option-${project.id}`}
onClick={() => onSelect(project)}
> >
{/* Drag Handle */} {/* Drag Handle */}
<button <button
@@ -44,14 +43,9 @@ export function SortableProjectItem({
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" /> <GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" />
</button> </button>
{/* Project content */} {/* Project content - clickable area */}
<div className="flex items-center gap-2.5 flex-1 min-w-0"> <div className="flex items-center gap-2.5 flex-1 min-w-0" onClick={() => onSelect(project)}>
<Folder <Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
className={cn(
'h-4 w-4 shrink-0',
currentProjectId === project.id ? 'text-brand-500' : 'text-muted-foreground'
)}
/>
<span className="flex-1 truncate text-sm font-medium">{project.name}</span> <span className="flex-1 truncate text-sm font-medium">{project.name}</span>
{currentProjectId === project.id && <Check className="h-4 w-4 text-brand-500 shrink-0" />} {currentProjectId === project.id && <Check className="h-4 w-4 text-brand-500 shrink-0" />}
</div> </div>

View File

@@ -37,11 +37,11 @@ export function OnboardingDialog({
<DialogContent className="max-w-2xl bg-popover/95 backdrop-blur-xl"> <DialogContent className="max-w-2xl bg-popover/95 backdrop-blur-xl">
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-brand-500/10 border border-brand-500/20 shrink-0"> <div className="flex items-center justify-center w-12 h-12 rounded-full bg-brand-500/10 border border-brand-500/20">
<Rocket className="w-6 h-6 text-brand-500" /> <Rocket className="w-6 h-6 text-brand-500" />
</div> </div>
<div className="min-w-0 flex-1"> <div>
<DialogTitle className="text-2xl truncate">Welcome to {newProjectName}!</DialogTitle> <DialogTitle className="text-2xl">Welcome to {newProjectName}!</DialogTitle>
<DialogDescription className="text-muted-foreground mt-1"> <DialogDescription className="text-muted-foreground mt-1">
Your new project is ready. Let&apos;s get you started. Your new project is ready. Let&apos;s get you started.
</DialogDescription> </DialogDescription>

View File

@@ -1,4 +1,3 @@
import { useState } from 'react';
import { X, Trash2, Undo2 } from 'lucide-react'; import { X, Trash2, Undo2 } from 'lucide-react';
import { import {
Dialog, Dialog,
@@ -9,8 +8,6 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import type { TrashedProject } from '@/lib/electron'; import type { TrashedProject } from '@/lib/electron';
interface TrashDialogProps { interface TrashDialogProps {
@@ -36,42 +33,8 @@ export function TrashDialog({
handleEmptyTrash, handleEmptyTrash,
isEmptyingTrash, isEmptyingTrash,
}: TrashDialogProps) { }: TrashDialogProps) {
// Confirmation dialog state (managed internally to avoid prop drilling)
const [deleteFromDiskProject, setDeleteFromDiskProject] = useState<TrashedProject | null>(null);
const [showEmptyTrashConfirm, setShowEmptyTrashConfirm] = useState(false);
// Reset confirmation dialog state when main dialog closes
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
setDeleteFromDiskProject(null);
setShowEmptyTrashConfirm(false);
}
onOpenChange(isOpen);
};
const onDeleteFromDiskClick = (project: TrashedProject) => {
setDeleteFromDiskProject(project);
};
const onConfirmDeleteFromDisk = () => {
if (deleteFromDiskProject) {
handleDeleteProjectFromDisk(deleteFromDiskProject);
setDeleteFromDiskProject(null);
}
};
const onEmptyTrashClick = () => {
setShowEmptyTrashConfirm(true);
};
const onConfirmEmptyTrash = () => {
handleEmptyTrash();
setShowEmptyTrashConfirm(false);
};
return ( return (
<> <Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="bg-popover/95 backdrop-blur-xl border-border max-w-2xl"> <DialogContent className="bg-popover/95 backdrop-blur-xl border-border max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Recycle Bin</DialogTitle> <DialogTitle>Recycle Bin</DialogTitle>
@@ -109,7 +72,7 @@ export function TrashDialog({
<Button <Button
size="sm" size="sm"
variant="destructive" variant="destructive"
onClick={() => onDeleteFromDiskClick(project)} onClick={() => handleDeleteProjectFromDisk(project)}
disabled={activeTrashId === project.id} disabled={activeTrashId === project.id}
data-testid={`delete-project-disk-${project.id}`} data-testid={`delete-project-disk-${project.id}`}
> >
@@ -139,7 +102,7 @@ export function TrashDialog({
{trashedProjects.length > 0 && ( {trashedProjects.length > 0 && (
<Button <Button
variant="outline" variant="outline"
onClick={onEmptyTrashClick} onClick={handleEmptyTrash}
disabled={isEmptyingTrash} disabled={isEmptyingTrash}
data-testid="empty-trash" data-testid="empty-trash"
> >
@@ -149,33 +112,5 @@ export function TrashDialog({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Delete from disk confirmation dialog */}
{deleteFromDiskProject && (
<DeleteConfirmDialog
open
onOpenChange={(isOpen) => !isOpen && setDeleteFromDiskProject(null)}
onConfirm={onConfirmDeleteFromDisk}
title={`Delete "${deleteFromDiskProject.name}" from disk?`}
description="This sends the folder to your system Trash."
confirmText="Delete from disk"
testId="delete-from-disk-confirm-dialog"
confirmTestId="confirm-delete-from-disk-button"
/>
)}
{/* Empty trash confirmation dialog */}
<ConfirmDialog
open={showEmptyTrashConfirm}
onOpenChange={setShowEmptyTrashConfirm}
onConfirm={onConfirmEmptyTrash}
title="Empty Recycle Bin"
description="Clear all projects from recycle bin? This does not delete folders from disk."
confirmText="Empty"
confirmVariant="destructive"
icon={Trash2}
iconClassName="text-destructive"
/>
</>
); );
} }

View File

@@ -8,5 +8,5 @@ export { useSpecRegeneration } from './use-spec-regeneration';
export { useNavigation } from './use-navigation'; export { useNavigation } from './use-navigation';
export { useProjectCreation } from './use-project-creation'; export { useProjectCreation } from './use-project-creation';
export { useSetupDialog } from './use-setup-dialog'; export { useSetupDialog } from './use-setup-dialog';
export { useTrashDialog } from './use-trash-dialog';
export { useProjectTheme } from './use-project-theme'; export { useProjectTheme } from './use-project-theme';
export { useUnviewedValidations } from './use-unviewed-validations';

View File

@@ -44,8 +44,6 @@ interface UseNavigationProps {
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void; setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
cyclePrevProject: () => void; cyclePrevProject: () => void;
cycleNextProject: () => void; cycleNextProject: () => void;
/** Count of unviewed validations to show on GitHub Issues nav item */
unviewedValidationsCount?: number;
} }
export function useNavigation({ export function useNavigation({
@@ -63,7 +61,6 @@ export function useNavigation({
setIsProjectPickerOpen, setIsProjectPickerOpen,
cyclePrevProject, cyclePrevProject,
cycleNextProject, cycleNextProject,
unviewedValidationsCount,
}: UseNavigationProps) { }: UseNavigationProps) {
// Track if current project has a GitHub remote // Track if current project has a GitHub remote
const [hasGitHubRemote, setHasGitHubRemote] = useState(false); const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
@@ -172,7 +169,6 @@ export function useNavigation({
id: 'github-issues', id: 'github-issues',
label: 'Issues', label: 'Issues',
icon: CircleDot, icon: CircleDot,
count: unviewedValidationsCount,
}, },
{ {
id: 'github-prs', id: 'github-prs',
@@ -184,15 +180,7 @@ export function useNavigation({
} }
return sections; return sections;
}, [ }, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles, hasGitHubRemote]);
shortcuts,
hideSpecEditor,
hideContext,
hideTerminal,
hideAiProfiles,
hasGitHubRemote,
unviewedValidationsCount,
]);
// Build keyboard shortcuts for navigation // Build keyboard shortcuts for navigation
const navigationShortcuts: KeyboardShortcut[] = useMemo(() => { const navigationShortcuts: KeyboardShortcut[] = useMemo(() => {

View File

@@ -3,7 +3,6 @@ import type { Project } from '@/lib/electron';
interface UseProjectPickerProps { interface UseProjectPickerProps {
projects: Project[]; projects: Project[];
currentProject: Project | null;
isProjectPickerOpen: boolean; isProjectPickerOpen: boolean;
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void; setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
setCurrentProject: (project: Project) => void; setCurrentProject: (project: Project) => void;
@@ -11,7 +10,6 @@ interface UseProjectPickerProps {
export function useProjectPicker({ export function useProjectPicker({
projects, projects,
currentProject,
isProjectPickerOpen, isProjectPickerOpen,
setIsProjectPickerOpen, setIsProjectPickerOpen,
setCurrentProject, setCurrentProject,
@@ -19,7 +17,6 @@ export function useProjectPicker({
const [projectSearchQuery, setProjectSearchQuery] = useState(''); const [projectSearchQuery, setProjectSearchQuery] = useState('');
const [selectedProjectIndex, setSelectedProjectIndex] = useState(0); const [selectedProjectIndex, setSelectedProjectIndex] = useState(0);
const projectSearchInputRef = useRef<HTMLInputElement>(null); const projectSearchInputRef = useRef<HTMLInputElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Filtered projects based on search query // Filtered projects based on search query
const filteredProjects = useMemo(() => { const filteredProjects = useMemo(() => {
@@ -30,66 +27,28 @@ export function useProjectPicker({
return projects.filter((project) => project.name.toLowerCase().includes(query)); return projects.filter((project) => project.name.toLowerCase().includes(query));
}, [projects, projectSearchQuery]); }, [projects, projectSearchQuery]);
// Helper function to scroll to a specific project // Reset selection when filtered results change
const scrollToProject = useCallback((projectId: string) => {
if (!scrollContainerRef.current) return;
const element = scrollContainerRef.current.querySelector(
`[data-testid="project-option-${projectId}"]`
);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
}, []);
// On open/close, handle search query reset and focus
useEffect(() => { useEffect(() => {
if (isProjectPickerOpen) { setSelectedProjectIndex(0);
// Focus search input after DOM renders }, [filteredProjects.length, projectSearchQuery]);
requestAnimationFrame(() => {
projectSearchInputRef.current?.focus(); // Reset search query when dropdown closes
}); useEffect(() => {
} else { if (!isProjectPickerOpen) {
// Reset search when closing
setProjectSearchQuery(''); setProjectSearchQuery('');
setSelectedProjectIndex(0);
} }
}, [isProjectPickerOpen]); }, [isProjectPickerOpen]);
// Update selection when search query changes (while picker is open) // Focus the search input when dropdown opens
useEffect(() => { useEffect(() => {
if (!isProjectPickerOpen) { if (isProjectPickerOpen) {
setSelectedProjectIndex(0); // Small delay to ensure the dropdown is rendered
return; setTimeout(() => {
projectSearchInputRef.current?.focus();
}, 0);
} }
}, [isProjectPickerOpen]);
if (projectSearchQuery.trim()) {
// When searching, reset to first result
setSelectedProjectIndex(0);
} else {
// When not searching (e.g., on open or search cleared), find and select the current project
const currentIndex = currentProject
? filteredProjects.findIndex((p) => p.id === currentProject.id)
: -1;
setSelectedProjectIndex(currentIndex !== -1 ? currentIndex : 0);
}
}, [isProjectPickerOpen, projectSearchQuery, filteredProjects, currentProject]);
// Scroll to highlighted item when selection changes
useEffect(() => {
if (!isProjectPickerOpen) return;
const targetProject = filteredProjects[selectedProjectIndex];
if (targetProject) {
// Use requestAnimationFrame to ensure DOM is rendered before scrolling
requestAnimationFrame(() => {
scrollToProject(targetProject.id);
});
}
}, [selectedProjectIndex, isProjectPickerOpen, filteredProjects, scrollToProject]);
// Handle selecting the currently highlighted project // Handle selecting the currently highlighted project
const selectHighlightedProject = useCallback(() => { const selectHighlightedProject = useCallback(() => {
@@ -140,7 +99,6 @@ export function useProjectPicker({
selectedProjectIndex, selectedProjectIndex,
setSelectedProjectIndex, setSelectedProjectIndex,
projectSearchInputRef, projectSearchInputRef,
scrollContainerRef,
filteredProjects, filteredProjects,
selectHighlightedProject, selectHighlightedProject,
}; };

View File

@@ -0,0 +1,40 @@
import { useState } from 'react';
import { useTrashOperations } from './use-trash-operations';
import type { TrashedProject } from '@/lib/electron';
interface UseTrashDialogProps {
restoreTrashedProject: (projectId: string) => void;
deleteTrashedProject: (projectId: string) => void;
emptyTrash: () => void;
trashedProjects: TrashedProject[];
}
/**
* Hook that combines trash operations with dialog state management
*/
export function useTrashDialog({
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
trashedProjects,
}: UseTrashDialogProps) {
// Dialog state
const [showTrashDialog, setShowTrashDialog] = useState(false);
// Reuse existing trash operations logic
const trashOperations = useTrashOperations({
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
trashedProjects,
});
return {
// Dialog state
showTrashDialog,
setShowTrashDialog,
// Trash operations (spread from existing hook)
...trashOperations,
};
}

View File

@@ -6,35 +6,35 @@ interface UseTrashOperationsProps {
restoreTrashedProject: (projectId: string) => void; restoreTrashedProject: (projectId: string) => void;
deleteTrashedProject: (projectId: string) => void; deleteTrashedProject: (projectId: string) => void;
emptyTrash: () => void; emptyTrash: () => void;
trashedProjects: TrashedProject[];
} }
export function useTrashOperations({ export function useTrashOperations({
restoreTrashedProject, restoreTrashedProject,
deleteTrashedProject, deleteTrashedProject,
emptyTrash, emptyTrash,
trashedProjects,
}: UseTrashOperationsProps) { }: UseTrashOperationsProps) {
const [activeTrashId, setActiveTrashId] = useState<string | null>(null); const [activeTrashId, setActiveTrashId] = useState<string | null>(null);
const [isEmptyingTrash, setIsEmptyingTrash] = useState(false); const [isEmptyingTrash, setIsEmptyingTrash] = useState(false);
const handleRestoreProject = useCallback( const handleRestoreProject = useCallback(
(projectId: string) => { (projectId: string) => {
try {
restoreTrashedProject(projectId); restoreTrashedProject(projectId);
toast.success('Project restored', { toast.success('Project restored', {
description: 'Added back to your project list.', description: 'Added back to your project list.',
}); });
} catch (error) {
console.error('[Sidebar] Failed to restore project:', error);
toast.error('Failed to restore project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
}, },
[restoreTrashedProject] [restoreTrashedProject]
); );
const handleDeleteProjectFromDisk = useCallback( const handleDeleteProjectFromDisk = useCallback(
async (trashedProject: TrashedProject) => { async (trashedProject: TrashedProject) => {
const confirmed = window.confirm(
`Delete "${trashedProject.name}" from disk?\nThis sends the folder to your system Trash.`
);
if (!confirmed) return;
setActiveTrashId(trashedProject.id); setActiveTrashId(trashedProject.id);
try { try {
const api = getElectronAPI(); const api = getElectronAPI();
@@ -64,19 +64,23 @@ export function useTrashOperations({
); );
const handleEmptyTrash = useCallback(() => { const handleEmptyTrash = useCallback(() => {
if (trashedProjects.length === 0) {
return;
}
const confirmed = window.confirm(
'Clear all projects from recycle bin? This does not delete folders from disk.'
);
if (!confirmed) return;
setIsEmptyingTrash(true); setIsEmptyingTrash(true);
try { try {
emptyTrash(); emptyTrash();
toast.success('Recycle bin cleared'); toast.success('Recycle bin cleared');
} catch (error) {
console.error('[Sidebar] Failed to empty trash:', error);
toast.error('Failed to clear recycle bin', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally { } finally {
setIsEmptyingTrash(false); setIsEmptyingTrash(false);
} }
}, [emptyTrash]); }, [emptyTrash, trashedProjects.length]);
return { return {
activeTrashId, activeTrashId,

View File

@@ -1,82 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { getElectronAPI } from '@/lib/electron';
import type { Project, StoredValidation } from '@/lib/electron';
/**
* Hook to track the count of unviewed (fresh) issue validations for a project.
* Also provides a function to decrement the count when a validation is viewed.
*/
export function useUnviewedValidations(currentProject: Project | null) {
const [count, setCount] = useState(0);
const projectPathRef = useRef<string | null>(null);
// Keep project path in ref for use in async functions
useEffect(() => {
projectPathRef.current = currentProject?.path ?? null;
}, [currentProject?.path]);
// Fetch and update count from server
const fetchUnviewedCount = useCallback(async () => {
const projectPath = projectPathRef.current;
if (!projectPath) return;
try {
const api = getElectronAPI();
if (api.github?.getValidations) {
const result = await api.github.getValidations(projectPath);
if (result.success && result.validations) {
const unviewed = result.validations.filter((v: StoredValidation) => {
if (v.viewedAt) return false;
// Check if not stale (< 24 hours)
const hoursSince = (Date.now() - new Date(v.validatedAt).getTime()) / (1000 * 60 * 60);
return hoursSince <= 24;
});
// Only update count if we're still on the same project (guard against race condition)
if (projectPathRef.current === projectPath) {
setCount(unviewed.length);
}
}
}
} catch (err) {
console.error('[useUnviewedValidations] Failed to load count:', err);
}
}, []);
// Load initial count and subscribe to events
useEffect(() => {
if (!currentProject?.path) {
setCount(0);
return;
}
// Load initial count
fetchUnviewedCount();
// Subscribe to validation events to update count
const api = getElectronAPI();
if (api.github?.onValidationEvent) {
const unsubscribe = api.github.onValidationEvent((event) => {
if (event.projectPath === currentProject.path) {
if (event.type === 'issue_validation_complete') {
// New validation completed - refresh count from server for consistency
fetchUnviewedCount();
} else if (event.type === 'issue_validation_viewed') {
// Validation was viewed - refresh count from server for consistency
fetchUnviewedCount();
}
}
});
return () => unsubscribe();
}
}, [currentProject?.path, fetchUnviewedCount]);
// Function to decrement count when a validation is viewed
const decrementCount = useCallback(() => {
setCount((prev) => Math.max(0, prev - 1));
}, []);
// Expose refreshCount as an alias to fetchUnviewedCount for external use
const refreshCount = fetchUnviewedCount;
return { count, decrementCount, refreshCount };
}

View File

@@ -11,8 +11,6 @@ export interface NavItem {
label: string; label: string;
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
shortcut?: string; shortcut?: string;
/** Optional count badge to display next to the nav item */
count?: number;
} }
export interface SortableProjectItemProps { export interface SortableProjectItemProps {

View File

@@ -24,6 +24,13 @@ const badgeVariants = cva(
// Muted variants for subtle indication // Muted variants for subtle indication
muted: 'border-border/50 bg-muted/50 text-muted-foreground', muted: 'border-border/50 bg-muted/50 text-muted-foreground',
brand: 'border-transparent bg-brand-500/15 text-brand-500 border border-brand-500/30', brand: 'border-transparent bg-brand-500/15 text-brand-500 border border-brand-500/30',
// Prism variants
prism:
'border-cyan-500/30 bg-cyan-500/10 text-cyan-400 hover:bg-cyan-500/20 font-mono tracking-wide rounded-md',
'prism-orange':
'border-amber-500/30 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 font-mono tracking-wide rounded-md',
'prism-green':
'border-emerald-500/30 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 font-mono tracking-wide rounded-md',
}, },
size: { size: {
default: 'px-2.5 py-0.5 text-xs', default: 'px-2.5 py-0.5 text-xs',

View File

@@ -1,102 +0,0 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
className
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-item"
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'a';
return (
<Comp
data-slot="breadcrumb-link"
className={cn('hover:text-foreground transition-colors', className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn('text-foreground font-normal', className)}
{...props}
/>
);
}
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -6,25 +6,32 @@ import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-300 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
{ {
variants: { variants: {
variant: { variant: {
default: default:
'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md hover:shadow-primary/25', 'bg-primary text-primary-foreground shadow-lg shadow-primary/20 hover:bg-primary/90 hover:shadow-primary/40 hover:-translate-y-0.5',
destructive: destructive:
'bg-destructive text-white shadow-sm hover:bg-destructive/90 hover:shadow-md hover:shadow-destructive/25 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', 'bg-destructive text-white shadow-sm hover:bg-destructive/90 hover:shadow-md hover:shadow-destructive/25 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', 'border border-border/50 bg-background/50 backdrop-blur-sm shadow-sm hover:bg-accent hover:text-accent-foreground dark:bg-white/5 dark:hover:bg-white/10 hover:border-accent',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', secondary:
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80 hover:shadow-md',
ghost: 'hover:bg-accent/50 hover:text-accent-foreground hover:backdrop-blur-sm',
link: 'text-primary underline-offset-4 hover:underline active:scale-100', link: 'text-primary underline-offset-4 hover:underline active:scale-100',
glass:
'border border-white/10 bg-white/5 text-foreground shadow-sm drop-shadow-sm backdrop-blur-md hover:bg-white/10 hover:border-white/20 hover:shadow-md transition-all duration-300',
'animated-outline': 'relative overflow-hidden rounded-xl hover:bg-transparent shadow-none', 'animated-outline': 'relative overflow-hidden rounded-xl hover:bg-transparent shadow-none',
'prism-primary':
'bg-cyan-400 text-slate-950 font-extrabold shadow-lg shadow-cyan-400/20 hover:brightness-110 hover:shadow-cyan-400/40 transition-all duration-200 tracking-wide',
'prism-glass':
'glass hover:bg-white/10 text-xs font-bold rounded-xl transition-all duration-200',
}, },
size: { size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3', default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 text-xs',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', lg: 'h-11 rounded-md px-8 has-[>svg]:px-5 text-base',
icon: 'size-9', icon: 'size-9',
'icon-sm': 'size-8', 'icon-sm': 'size-8',
'icon-lg': 'size-10', 'icon-lg': 'size-10',

View File

@@ -11,9 +11,9 @@ function Card({ className, gradient = false, ...props }: CardProps) {
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
'bg-card text-card-foreground flex flex-col gap-1 rounded-xl border border-white/10 backdrop-blur-md py-6', 'bg-white/5 text-card-foreground flex flex-col gap-1 rounded-[1.5rem] border border-white/10 backdrop-blur-xl py-6 transition-all duration-300',
// Premium layered shadow // Prism hover effect
'shadow-[0_1px_2px_rgba(0,0,0,0.05),0_4px_6px_rgba(0,0,0,0.05),0_10px_20px_rgba(0,0,0,0.04)]', 'hover:-translate-y-1 hover:bg-white/[0.06] hover:border-white/15',
// Gradient border option // Gradient border option
gradient && gradient &&
'relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10', 'relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10',

View File

@@ -1,10 +0,0 @@
import * as React from 'react';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -1,83 +0,0 @@
import type { ReactNode } from 'react';
import { LucideIcon } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
title: string;
description: string;
/** Optional icon to show in the title */
icon?: LucideIcon;
/** Icon color class. Defaults to "text-primary" */
iconClassName?: string;
/** Optional content to show between description and buttons */
children?: ReactNode;
/** Text for the confirm button. Defaults to "Confirm" */
confirmText?: string;
/** Text for the cancel button. Defaults to "Cancel" */
cancelText?: string;
/** Variant for the confirm button. Defaults to "default" */
confirmVariant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
}
export function ConfirmDialog({
open,
onOpenChange,
onConfirm,
title,
description,
icon: Icon,
iconClassName = 'text-primary',
children,
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmVariant = 'default',
}: ConfirmDialogProps) {
const handleConfirm = () => {
onConfirm();
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{Icon && <Icon className={`w-5 h-5 ${iconClassName}`} />}
{title}
</DialogTitle>
<DialogDescription className="text-muted-foreground">{description}</DialogDescription>
</DialogHeader>
{children}
<DialogFooter className="gap-2 sm:gap-2 pt-4">
<Button variant="ghost" onClick={() => onOpenChange(false)} className="px-4">
{cancelText}
</Button>
<HotkeyButton
variant={confirmVariant}
onClick={handleConfirm}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
className="px-4"
>
{Icon && <Icon className="w-4 h-4 mr-2" />}
{confirmText}
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -66,10 +66,10 @@ function DialogOverlay({
<DialogOverlayPrimitive <DialogOverlayPrimitive
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm', 'fixed inset-0 z-50 bg-black/40 backdrop-blur-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out', 'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'duration-200', 'duration-300',
className className
)} )}
{...props} {...props}
@@ -99,15 +99,15 @@ const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
className={cn( className={cn(
'fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]', 'fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
'flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-4rem)]', 'flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-4rem)]',
'bg-card border border-border rounded-xl shadow-2xl', 'bg-card/90 border border-white/10 rounded-2xl shadow-2xl backdrop-blur-xl',
// Premium shadow // Premium shadow
'shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]', 'shadow-[0_40px_80px_-12px_rgba(0,0,0,0.5)]',
// Animations - smoother with scale // Animations - smoother with scale
'data-[state=open]:animate-in data-[state=closed]:animate-out', 'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]', 'data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]',
'duration-200', 'duration-300 ease-out',
compact ? 'max-w-4xl p-4' : !hasCustomMaxWidth ? 'sm:max-w-2xl p-6' : 'p-6', compact ? 'max-w-4xl p-4' : !hasCustomMaxWidth ? 'sm:max-w-2xl p-6' : 'p-6',
className className
)} )}

View File

@@ -157,7 +157,8 @@ const DropdownMenuContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 'z-50 min-w-[8rem] overflow-hidden rounded-lg border border-white/10 bg-popover/80 p-1 text-popover-foreground shadow-xl backdrop-blur-xl',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className className
)} )}
{...props} {...props}

View File

@@ -1,36 +0,0 @@
import { CircleDot, RefreshCw } from 'lucide-react';
import { Button } from './button';
interface ErrorStateProps {
/** Error message to display */
error: string;
/** Title for the error state (default: "Failed to Load") */
title?: string;
/** Callback when retry button is clicked */
onRetry?: () => void;
/** Text for the retry button (default: "Try Again") */
retryText?: string;
}
export function ErrorState({
error,
title = 'Failed to Load',
onRetry,
retryText = 'Try Again',
}: ErrorStateProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-destructive/10 mb-4">
<CircleDot className="h-12 w-12 text-destructive" />
</div>
<h2 className="text-lg font-medium mb-2">{title}</h2>
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
{onRetry && (
<Button variant="outline" onClick={onRetry}>
<RefreshCw className="h-4 w-4 mr-2" />
{retryText}
</Button>
)}
</div>
);
}

View File

@@ -15,17 +15,21 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
'file:text-foreground placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground bg-input border-border h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', 'file:text-foreground placeholder:text-muted-foreground/50 selection:bg-cyan-500/30 selection:text-cyan-100',
// Inner shadow for depth 'bg-white/5 border-white/10 h-9 w-full min-w-0 rounded-xl border px-3 py-1 text-sm shadow-sm outline-none transition-all duration-200',
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]', 'file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium',
// Animated focus ring 'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
'transition-[color,box-shadow,border-color] duration-200 ease-out', 'backdrop-blur-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', // Hover state
'hover:bg-white/10 hover:border-white/20',
// Focus state with ring
'focus:bg-white/10 focus:border-cyan-500/50',
'focus-visible:border-cyan-500/50 focus-visible:ring-cyan-500/20 focus-visible:ring-[4px]',
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive', 'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
// Adjust padding for addons // Adjust padding for addons
startAddon && 'pl-0', startAddon && 'pl-0',
endAddon && 'pr-0', endAddon && 'pr-0',
hasAddons && 'border-0 shadow-none focus-visible:ring-0', hasAddons && 'border-0 shadow-none focus-visible:ring-0 bg-transparent',
className className
)} )}
{...props} {...props}
@@ -39,10 +43,10 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
return ( return (
<div <div
className={cn( className={cn(
'flex items-center h-9 w-full rounded-md border border-border bg-input shadow-xs', 'flex items-center h-9 w-full rounded-lg border border-input/50 bg-input/50 shadow-xs backdrop-blur-sm transition-all duration-300',
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]', 'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
'transition-[box-shadow,border-color] duration-200 ease-out', 'focus-within:bg-input/80 focus-within:border-ring/50',
'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]', 'focus-within:border-ring focus-within:ring-ring/20 focus-within:ring-[4px]',
'has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed', 'has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed',
'has-[input[aria-invalid]]:ring-destructive/20 has-[input[aria-invalid]]:border-destructive' 'has-[input[aria-invalid]]:ring-destructive/20 has-[input[aria-invalid]]:border-destructive'
)} )}

View File

@@ -1,28 +0,0 @@
import { cn } from '@/lib/utils';
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
return (
<kbd
data-slot="kbd"
className={cn(
'bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none',
"[&_svg:not([class*='size-'])]:size-3",
'in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10',
className
)}
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="kbd-group"
className={cn('inline-flex items-center gap-1', className)}
{...props}
/>
);
}
export { Kbd, KbdGroup };

View File

@@ -1,17 +0,0 @@
import { Loader2 } from 'lucide-react';
interface LoadingStateProps {
/** Optional custom message to display below the spinner */
message?: string;
/** Optional custom size class for the spinner (default: h-8 w-8) */
size?: string;
}
export function LoadingState({ message, size = 'h-8 w-8' }: LoadingStateProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center">
<Loader2 className={`${size} animate-spin text-muted-foreground`} />
{message && <p className="mt-4 text-sm text-muted-foreground">{message}</p>}
</div>
);
}

View File

@@ -1,442 +0,0 @@
import { useEffect, Fragment, FocusEvent, KeyboardEvent, MouseEvent } from 'react';
import { useState, useRef, useCallback, useMemo } from 'react';
import { Home, ArrowLeft, Pencil, ArrowRight, Search, Folder, File, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Kbd } from '@/components/ui/kbd';
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from '@/components/ui/command';
import { cn } from '@/lib/utils';
interface BreadcrumbSegment {
name: string;
path: string;
isLast: boolean;
}
function parseBreadcrumbs(path: string): BreadcrumbSegment[] {
if (!path) return [];
// Handle root path on Unix-like systems
if (path === '/') {
return [{ name: '/', path: '/', isLast: true }];
}
const segments = path.split(/[/\\]/).filter(Boolean);
const isWindows = segments[0]?.includes(':');
return segments.map((segment, index) => {
let fullPath: string;
if (isWindows) {
const pathParts = segments.slice(0, index + 1);
if (index === 0) {
fullPath = `${pathParts[0]}\\`;
} else {
fullPath = pathParts.join('\\');
}
} else {
fullPath = '/' + segments.slice(0, index + 1).join('/');
}
return {
name: segment,
path: fullPath,
isLast: index === segments.length - 1,
};
});
}
interface FileSystemEntry {
name: string;
path: string;
isDirectory: boolean;
}
interface PathInputProps {
/** Current resolved path */
currentPath: string;
/** Parent path for back navigation (null if at root) */
parentPath: string | null;
/** Whether the component is in a loading state */
loading?: boolean;
/** Whether there's an error (shows input mode and red border when true) */
error?: boolean;
/** Placeholder text for the input field */
placeholder?: string;
/** Placeholder text for the search input field */
searchPlaceholder?: string;
/** Called when user navigates to a path (via breadcrumb click, enter key, or navigation buttons) */
onNavigate: (path: string) => void;
/** Called when user clicks home button (navigates to home directory) */
onHome: () => void;
/** Additional className for the container */
className?: string;
/** List of files and directories in current path for search functionality */
entries?: FileSystemEntry[];
/** Called when user selects an entry from search results */
onSelectEntry?: (entry: FileSystemEntry) => void;
}
function PathInput({
currentPath,
parentPath,
loading = false,
error,
placeholder = 'Paste or type a full path (e.g., /home/user/projects/myapp)',
searchPlaceholder = 'Search...',
onNavigate,
onHome,
className,
entries = [],
onSelectEntry,
}: PathInputProps) {
const [isEditing, setIsEditing] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [pathInput, setPathInput] = useState(currentPath);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Sync pathInput with currentPath when it changes externally
useEffect(() => {
if (!isEditing) {
setPathInput(currentPath);
}
}, [currentPath, isEditing]);
// Focus input when error occurs or entering edit mode
useEffect(() => {
if ((error || isEditing) && inputRef.current) {
inputRef.current.focus();
if (error) {
inputRef.current.select();
}
}
}, [error, isEditing]);
const handleGoToParent = useCallback(() => {
if (parentPath) {
onNavigate(parentPath);
}
}, [parentPath, onNavigate]);
const handleBreadcrumbClick = useCallback(
(path: string) => {
onNavigate(path);
},
[onNavigate]
);
const handleStartEditing = useCallback(() => {
setIsEditing(true);
}, []);
const handleInputBlur = useCallback(
(e: FocusEvent) => {
// Check if focus is moving to another element within this component
if (containerRef.current?.contains(e.relatedTarget)) {
return;
}
if (pathInput !== currentPath) {
setPathInput(currentPath);
}
setIsEditing(false);
},
[pathInput, currentPath]
);
const handleGoToPath = useCallback(() => {
const trimmedPath = pathInput.trim();
if (trimmedPath) {
onNavigate(trimmedPath);
setIsEditing(false);
}
}, [pathInput, onNavigate]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleGoToPath();
} else if (e.key === 'Escape') {
e.preventDefault();
setPathInput(currentPath);
setIsEditing(false);
inputRef.current?.blur();
}
},
[handleGoToPath, currentPath]
);
// Handle click on the path container to start editing
const handleContainerClick = useCallback(
(e: MouseEvent) => {
// Don't trigger if clicking on a button or already editing
if (
isEditing ||
isSearchOpen ||
(e.target as HTMLElement).closest('button') ||
(e.target as HTMLElement).closest('a')
) {
return;
}
setIsEditing(true);
},
[isEditing, isSearchOpen]
);
const handleSelectEntry = useCallback(
(entry: FileSystemEntry) => {
if (onSelectEntry) {
onSelectEntry(entry);
}
setIsSearchOpen(false);
},
[onSelectEntry]
);
// Global keyboard shortcut to activate search (/)
useEffect(() => {
const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => {
// Activate search with '/' key (unless in an input field or contenteditable)
if (
e.key === '/' &&
!isEditing &&
!isSearchOpen &&
entries.length > 0 &&
!(e.target as HTMLElement).matches('input, textarea, [contenteditable="true"]')
) {
e.preventDefault();
setIsSearchOpen(true);
}
// Close search with Escape key
if (e.key === 'Escape' && isSearchOpen) {
e.preventDefault();
e.stopPropagation(); // Stop propagation so parent modal doesn't close
setIsSearchOpen(false);
}
};
// Use capture phase to intercept ESC before parent modal handlers
// This allows us to close search first, then let ESC bubble to close modal on second press
window.addEventListener('keydown', handleGlobalKeyDown, true);
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
}, [isEditing, isSearchOpen, entries.length]);
// Close search when clicking outside
useEffect(() => {
if (!isSearchOpen) return;
const handleClickOutside = (e: globalThis.MouseEvent) => {
const target = e.target as HTMLElement;
if (containerRef.current && !containerRef.current.contains(target)) {
setIsSearchOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isSearchOpen]);
const breadcrumbs = useMemo(() => parseBreadcrumbs(currentPath), [currentPath]);
const entryItems = useMemo(
() =>
entries.map((entry) => (
<CommandItem key={entry.path} value={entry.name} onSelect={() => handleSelectEntry(entry)}>
{entry.isDirectory ? (
<Folder className="w-3.5 h-3.5 text-brand-500 mr-2" />
) : (
<File className="w-3.5 h-3.5 text-muted-foreground mr-2" />
)}
<span className="flex-1 truncate font-mono text-xs">{entry.name}</span>
</CommandItem>
)),
[entries, handleSelectEntry]
);
const showBreadcrumbs = currentPath && !isEditing && !loading && !error;
return (
<div
ref={containerRef}
className={cn('flex items-center gap-2', className)}
role="navigation"
aria-label="Path navigation"
>
{/* Navigation buttons */}
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
onClick={onHome}
className="h-7 w-7"
disabled={loading}
aria-label="Go to home directory"
>
<Home className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleGoToParent}
className="h-7 w-7"
disabled={loading || !parentPath}
aria-label="Go to parent directory"
>
<ArrowLeft className="w-4 h-4" />
</Button>
</div>
{/* Path display / input */}
<div className="flex-1 relative min-w-0">
{/* Search Popover - positioned to overlap the input */}
{isSearchOpen && (
<div className="absolute inset-0 z-50">
<div className="relative w-full h-full">
<div className="absolute inset-0 bg-popover border border-border rounded-md shadow-lg">
<Command className="h-auto max-h-[300px]">
<div className="flex items-center gap-2 px-3 **:data-[slot=command-input-wrapper]:border-0 **:data-[slot=command-input-wrapper]:px-0">
<CommandInput
autoFocus
placeholder={searchPlaceholder}
className="h-8 flex-1"
/>
<div className="flex items-center gap-1 shrink-0 ml-auto">
<Button
variant="ghost"
size="icon"
onClick={() => setIsSearchOpen(false)}
className="h-6 w-6 text-muted-foreground hover:text-foreground"
aria-label="Close search"
>
<X className="w-3.5 h-3.5" />
</Button>
<Kbd className="hidden py-0.5 sm:inline-block">ESC</Kbd>
</div>
</div>
<CommandList className="scrollbar-styled">
<CommandEmpty>No files or directories found</CommandEmpty>
<CommandGroup>{entryItems}</CommandGroup>
</CommandList>
</Command>
</div>
</div>
</div>
)}
<div
onClick={handleContainerClick}
className={cn(
'flex items-center gap-2 min-w-0 h-8 px-3 rounded-md border bg-background/50 transition-colors',
error
? 'border-destructive focus-within:border-destructive'
: 'border-input focus-within:border-ring focus-within:ring-1 focus-within:ring-ring',
!isEditing && !error && 'cursor-text hover:border-ring/50'
)}
>
{showBreadcrumbs ? (
<>
<Breadcrumb className="flex-1 min-w-0 overflow-hidden">
<BreadcrumbList className="flex-nowrap overflow-x-auto scrollbar-none">
{breadcrumbs.map((crumb) => (
<Fragment key={crumb.path}>
<BreadcrumbItem className="shrink-0">
{crumb.isLast ? (
<BreadcrumbPage className="font-mono text-xs font-medium truncate max-w-[200px]">
{crumb.name}
</BreadcrumbPage>
) : (
<BreadcrumbLink
href="#"
onClick={(e) => {
e.preventDefault();
handleBreadcrumbClick(crumb.path);
}}
className="font-mono text-xs text-muted-foreground hover:text-foreground transition-colors truncate max-w-[150px]"
>
{crumb.name}
</BreadcrumbLink>
)}
</BreadcrumbItem>
{!crumb.isLast && (
<BreadcrumbSeparator className="[&>svg]:size-3.5 shrink-0" />
)}
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => setIsSearchOpen(true)}
className="h-6 w-6 text-muted-foreground hover:text-foreground"
aria-label="Search files and directories"
title="Search files and directories"
disabled={loading || entries.length === 0}
>
<Search className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleStartEditing}
className="h-6 w-6 text-muted-foreground hover:text-foreground"
aria-label="Edit path"
>
<Pencil className="w-3.5 h-3.5" />
</Button>
</div>
</>
) : (
<>
<Input
ref={inputRef}
type="text"
placeholder={placeholder}
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
className="flex-1 font-mono text-xs h-7 px-0 border-0 shadow-none focus-visible:ring-0 bg-transparent"
data-testid="path-input"
disabled={loading}
aria-label="Path input"
aria-invalid={error}
/>
<Button
variant="ghost"
size="icon"
onClick={handleGoToPath}
disabled={!pathInput.trim() || loading}
className="h-6 w-6 shrink-0"
aria-label="Go to path"
>
<ArrowRight className="w-3.5 h-3.5" />
</Button>
</>
)}
</div>
</div>
</div>
);
}
export { PathInput, parseBreadcrumbs };
export type { PathInputProps, BreadcrumbSegment, FileSystemEntry };

View File

@@ -50,10 +50,10 @@ const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(({ className, ...p
className={cn('relative flex w-full touch-none select-none items-center', className)} className={cn('relative flex w-full touch-none select-none items-center', className)}
{...props} {...props}
> >
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer"> <SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-white/10 cursor-pointer">
<SliderRangePrimitive className="slider-range absolute h-full bg-primary" /> <SliderRangePrimitive className="slider-range absolute h-full bg-cyan-400" />
</SliderTrackPrimitive> </SliderTrackPrimitive>
<SliderThumbPrimitive className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" /> <SliderThumbPrimitive className="slider-thumb block h-4 w-4 rounded-full border border-cyan-400/50 bg-background shadow-none transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400 disabled:pointer-events-none disabled:opacity-50 hover:bg-cyan-950/30 hover:border-cyan-400" />
</SliderRootPrimitive> </SliderRootPrimitive>
)); ));
Slider.displayName = SliderPrimitive.Root.displayName; Slider.displayName = SliderPrimitive.Root.displayName;

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