Compare commits

..

1 Commits

Author SHA1 Message Date
SuperComboGamer
379976aba7 feat: implement new layout components and enhance UI with glassmorphism
This commit introduces a new app layout structure with an AppLayout component, a TopHeader for improved navigation, and a Sidebar for project management. Additionally, it adds GlassPanel and GlassCard components to enhance the UI with a glassmorphism effect. The Kanban board and agent views have been updated to utilize these new components, improving the overall user experience and visual consistency across the application.
2025-12-23 16:38:49 -05:00
158 changed files with 4970 additions and 13344 deletions

3
.gitignore vendored
View File

@@ -79,5 +79,4 @@ blob-report/
# Misc
*.pem
docker-compose.override.yml
.claude/
docker-compose.override.yml

412
README.md
View File

@@ -19,7 +19,7 @@
- [What Makes Automaker Different?](#what-makes-automaker-different)
- [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)
- [Security Disclaimer](#security-disclaimer)
- [Community & Support](#community--support)
@@ -28,36 +28,22 @@
- [Quick Start](#quick-start)
- [How to Run](#how-to-run)
- [Development Mode](#development-mode)
- [Electron Desktop App (Recommended)](#electron-desktop-app-recommended)
- [Web Browser Mode](#web-browser-mode)
- [Building for Production](#building-for-production)
- [Running Production Build](#running-production-build)
- [Testing](#testing)
- [Linting](#linting)
- [Environment Configuration](#environment-configuration)
- [Authentication Setup](#authentication-setup)
- [Authentication Options](#authentication-options)
- [Persistent Setup (Optional)](#persistent-setup-optional)
- [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)
- [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)
- [License](#license)
</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)
@@ -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
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://platform.claude.com/docs/en/agent-sdk/overview) 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
@@ -109,7 +95,8 @@ In the Discord, you can:
- 🚀 Show off projects built with AI agents
- 🤝 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
- **Node.js 18+** (tested with Node.js 22)
- **npm** (comes with Node.js)
- **Authentication** (choose one):
- **[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/))
- Node.js 18+
- npm
- [Claude Code CLI](https://code.claude.com/docs/en/overview) installed and authenticated
### Quick Start
```bash
# 1. Clone the repository
# 1. Clone the repo
git clone https://github.com/AutoMaker-Org/automaker.git
cd automaker
# 2. Install dependencies
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
# 4. Set up authentication (skip if using Claude Code CLI)
# 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)
# 4. Run Automaker (pick your mode)
npm run dev
# Choose between:
# 1. Web Application (browser at localhost:3007)
# 2. Desktop Application (Electron - recommended)
# Then choose your run mode when prompted, or use specific commands below
```
**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
### Development Mode
@@ -197,65 +163,31 @@ npm run dev:web
### Building for Production
#### Web Application
```bash
# Build for web deployment (uses Vite)
# Build Next.js app
npm run build
# Run production build
npm run start
```
#### Desktop Application
```bash
# Build for current platform (macOS/Windows/Linux)
# Build Electron app for distribution
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
# Build and run with Docker Compose (recommended for security)
docker-compose up -d
# Access at http://localhost:3007
# API at http://localhost:3008
# Start production Next.js server
npm run start
```
### Testing
#### End-to-End Tests (Playwright)
```bash
npm run test # Headless E2E tests
npm run test:headed # Browser visible E2E tests
# Run tests headless
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
```bash
@@ -263,283 +195,59 @@ npm run test:all # Packages + server tests
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
- `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
### Persistent Setup (Optional)
Add to your `~/.bashrc` or `~/.zshrc`:
```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`).
##### 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.
Then restart your terminal or run `source ~/.bashrc`.
## Features
### Core Workflow
- 📋 **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"
- 🔀 **Git Worktree Isolation** - Each feature executes in isolated git worktrees to protect your main branch
- 📡 **Real-time Streaming** - Watch AI agents work in real-time with live tool usage, progress updates, and task completion
- 🔄 **Follow-up Instructions** - Send additional instructions to running agents without stopping them
### AI & Planning
- 🧠 **Multi-Model Support** - Choose from Claude Opus, Sonnet, and Haiku per feature
- 💭 **Extended Thinking** - Enable thinking modes (none, medium, deep, ultra) for complex problem-solving
- 📝 **Planning Modes** - Four planning levels: skip (direct implementation), lite (quick plan), spec (task breakdown), full (phased execution)
- **Plan Approval** - Review and approve AI-generated plans before implementation begins
- 📊 **Multi-Agent Task Execution** - Spec mode spawns dedicated agents per task for focused implementation
### Project Management
- 🔍 **Project Analysis** - AI-powered codebase analysis to understand your project structure
- 💡 **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
- 🧠 **Multi-Model Support** - Choose from multiple AI models including Claude Opus, Sonnet, and more
- 💭 **Extended Thinking** - Enable extended thinking modes for complex problem-solving
- 📡 **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
- 📁 **Context Management** - Add context files to help AI agents understand your project better
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on your project
- 🖼️ **Image Support** - Attach images and screenshots to feature descriptions
- **Concurrent Processing** - Configure concurrency to process multiple features simultaneously
- 🧪 **Test Integration** - Automatic test running and verification for implemented features
- 🔀 **Git Integration** - View git diffs and track changes made by AI agents
- 👤 **AI Profiles** - Create and manage different AI agent profiles for various tasks
- 💬 **Chat History** - Keep track of conversations and interactions with AI agents
- ⌨️ **Keyboard Shortcuts** - Efficient navigation and actions via keyboard shortcuts
- 🎨 **Dark/Light Theme** - Beautiful UI with theme support
- 🖥️ **Cross-Platform** - Desktop application built with Electron for Windows, macOS, and Linux
## Tech Stack
### Frontend
- **React 19** - UI framework
- **Vite 7** - Build tool and development server
- **Electron 39** - Desktop application framework
- **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
```
- [Next.js](https://nextjs.org) - React framework
- [Electron](https://www.electronjs.org/) - Desktop application framework
- [Tailwind CSS](https://tailwindcss.com/) - Styling
- [Zustand](https://zustand-demo.pmnd.rs/) - State management
- [dnd-kit](https://dndkit.com/) - Drag and drop functionality
## 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
- [Docker Isolation Guide](./docs/docker-isolation.md) - Security-focused Docker deployment
- [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)
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## 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 { createGitHubRoutes } from './routes/github/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
dotenv.config();
@@ -113,11 +111,10 @@ app.use(express.json({ limit: '50mb' }));
const events: EventEmitter = createEventEmitter();
// Create services
// Note: settingsService is created first so it can be injected into other services
const settingsService = new SettingsService(DATA_DIR);
const agentService = new AgentService(DATA_DIR, events, settingsService);
const agentService = new AgentService(DATA_DIR, events);
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();
// Initialize services
@@ -126,15 +123,6 @@ const claudeUsageService = new ClaudeUsageService();
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
app.use('/api/health', createHealthRoutes());
@@ -150,18 +138,17 @@ app.use('/api/enhance-prompt', createEnhancePromptRoutes());
app.use('/api/worktree', createWorktreeRoutes());
app.use('/api/git', createGitRoutes());
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/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events));
app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService));
app.use('/api/workspace', createWorkspaceRoutes());
app.use('/api/templates', createTemplatesRoutes());
app.use('/api/terminal', createTerminalRoutes());
app.use('/api/settings', createSettingsRoutes(settingsService));
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
app.use('/api/github', createGitHubRoutes(events, settingsService));
app.use('/api/context', createContextRoutes(settingsService));
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
app.use('/api/github', createGitHubRoutes());
app.use('/api/context', createContextRoutes());
// Create HTTP server
const server = createServer(app);
@@ -190,31 +177,12 @@ server.on('upgrade', (request, socket, head) => {
// Events WebSocket connection handler
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
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) {
const message = 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
);
ws.send(JSON.stringify({ type, payload }));
}
});
@@ -224,7 +192,7 @@ wss.on('connection', (ws: WebSocket) => {
});
ws.on('error', (error) => {
console.error('[WebSocket] ERROR:', error);
console.error('[WebSocket] Error:', error);
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
*/
@@ -213,12 +160,6 @@ export interface CreateSdkOptionsConfig {
type: 'json_schema';
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
* - Extended turns for thorough exploration
* - 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 {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
return {
...getBaseOptions(),
// Override permissionMode - spec generation only needs read-only tools
@@ -247,7 +184,7 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt
maxTurns: MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.specGeneration],
...claudeMdOptions,
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
...(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)
* - Quick turns since it's mostly JSON generation
* - 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 {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
return {
...getBaseOptions(),
// Override permissionMode - feature generation only needs read-only tools
@@ -277,7 +210,7 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
maxTurns: MAX_TURNS.quick,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions,
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
};
}
@@ -289,22 +222,18 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
* - Uses read-only tools for analysis
* - Standard turns to allow thorough codebase exploration and structured output generation
* - 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 {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
return {
...getBaseOptions(),
model: getModelForUseCase('suggestions', config.model),
maxTurns: MAX_TURNS.extended,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions,
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
...(config.outputFormat && { outputFormat: config.outputFormat }),
};
@@ -317,8 +246,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
* - Full tool access for code modification
* - Standard turns for interactive sessions
* - Model priority: explicit model > session model > chat default
* - Sandbox mode controlled by enableSandboxMode setting
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
* - Sandbox enabled for bash safety
*/
export function createChatOptions(config: CreateSdkOptionsConfig): 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
const effectiveModel = config.model || config.sessionModel;
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
return {
...getBaseOptions(),
model: getModelForUseCase('chat', effectiveModel),
maxTurns: MAX_TURNS.standard,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.chat],
...(config.enableSandboxMode && {
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
}),
...claudeMdOptions,
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
};
}
@@ -354,29 +277,23 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
* - Full tool access for code modification and implementation
* - Extended turns for thorough feature implementation
* - Uses default model (can be overridden)
* - Sandbox mode controlled by enableSandboxMode setting
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
* - Sandbox enabled for bash safety
*/
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
return {
...getBaseOptions(),
model: getModelForUseCase('auto', config.model),
maxTurns: MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.fullAccess],
...(config.enableSandboxMode && {
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
}),
...claudeMdOptions,
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
};
}
@@ -385,7 +302,6 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
* Create custom SDK options with explicit configuration
*
* 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(
config: CreateSdkOptionsConfig & {
@@ -397,9 +313,6 @@ export function createCustomOptions(
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
return {
...getBaseOptions(),
model: getModelForUseCase('default', config.model),
@@ -407,7 +320,7 @@ export function createCustomOptions(
cwd: config.cwd,
allowedTools: config.allowedTools ? [...config.allowedTools] : [...TOOL_PRESETS.readOnly],
...(config.sandbox && { sandbox: config.sandbox }),
...claudeMdOptions,
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
};
}

View File

@@ -20,9 +20,4 @@ export const {
lstat,
joinPath,
resolvePath,
// Throttling configuration and monitoring
configureThrottling,
getThrottlingConfig,
getPendingOperations,
getActiveOperations,
} = 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,
cwd,
allowedTools: toolsToUse,
permissionMode: 'default',
permissionMode: 'acceptEdits',
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
abortController,
// Resume existing SDK session if we have a session ID
...(sdkSessionId && conversationHistory && conversationHistory.length > 0
? { 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
@@ -88,8 +88,7 @@ export class ClaudeProvider extends BaseProvider {
yield msg as ProviderMessage;
}
} catch (error) {
console.error('[ClaudeProvider] ERROR: executeQuery() error during execution:', error);
console.error('[ClaudeProvider] ERROR stack:', (error as Error).stack);
console.error('[ClaudeProvider] executeQuery() error during execution:', error);
throw error;
}
}

View File

@@ -26,15 +26,13 @@ export interface ExecuteOptions {
prompt: string | Array<{ type: string; text?: string; source?: object }>;
model: string;
cwd: string;
systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: string };
systemPrompt?: string;
maxTurns?: number;
allowedTools?: string[];
mcpServers?: Record<string, unknown>;
abortController?: AbortController;
conversationHistory?: ConversationMessage[]; // Previous messages for context
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 { createClearHandler } from './routes/clear.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 {
const router = Router();
@@ -31,15 +27,5 @@ export function createAgentRoutes(agentService: AgentService, _events: EventEmit
router.post('/clear', createClearHandler(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;
}

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;
};
console.log('[Send Handler] Received request:', {
sessionId,
messageLength: message?.length,
workingDirectory,
imageCount: imagePaths?.length || 0,
model,
});
if (!sessionId || !message) {
console.log('[Send Handler] ERROR: Validation failed - missing sessionId or message');
res.status(400).json({
success: false,
error: 'sessionId and message are required',
@@ -36,8 +27,6 @@ export function createSendHandler(agentService: AgentService) {
return;
}
console.log('[Send Handler] Validation passed, calling agentService.sendMessage()');
// Start the message processing (don't await - it streams via WebSocket)
agentService
.sendMessage({
@@ -48,16 +37,12 @@ export function createSendHandler(agentService: AgentService) {
model,
})
.catch((error) => {
console.error('[Send Handler] ERROR: Background error in sendMessage():', error);
logError(error, 'Send message failed (background)');
});
console.log('[Send Handler] Returning immediate response to client');
// Return immediately - responses come via WebSocket
res.json({ success: true, message: 'Message sent' });
} catch (error) {
console.error('[Send Handler] ERROR: Synchronous error:', error);
logError(error, 'Send message failed');
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 { parseAndCreateFeatures } from './parse-and-create-features.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
const logger = createLogger('SpecRegeneration');
@@ -21,8 +19,7 @@ export async function generateFeaturesFromSpec(
projectPath: string,
events: EventEmitter,
abortController: AbortController,
maxFeatures?: number,
settingsService?: SettingsService
maxFeatures?: number
): Promise<void> {
const featureCount = maxFeatures ?? DEFAULT_MAX_FEATURES;
logger.debug('========== generateFeaturesFromSpec() started ==========');
@@ -94,17 +91,9 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
projectPath: projectPath,
});
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[FeatureGeneration]'
);
const options = createFeatureGenerationOptions({
cwd: projectPath,
abortController,
autoLoadClaudeMd,
});
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 { generateFeaturesFromSpec } from './generate-features-from-spec.js';
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');
@@ -29,8 +27,7 @@ export async function generateSpec(
abortController: AbortController,
generateFeatures?: boolean,
analyzeProject?: boolean,
maxFeatures?: number,
settingsService?: SettingsService
maxFeatures?: number
): Promise<void> {
logger.info('========== generateSpec() started ==========');
logger.info('projectPath:', projectPath);
@@ -86,17 +83,9 @@ ${getStructuredSpecPromptInstruction()}`;
content: 'Starting spec generation...\n',
});
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[SpecRegeneration]'
);
const options = createSpecGenerationOptions({
cwd: projectPath,
abortController,
autoLoadClaudeMd,
outputFormat: {
type: 'json_schema',
schema: specOutputSchema,
@@ -280,13 +269,7 @@ ${getStructuredSpecPromptInstruction()}`;
// Create a new abort controller for feature generation
const featureAbortController = new AbortController();
try {
await generateFeaturesFromSpec(
projectPath,
events,
featureAbortController,
maxFeatures,
settingsService
);
await generateFeaturesFromSpec(projectPath, events, featureAbortController, maxFeatures);
// Final completion will be emitted by generateFeaturesFromSpec -> parseAndCreateFeatures
} catch (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 { createStopHandler } from './routes/stop.js';
import { createStatusHandler } from './routes/status.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createSpecRegenerationRoutes(
events: EventEmitter,
settingsService?: SettingsService
): Router {
export function createSpecRegenerationRoutes(events: EventEmitter): Router {
const router = Router();
router.post('/create', createCreateHandler(events));
router.post('/generate', createGenerateHandler(events, settingsService));
router.post('/generate-features', createGenerateFeaturesHandler(events, settingsService));
router.post('/generate', createGenerateHandler(events));
router.post('/generate-features', createGenerateFeaturesHandler(events));
router.post('/stop', createStopHandler());
router.get('/status', createStatusHandler());

View File

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

View File

@@ -13,11 +13,10 @@ import {
getErrorMessage,
} from '../common.js';
import { generateSpec } from '../generate-spec.js';
import type { SettingsService } from '../../../services/settings-service.js';
const logger = createLogger('SpecRegeneration');
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
export function createGenerateHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
logger.info('========== /generate endpoint called ==========');
logger.debug('Request body:', JSON.stringify(req.body, null, 2));
@@ -68,8 +67,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
abortController,
generateFeatures,
analyzeProject,
maxFeatures,
settingsService
maxFeatures
)
.catch((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 { createDescribeImageHandler } from './routes/describe-image.js';
import { createDescribeFileHandler } from './routes/describe-file.js';
import type { SettingsService } from '../../services/settings-service.js';
/**
* Create the context router
*
* @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
* @returns Express router with context endpoints
*/
export function createContextRoutes(settingsService?: SettingsService): Router {
export function createContextRoutes(): Router {
const router = Router();
router.post('/describe-image', createDescribeImageHandler(settingsService));
router.post('/describe-file', createDescribeFileHandler(settingsService));
router.post('/describe-image', createDescribeImageHandler());
router.post('/describe-file', createDescribeFileHandler());
return router;
}

View File

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

View File

@@ -3,54 +3,16 @@
*/
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 { createListIssuesHandler } from './routes/list-issues.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(
events: EventEmitter,
settingsService?: SettingsService
): Router {
export function createGitHubRoutes(): Router {
const router = Router();
router.post('/check-remote', validatePathParams('projectPath'), createCheckGitHubRemoteHandler());
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
router.post('/prs', validatePathParams('projectPath'), 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)
);
router.post('/check-remote', createCheckGitHubRemoteHandler());
router.post('/issues', createListIssuesHandler());
router.post('/prs', createListPRsHandler());
return router;
}

View File

@@ -2,7 +2,6 @@
* POST /list-issues endpoint - List GitHub issues for a project
*/
import { spawn } from 'child_process';
import type { Request, Response } from 'express';
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js';
@@ -14,19 +13,6 @@ export interface GitHubLabel {
export interface GitHubAuthor {
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 {
@@ -38,8 +24,6 @@ export interface GitHubIssue {
labels: GitHubLabel[];
url: string;
body: string;
assignees: GitHubAssignee[];
linkedPRs?: LinkedPullRequest[];
}
export interface ListIssuesResult {
@@ -49,146 +33,6 @@ export interface ListIssuesResult {
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() {
return async (req: Request, res: Response): Promise<void> => {
try {
@@ -209,17 +53,17 @@ export function createListIssuesHandler() {
return;
}
// Fetch open and closed issues in parallel (now including assignees)
// Fetch open and closed issues in parallel
const [openResult, closedResult] = await Promise.all([
execAsync(
'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body,assignees --limit 100',
'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body --limit 100',
{
cwd: projectPath,
env: execEnv,
}
),
execAsync(
'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body,assignees --limit 50',
'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body --limit 50',
{
cwd: projectPath,
env: execEnv,
@@ -233,24 +77,6 @@ export function createListIssuesHandler() {
const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]');
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({
success: true,
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 { createLogger } from '@automaker/utils';
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');
/**
* 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
*/
@@ -127,8 +42,7 @@ export async function generateSuggestions(
projectPath: string,
suggestionType: string,
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService
abortController: AbortController
): Promise<void> {
const typePrompts: Record<string, string> = {
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.',
};
// Load existing context to avoid duplicates
const existingContext = await loadExistingContext(projectPath);
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.
For each suggestion, provide:
@@ -154,20 +63,14 @@ For each suggestion, provide:
The response will be automatically formatted as structured JSON.`;
// Don't send initial message - let the agent output speak for itself
// The first agent message will be captured as an info entry
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[Suggestions]'
);
events.emit('suggestions:event', {
type: 'suggestions_progress',
content: `Starting ${suggestionType} analysis...\n`,
});
const options = createSuggestionsOptions({
cwd: projectPath,
abortController,
autoLoadClaudeMd,
outputFormat: {
type: 'json_schema',
schema: suggestionsSchema,

View File

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

View File

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

@@ -56,56 +56,32 @@ export function createCreatePRHandler() {
}
// Check for uncommitted changes
console.log(`[CreatePR] Checking for uncommitted changes in: ${worktreePath}`);
const { stdout: status } = await execAsync('git status --porcelain', {
cwd: worktreePath,
env: execEnv,
});
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
let commitHash: string | null = null;
let commitError: string | null = null;
if (hasChanges) {
const message = commitMessage || `Changes from ${branchName}`;
console.log(`[CreatePR] Committing changes with message: ${message}`);
try {
// Stage all changes
console.log(`[CreatePR] Running: git add -A`);
await execAsync('git add -A', { cwd: worktreePath, env: execEnv });
// Stage all changes
await execAsync('git add -A', { cwd: worktreePath, env: execEnv });
// Create commit
console.log(`[CreatePR] Running: git commit`);
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
cwd: worktreePath,
env: execEnv,
});
// Create commit
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
cwd: worktreePath,
env: execEnv,
});
// Get commit hash
const { stdout: hashOutput } = await execAsync('git rev-parse HEAD', {
cwd: worktreePath,
env: execEnv,
});
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;
}
// Get commit hash
const { stdout: hashOutput } = await execAsync('git rev-parse HEAD', {
cwd: worktreePath,
env: execEnv,
});
commitHash = hashOutput.trim().substring(0, 8);
}
// Push the branch to remote
@@ -384,9 +360,8 @@ export function createCreatePRHandler() {
success: true,
result: {
branch: branchName,
committed: hasChanges && !commitError,
committed: hasChanges,
commitHash,
commitError: commitError || undefined,
pushed: true,
prUrl,
prNumber,

View File

@@ -16,12 +16,6 @@ import {
import { ProviderFactory } from '../providers/provider-factory.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { PathNotAllowedError } from '@automaker/platform';
import type { SettingsService } from './settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getEnableSandboxModeSetting,
filterClaudeMdFromContext,
} from '../lib/settings-helpers.js';
interface Message {
id: string;
@@ -36,14 +30,6 @@ interface Message {
isError?: boolean;
}
interface QueuedPrompt {
id: string;
message: string;
imagePaths?: string[];
model?: string;
addedAt: string;
}
interface Session {
messages: Message[];
isRunning: boolean;
@@ -51,7 +37,6 @@ interface Session {
workingDirectory: string;
model?: string;
sdkSessionId?: string; // Claude SDK session ID for conversation continuity
promptQueue: QueuedPrompt[]; // Queue of prompts to auto-run after current task
}
interface SessionMetadata {
@@ -72,13 +57,11 @@ export class AgentService {
private stateDir: string;
private metadataFile: string;
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.metadataFile = path.join(dataDir, 'sessions-metadata.json');
this.events = events;
this.settingsService = settingsService ?? null;
}
async initialize(): Promise<void> {
@@ -107,16 +90,12 @@ export class AgentService {
// Validate that the working directory is allowed using centralized validation
validateWorkingDirectory(resolvedWorkingDirectory);
// Load persisted queue
const promptQueue = await this.loadQueueState(sessionId);
this.sessions.set(sessionId, {
messages,
isRunning: false,
abortController: null,
workingDirectory: resolvedWorkingDirectory,
sdkSessionId: sessionMetadata?.sdkSessionId, // Load persisted SDK session ID
promptQueue,
});
}
@@ -146,12 +125,10 @@ export class AgentService {
}) {
const session = this.sessions.get(sessionId);
if (!session) {
console.error('[AgentService] ERROR: Session not found:', sessionId);
throw new Error(`Session ${sessionId} not found`);
}
if (session.isRunning) {
console.error('[AgentService] ERROR: Agent already running for session:', sessionId);
throw new Error('Agent is already processing a message');
}
@@ -197,11 +174,6 @@ export class AgentService {
session.isRunning = true;
session.abortController = new AbortController();
// Emit started event so UI can show thinking indicator
this.emitAgentEvent(sessionId, {
type: 'started',
});
// Emit user message event
this.emitAgentEvent(sessionId, {
type: 'message',
@@ -214,29 +186,12 @@ export class AgentService {
// Determine the effective working directory for context loading
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.)
const contextResult = await loadContextFiles({
const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
projectPath: effectiveWorkDir,
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
const baseSystemPrompt = this.getSystemPrompt();
const combinedSystemPrompt = contextFilesPrompt
@@ -250,8 +205,6 @@ export class AgentService {
sessionModel: session.model,
systemPrompt: combinedSystemPrompt,
abortController: session.abortController!,
autoLoadClaudeMd,
enableSandboxMode,
});
// Extract model, maxTurns, and allowedTools from SDK options
@@ -262,18 +215,20 @@ export class AgentService {
// Get provider for this model
const provider = ProviderFactory.getProviderForModel(effectiveModel);
console.log(
`[AgentService] Using provider "${provider.getName()}" for model "${effectiveModel}"`
);
// Build options for provider
const options: ExecuteOptions = {
prompt: '', // Will be set below based on images
model: effectiveModel,
cwd: effectiveWorkDir,
systemPrompt: sdkOptions.systemPrompt,
systemPrompt: combinedSystemPrompt,
maxTurns: maxTurns,
allowedTools: allowedTools,
abortController: session.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
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
if (msg.session_id && !session.sdkSessionId) {
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
await this.updateSession(sessionId, { sdkSessionId: msg.session_id });
}
@@ -363,9 +319,6 @@ export class AgentService {
session.isRunning = false;
session.abortController = null;
// Process next item in queue after completion
setImmediate(() => this.processNextInQueue(sessionId));
return {
success: true,
message: currentAssistantMessage,
@@ -604,165 +557,6 @@ export class AgentService {
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 {
this.events.emit('agent:stream', { sessionId, ...data });
}

View File

@@ -25,18 +25,8 @@ import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../lib/secure-fs.js';
import type { EventEmitter } from '../lib/events.js';
import {
createAutoModeOptions,
createCustomOptions,
validateWorkingDirectory,
} from '../lib/sdk-options.js';
import { createAutoModeOptions, validateWorkingDirectory } from '../lib/sdk-options.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);
@@ -351,11 +341,9 @@ export class AutoModeService {
private autoLoopAbortController: AbortController | null = null;
private config: AutoModeConfig | null = null;
private pendingApprovals = new Map<string, PendingApproval>();
private settingsService: SettingsService | null = null;
constructor(events: EventEmitter, settingsService?: SettingsService) {
constructor(events: EventEmitter) {
this.events = events;
this.settingsService = settingsService ?? null;
}
/**
@@ -563,25 +551,14 @@ export class AutoModeService {
// Update feature status to 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)
let prompt: string;
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt
const contextResult = await loadContextFiles({
const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
projectPath,
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) {
// Continuation prompt is used when recovering from a plan approval
// The plan was already approved, so skip the planning phase
@@ -627,7 +604,6 @@ export class AutoModeService {
planningMode: feature.planningMode,
requirePlanApproval: feature.requirePlanApproval,
systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd,
}
);
@@ -770,23 +746,12 @@ export class AutoModeService {
// 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
const contextResult = await loadContextFiles({
const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
projectPath,
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
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
previousContent: previousContext || 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
*/
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 analysisFeatureId = `analysis-${Date.now()}`;
@@ -1128,32 +1097,13 @@ Format your response as a structured markdown document.`;
const analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude);
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 = {
prompt,
model: sdkOptions.model ?? analysisModel,
cwd: sdkOptions.cwd ?? projectPath,
maxTurns: sdkOptions.maxTurns,
allowedTools: sdkOptions.allowedTools as string[],
model: analysisModel,
maxTurns: 5,
cwd: projectPath,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
};
const stream = provider.executeQuery(options);
@@ -1758,7 +1708,6 @@ This helps parse your summary correctly in the output logs.`;
requirePlanApproval?: boolean;
previousContent?: string;
systemPrompt?: string;
autoLoadClaudeMd?: boolean;
}
): Promise<void> {
const finalProjectPath = options?.projectPath || projectPath;
@@ -1831,23 +1780,11 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
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
const sdkOptions = createAutoModeOptions({
cwd: workDir,
model: model,
abortController,
autoLoadClaudeMd,
enableSandboxMode,
});
// 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,
allowedTools: allowedTools,
abortController,
systemPrompt: sdkOptions.systemPrompt,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
systemPrompt: options?.systemPrompt,
};
// Execute via provider

View File

@@ -179,7 +179,7 @@ describe('sdk-options.ts', () => {
it('should create options with chat settings', async () => {
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.maxTurns).toBe(MAX_TURNS.standard);
@@ -212,27 +212,6 @@ describe('sdk-options.ts', () => {
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', () => {
@@ -240,7 +219,7 @@ describe('sdk-options.ts', () => {
const { createAutoModeOptions, TOOL_PRESETS, MAX_TURNS } =
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.maxTurns).toBe(MAX_TURNS.maximum);
@@ -273,27 +252,6 @@ describe('sdk-options.ts', () => {
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', () => {

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,
cwd: '/test/dir',
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(
(async function* () {
yield { type: 'text', text: 'test' };
@@ -110,10 +110,6 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
cwd: '/test',
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
});
await collectAsyncGenerator(generator);
@@ -246,21 +242,11 @@ describe('claude-provider.ts', () => {
});
await expect(collectAsyncGenerator(generator)).rejects.toThrow('SDK execution failed');
// Should log error message
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
1,
'[ClaudeProvider] ERROR: executeQuery() error during execution:',
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[ClaudeProvider] executeQuery() error during execution:',
testError
);
// Should log stack trace
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
2,
'[ClaudeProvider] ERROR stack:',
testError.stack
);
consoleErrorSpy.mockRestore();
});
});

View File

@@ -106,9 +106,9 @@ describe('agent-service.ts', () => {
});
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)
expect(fs.readFile).toHaveBeenCalledTimes(3);
expect(fs.readFile).toHaveBeenCalledTimes(2);
});
});

View File

@@ -44,7 +44,6 @@
"@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.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-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",

View File

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

View File

@@ -0,0 +1,36 @@
import { ReactNode } from 'react';
import { Sidebar } from './sidebar';
// TopHeader removed from layout to be view-specific
interface AppLayoutProps {
children: ReactNode;
}
export function AppLayout({ children }: AppLayoutProps) {
return (
<div className="flex h-screen w-full relative selection:bg-brand-cyan selection:text-black font-sans bg-dark-950 overflow-hidden">
{/* Ambient Background */}
<div
className="fixed bottom-[-25%] left-[-15%] w-[1000px] h-[1000px] opacity-80 pointer-events-none z-0 blob-rainbow"
style={{
background:
'radial-gradient(circle at center, rgba(6, 182, 212, 0.15) 0%, rgba(59, 130, 246, 0.12) 30%, rgba(249, 115, 22, 0.08) 60%, transparent 80%)',
filter: 'blur(100px)',
}}
></div>
<div
className="fixed top-[-20%] right-[-10%] w-[700px] h-[700px] pointer-events-none z-0"
style={{
background: 'radial-gradient(circle, rgba(16, 185, 129, 0.05) 0%, transparent 70%)',
filter: 'blur(100px)',
}}
></div>
<Sidebar />
<main className="flex-1 flex flex-col min-w-0 relative z-10 h-full">
<div className="flex-1 overflow-hidden relative">{children}</div>
</main>
</div>
);
}

View File

@@ -1,372 +1,213 @@
import { useState, useCallback } from 'react';
import { useNavigate, useLocation } from '@tanstack/react-router';
import React from 'react';
import {
Code2,
PanelLeft,
Plus,
Folder,
Bell,
FolderOpen,
MoreVertical,
LayoutGrid,
Bot,
FileJson,
BookOpen,
UserCircle,
TerminalSquare,
Book,
Activity,
Settings,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
import { toast } from 'sonner';
import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
// Local imports from subfolder
import {
CollapseToggleButton,
SidebarHeader,
ProjectActions,
SidebarNavigation,
ProjectSelectorWithOptions,
SidebarFooter,
} from './sidebar/components';
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
import {
useSidebarAutoCollapse,
useRunningAgents,
useSpecRegeneration,
useNavigation,
useProjectCreation,
useSetupDialog,
useTrashOperations,
useProjectTheme,
useUnviewedValidations,
} from './sidebar/hooks';
import { Link, useLocation } from '@tanstack/react-router';
export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const {
projects,
trashedProjects,
currentProject,
sidebarOpen,
projectHistory,
upsertAndSetCurrentProject,
toggleSidebar,
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
cyclePrevProject,
cycleNextProject,
moveProjectToTrash,
specCreatingForProject,
setSpecCreatingForProject,
} = useAppStore();
// Environment variable flags for hiding sidebar items
const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor, hideAiProfiles } =
SIDEBAR_FEATURE_FLAGS;
// Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
// State for project picker (needed for keyboard shortcuts)
const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false);
// State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
// State for trash dialog
const [showTrashDialog, setShowTrashDialog] = useState(false);
// Project theme management (must come before useProjectCreation which uses globalTheme)
const { globalTheme } = useProjectTheme();
// Project creation state and handlers
const {
showNewProjectModal,
setShowNewProjectModal,
isCreatingProject,
showOnboardingDialog,
setShowOnboardingDialog,
newProjectName,
setNewProjectName,
newProjectPath,
setNewProjectPath,
handleCreateBlankProject,
handleCreateFromTemplate,
handleCreateFromCustomUrl,
} = useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
});
// Setup dialog state and handlers
const {
showSetupDialog,
setShowSetupDialog,
setupProjectPath,
setSetupProjectPath,
projectOverview,
setProjectOverview,
generateFeatures,
setGenerateFeatures,
analyzeProject,
setAnalyzeProject,
featureCount,
setFeatureCount,
handleCreateInitialSpec,
handleSkipSetup,
handleOnboardingGenerateSpec,
handleOnboardingSkip,
} = useSetupDialog({
setSpecCreatingForProject,
newProjectPath,
setNewProjectName,
setNewProjectPath,
setShowOnboardingDialog,
});
// Derive isCreatingSpec from store state
const isCreatingSpec = specCreatingForProject !== null;
const creatingSpecProjectPath = specCreatingForProject;
// Auto-collapse sidebar on small screens and update Electron window minWidth
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
// Running agents count
const { runningAgentsCount } = useRunningAgents();
// Unviewed validations count
const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject);
// Trash operations
const {
activeTrashId,
isEmptyingTrash,
handleRestoreProject,
handleDeleteProjectFromDisk,
handleEmptyTrash,
} = useTrashOperations({
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
});
// Spec regeneration events
useSpecRegeneration({
creatingSpecProjectPath,
setupProjectPath,
setSpecCreatingForProject,
setShowSetupDialog,
setProjectOverview,
setSetupProjectPath,
setNewProjectName,
setNewProjectPath,
});
/**
* Opens the system folder selection dialog and initializes the selected project.
* Used by both the 'O' keyboard shortcut and the folder icon button.
*/
const handleOpenFolder = useCallback(async () => {
const api = getElectronAPI();
const result = await api.openDirectory();
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
// Extract folder name from path (works on both Windows and Mac/Linux)
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
try {
// Check if this is a brand new project (no .automaker directory)
const hadAutomakerDir = await hasAutomakerDir(path);
// Initialize the .automaker directory structure
const initResult = await initializeProject(path);
if (!initResult.success) {
toast.error('Failed to initialize project', {
description: initResult.error || 'Unknown error occurred',
});
return;
}
// Upsert project and set as current (handles both create and update cases)
// Theme preservation is handled by the store action
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(path, name, effectiveTheme);
// Check if app_spec.txt exists
const specExists = await hasAppSpec(path);
if (!hadAutomakerDir && !specExists) {
// This is a brand new project - show setup dialog
setSetupProjectPath(path);
setShowSetupDialog(true);
toast.success('Project opened', {
description: `Opened ${name}. Let's set up your app specification!`,
});
} else if (initResult.createdFiles && initResult.createdFiles.length > 0) {
toast.success(initResult.isNewProject ? 'Project initialized' : 'Project updated', {
description: `Set up ${initResult.createdFiles.length} file(s) in .automaker`,
});
} else {
toast.success('Project opened', {
description: `Opened ${name}`,
});
}
} catch (error) {
console.error('[Sidebar] Failed to open project:', error);
toast.error('Failed to open project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
}
}, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme]);
// Navigation sections and keyboard shortcuts (defined after handlers)
const { navSections, navigationShortcuts } = useNavigation({
shortcuts,
hideSpecEditor,
hideContext,
hideTerminal,
hideAiProfiles,
currentProject,
projects,
projectHistory,
navigate,
toggleSidebar,
handleOpenFolder,
setIsProjectPickerOpen,
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
});
// Register keyboard shortcuts
useKeyboardShortcuts(navigationShortcuts);
const isActiveRoute = (id: string) => {
// Map view IDs to route paths
const routePath = id === 'welcome' ? '/' : `/${id}`;
return location.pathname === routePath;
};
return (
<aside
className={cn(
'flex-shrink-0 flex flex-col z-30 relative',
// Glass morphism background with gradient
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
// Premium border with subtle glow
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
// Smooth width transition
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
sidebarOpen ? 'w-16 lg:w-72' : 'w-16'
)}
data-testid="sidebar"
>
<CollapseToggleButton
sidebarOpen={sidebarOpen}
toggleSidebar={toggleSidebar}
shortcut={shortcuts.toggleSidebar}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
{/* Project Actions - Moved above project selector */}
{sidebarOpen && (
<ProjectActions
setShowNewProjectModal={setShowNewProjectModal}
handleOpenFolder={handleOpenFolder}
setShowTrashDialog={setShowTrashDialog}
trashedProjects={trashedProjects}
shortcuts={{ openProject: shortcuts.openProject }}
/>
)}
<ProjectSelectorWithOptions
sidebarOpen={sidebarOpen}
isProjectPickerOpen={isProjectPickerOpen}
setIsProjectPickerOpen={setIsProjectPickerOpen}
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
/>
<SidebarNavigation
currentProject={currentProject}
sidebarOpen={sidebarOpen}
navSections={navSections}
isActiveRoute={isActiveRoute}
navigate={navigate}
/>
<aside className="w-[260px] flex-shrink-0 flex flex-col glass-sidebar z-30 relative h-full">
{/* Logo */}
<div className="h-16 flex items-center px-6 gap-3 flex-shrink-0">
<div className="text-brand-cyan relative flex items-center justify-center">
<div className="absolute inset-0 bg-brand-cyan blur-md opacity-30"></div>
<Code2 className="w-6 h-6 relative z-10" />
</div>
<span className="text-white font-bold text-lg tracking-tight">automaker.</span>
<button className="ml-auto text-slate-600 hover:text-white transition">
<PanelLeft className="w-4 h-4" />
</button>
</div>
<SidebarFooter
sidebarOpen={sidebarOpen}
isActiveRoute={isActiveRoute}
navigate={navigate}
hideWiki={hideWiki}
hideRunningAgents={hideRunningAgents}
runningAgentsCount={runningAgentsCount}
shortcuts={{ settings: shortcuts.settings }}
/>
<TrashDialog
open={showTrashDialog}
onOpenChange={setShowTrashDialog}
trashedProjects={trashedProjects}
activeTrashId={activeTrashId}
handleRestoreProject={handleRestoreProject}
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
deleteTrashedProject={deleteTrashedProject}
handleEmptyTrash={handleEmptyTrash}
isEmptyingTrash={isEmptyingTrash}
/>
{/* Top Actions */}
<div className="px-5 pb-6 space-y-4 flex-shrink-0">
<div className="grid grid-cols-4 gap-2">
<button className="col-span-2 bg-dark-850/60 hover:bg-dark-700 text-slate-200 py-2 px-3 rounded-lg border border-white/5 flex items-center justify-center gap-2 transition text-xs font-medium shadow-lg shadow-black/20 group">
<Plus className="w-3.5 h-3.5 group-hover:text-brand-cyan transition-colors" /> New
</button>
<button className="col-span-1 bg-dark-850/60 hover:bg-dark-700 text-slate-400 hover:text-white py-2 rounded-lg border border-white/5 flex items-center justify-center transition">
<Folder className="w-3.5 h-3.5" />
<span className="ml-1 text-[10px]">0</span>
</button>
<button className="col-span-1 bg-dark-850/60 hover:bg-dark-700 text-slate-400 hover:text-white py-2 rounded-lg border border-white/5 flex items-center justify-center transition relative">
<Bell className="w-3.5 h-3.5" />
<span className="absolute top-2 right-2.5 w-1.5 h-1.5 bg-brand-red rounded-full ring-2 ring-dark-850"></span>
</button>
</div>
{/* New Project Setup Dialog */}
<CreateSpecDialog
open={showSetupDialog}
onOpenChange={setShowSetupDialog}
projectOverview={projectOverview}
onProjectOverviewChange={setProjectOverview}
generateFeatures={generateFeatures}
onGenerateFeaturesChange={setGenerateFeatures}
analyzeProject={analyzeProject}
onAnalyzeProjectChange={setAnalyzeProject}
featureCount={featureCount}
onFeatureCountChange={setFeatureCount}
onCreateSpec={handleCreateInitialSpec}
onSkip={handleSkipSetup}
isCreatingSpec={isCreatingSpec}
showSkipButton={true}
title="Set Up Your Project"
description="We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification."
/>
{/* Project Selector */}
<div className="bg-dark-850/40 border border-white/5 rounded-xl p-1 flex items-center justify-between cursor-pointer hover:border-white/10 hover:bg-dark-850/60 transition group">
<div className="flex items-center gap-3 px-2 py-1.5">
<FolderOpen className="w-4 h-4 text-brand-cyan group-hover:text-cyan-300 transition" />
<span className="text-white font-medium text-sm">test case 1</span>
</div>
<div className="flex items-center gap-1 pr-1">
<span className="w-5 h-5 rounded bg-dark-700 flex items-center justify-center text-[10px] text-slate-400 font-bold border border-white/5">
P
</span>
<MoreVertical className="w-4 h-4 text-slate-500" />
</div>
</div>
</div>
<OnboardingDialog
open={showOnboardingDialog}
onOpenChange={setShowOnboardingDialog}
newProjectName={newProjectName}
onSkip={handleOnboardingSkip}
onGenerateSpec={handleOnboardingGenerateSpec}
/>
{/* Navigation */}
<div className="flex-1 overflow-y-auto px-0 space-y-6 custom-scrollbar">
{/* Project Section */}
<div>
<h3 className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2 px-6 font-mono">
Project
</h3>
<nav className="space-y-0.5">
<NavItem
to="/"
icon={<LayoutGrid className="w-4 h-4" />}
label="Kanban Board"
shortcut="L"
isActive={location.pathname === '/' || location.pathname === '/board'}
/>
<NavItem
to="/agents"
icon={<Bot className="w-4 h-4" />}
label="Agent Runner"
shortcut="A"
isActive={location.pathname.startsWith('/agents')}
/>
</nav>
</div>
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteProjectDialog}
onOpenChange={setShowDeleteProjectDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
{/* Tools Section */}
<div>
<h3 className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2 px-6 font-mono">
Tools
</h3>
<nav className="space-y-0.5">
<NavItem
to="/spec"
icon={<FileJson className="w-4 h-4" />}
label="Spec Editor"
shortcut="D"
isActive={location.pathname.startsWith('/spec')}
/>
<NavItem
to="/context"
icon={<BookOpen className="w-4 h-4" />}
label="Context"
shortcut="C"
isActive={location.pathname.startsWith('/context')}
/>
<NavItem
to="/profiles"
icon={<UserCircle className="w-4 h-4" />}
label="AI Profiles"
shortcut="H"
isActive={location.pathname.startsWith('/profiles')}
/>
<NavItem
to="/terminal"
icon={<TerminalSquare className="w-4 h-4" />}
label="Terminal"
shortcut="T"
isActive={location.pathname.startsWith('/terminal')}
/>
</nav>
</div>
</div>
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreatingProject}
/>
{/* Footer */}
<div className="p-4 border-t border-white/5 space-y-1 bg-dark-900/30 flex-shrink-0 backdrop-blur-sm">
<Link
to="/wiki"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition"
>
<Book className="w-4 h-4" />
<span className="text-sm">Wiki</span>
</Link>
<Link
to="/running-agents"
className="flex items-center justify-between px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition"
>
<div className="flex items-center gap-3">
<Activity className="w-4 h-4 text-brand-cyan" />
<span className="text-sm">Running Agents</span>
</div>
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-brand-cyan text-[10px] text-black font-bold shadow-glow-cyan">
3
</span>
</Link>
<Link
to="/settings"
className="flex items-center justify-between px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition"
>
<div className="flex items-center gap-3">
<Settings className="w-4 h-4" />
<span className="text-sm">Settings</span>
</div>
<span className="text-[10px] bg-dark-700 text-slate-500 px-1.5 py-0.5 rounded font-mono border border-white/5">
S
</span>
</Link>
</div>
</aside>
);
}
function NavItem({
to,
icon,
label,
shortcut,
isActive,
}: {
to: string;
icon: React.ReactNode;
label: string;
shortcut: string;
isActive: boolean;
}) {
return (
<Link
to={to}
className={cn(
'flex items-center justify-between px-6 py-2.5 transition group border-l-[2px]',
isActive
? 'nav-item-active bg-gradient-to-r from-brand-cyan/10 to-transparent border-brand-cyan text-brand-cyan-hover'
: 'text-slate-400 hover:text-white hover:bg-white/5 border-transparent'
)}
>
<div className="flex items-center gap-3">
<span className={cn(isActive ? 'text-brand-cyan' : 'group-hover:text-slate-300')}>
{icon}
</span>
<span className="text-sm font-medium">{label}</span>
</div>
<span
className={cn(
'text-[10px] px-1.5 py-0.5 rounded font-mono border',
isActive
? 'bg-brand-cyan/10 text-brand-cyan border-brand-cyan/20'
: 'bg-dark-700 text-slate-500 border-white/5 group-hover:text-slate-300'
)}
>
{shortcut}
</span>
</Link>
);
}

View File

@@ -78,29 +78,14 @@ export function SidebarNavigation({
title={!sidebarOpen ? item.label : undefined}
data-testid={`nav-${item.id}`}
>
<div className="relative">
<Icon
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActive
? 'text-brand-500 drop-shadow-sm'
: '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>
<Icon
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActive
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
</div>
/>
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
@@ -109,21 +94,7 @@ export function SidebarNavigation({
>
{item.label}
</span>
{/* Count badge */}
{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 && (
{item.shortcut && sidebarOpen && (
<span
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',

View File

@@ -1,4 +1,3 @@
import { useState } from 'react';
import { X, Trash2, Undo2 } from 'lucide-react';
import {
Dialog,
@@ -9,8 +8,6 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
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';
interface TrashDialogProps {
@@ -36,146 +33,84 @@ export function TrashDialog({
handleEmptyTrash,
isEmptyingTrash,
}: 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 (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="bg-popover/95 backdrop-blur-xl border-border max-w-2xl">
<DialogHeader>
<DialogTitle>Recycle Bin</DialogTitle>
<DialogDescription className="text-muted-foreground">
Restore projects to the sidebar or delete their folders using your system Trash.
</DialogDescription>
</DialogHeader>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover/95 backdrop-blur-xl border-border max-w-2xl">
<DialogHeader>
<DialogTitle>Recycle Bin</DialogTitle>
<DialogDescription className="text-muted-foreground">
Restore projects to the sidebar or delete their folders using your system Trash.
</DialogDescription>
</DialogHeader>
{trashedProjects.length === 0 ? (
<p className="text-sm text-muted-foreground">Recycle bin is empty.</p>
) : (
<div className="space-y-3 max-h-[360px] overflow-y-auto pr-1">
{trashedProjects.map((project) => (
<div
key={project.id}
className="flex items-start justify-between gap-3 rounded-lg border border-border bg-card/50 p-4"
>
<div className="space-y-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{project.name}</p>
<p className="text-xs text-muted-foreground break-all">{project.path}</p>
<p className="text-[11px] text-muted-foreground/80">
Trashed {new Date(project.trashedAt).toLocaleString()}
</p>
</div>
<div className="flex flex-col gap-2 shrink-0">
<Button
size="sm"
variant="secondary"
onClick={() => handleRestoreProject(project.id)}
data-testid={`restore-project-${project.id}`}
>
<Undo2 className="h-3.5 w-3.5 mr-1.5" />
Restore
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => onDeleteFromDiskClick(project)}
disabled={activeTrashId === project.id}
data-testid={`delete-project-disk-${project.id}`}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{activeTrashId === project.id ? 'Deleting...' : 'Delete from disk'}
</Button>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={() => deleteTrashedProject(project.id)}
data-testid={`remove-project-${project.id}`}
>
<X className="h-3.5 w-3.5 mr-1.5" />
Remove from list
</Button>
</div>
</div>
))}
</div>
)}
<DialogFooter className="flex justify-between">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Close
</Button>
{trashedProjects.length > 0 && (
<Button
variant="outline"
onClick={onEmptyTrashClick}
disabled={isEmptyingTrash}
data-testid="empty-trash"
{trashedProjects.length === 0 ? (
<p className="text-sm text-muted-foreground">Recycle bin is empty.</p>
) : (
<div className="space-y-3 max-h-[360px] overflow-y-auto pr-1">
{trashedProjects.map((project) => (
<div
key={project.id}
className="flex items-start justify-between gap-3 rounded-lg border border-border bg-card/50 p-4"
>
{isEmptyingTrash ? 'Clearing...' : 'Empty Recycle Bin'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<div className="space-y-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{project.name}</p>
<p className="text-xs text-muted-foreground break-all">{project.path}</p>
<p className="text-[11px] text-muted-foreground/80">
Trashed {new Date(project.trashedAt).toLocaleString()}
</p>
</div>
<div className="flex flex-col gap-2 shrink-0">
<Button
size="sm"
variant="secondary"
onClick={() => handleRestoreProject(project.id)}
data-testid={`restore-project-${project.id}`}
>
<Undo2 className="h-3.5 w-3.5 mr-1.5" />
Restore
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteProjectFromDisk(project)}
disabled={activeTrashId === project.id}
data-testid={`delete-project-disk-${project.id}`}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{activeTrashId === project.id ? 'Deleting...' : 'Delete from disk'}
</Button>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={() => deleteTrashedProject(project.id)}
data-testid={`remove-project-${project.id}`}
>
<X className="h-3.5 w-3.5 mr-1.5" />
Remove from list
</Button>
</div>
</div>
))}
</div>
)}
{/* 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"
/>
</>
<DialogFooter className="flex justify-between">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Close
</Button>
{trashedProjects.length > 0 && (
<Button
variant="outline"
onClick={handleEmptyTrash}
disabled={isEmptyingTrash}
data-testid="empty-trash"
>
{isEmptyingTrash ? 'Clearing...' : 'Empty Recycle Bin'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -8,5 +8,5 @@ export { useSpecRegeneration } from './use-spec-regeneration';
export { useNavigation } from './use-navigation';
export { useProjectCreation } from './use-project-creation';
export { useSetupDialog } from './use-setup-dialog';
export { useTrashDialog } from './use-trash-dialog';
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;
cyclePrevProject: () => void;
cycleNextProject: () => void;
/** Count of unviewed validations to show on GitHub Issues nav item */
unviewedValidationsCount?: number;
}
export function useNavigation({
@@ -63,7 +61,6 @@ export function useNavigation({
setIsProjectPickerOpen,
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
}: UseNavigationProps) {
// Track if current project has a GitHub remote
const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
@@ -172,7 +169,6 @@ export function useNavigation({
id: 'github-issues',
label: 'Issues',
icon: CircleDot,
count: unviewedValidationsCount,
},
{
id: 'github-prs',
@@ -184,15 +180,7 @@ export function useNavigation({
}
return sections;
}, [
shortcuts,
hideSpecEditor,
hideContext,
hideTerminal,
hideAiProfiles,
hasGitHubRemote,
unviewedValidationsCount,
]);
}, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles, hasGitHubRemote]);
// Build keyboard shortcuts for navigation
const navigationShortcuts: KeyboardShortcut[] = useMemo(() => {

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;
deleteTrashedProject: (projectId: string) => void;
emptyTrash: () => void;
trashedProjects: TrashedProject[];
}
export function useTrashOperations({
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
trashedProjects,
}: UseTrashOperationsProps) {
const [activeTrashId, setActiveTrashId] = useState<string | null>(null);
const [isEmptyingTrash, setIsEmptyingTrash] = useState(false);
const handleRestoreProject = useCallback(
(projectId: string) => {
try {
restoreTrashedProject(projectId);
toast.success('Project restored', {
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(projectId);
toast.success('Project restored', {
description: 'Added back to your project list.',
});
},
[restoreTrashedProject]
);
const handleDeleteProjectFromDisk = useCallback(
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);
try {
const api = getElectronAPI();
@@ -64,19 +64,23 @@ export function useTrashOperations({
);
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);
try {
emptyTrash();
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 {
setIsEmptyingTrash(false);
}
}, [emptyTrash]);
}, [emptyTrash, trashedProjects.length]);
return {
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;
icon: React.ComponentType<{ className?: string }>;
shortcut?: string;
/** Optional count badge to display next to the nav item */
count?: number;
}
export interface SortableProjectItemProps {

View File

@@ -0,0 +1,38 @@
import { Users, Play, Plus } from 'lucide-react';
export function TopHeader() {
return (
<header className="h-16 glass-header flex items-center justify-between px-8 flex-shrink-0 z-20">
<div>
<h1 className="text-white font-bold text-lg tracking-tight">Kanban Board</h1>
<p className="text-xs text-slate-500 font-medium font-mono mt-0.5">test case 1</p>
</div>
<div className="flex items-center gap-4">
{/* User Toggle */}
<div className="flex items-center bg-dark-850/60 rounded-lg p-1 border border-white/5 h-9 shadow-inner-light">
<div className="flex items-center gap-3 px-2 border-r border-white/5 h-full mr-2">
<Users className="w-3.5 h-3.5 text-slate-400" />
{/* Toggle Switch */}
<div className="w-[28px] h-[16px] bg-[#2d3546] rounded-full relative cursor-pointer border border-white/10 transition-colors">
<div className="absolute top-[2px] right-[2px] w-[10px] h-[10px] bg-brand-cyan rounded-full shadow-[0_0_6px_rgba(6,182,212,0.6)]"></div>
</div>
</div>
<span className="text-xs text-slate-400 px-1 font-mono">3</span>
</div>
{/* Auto Mode */}
<button className="flex items-center gap-2 text-slate-300 hover:text-white px-3 py-1.5 rounded-lg border border-white/5 bg-dark-850/60 hover:bg-dark-700 transition text-xs font-medium h-9">
<Play className="w-3.5 h-3.5 fill-current" />
<span>Auto Mode</span>
</button>
{/* Add Feature */}
<button className="flex items-center gap-2 bg-brand-cyan hover:bg-cyan-400 text-dark-950 font-bold px-4 py-1.5 rounded-lg transition shadow-glow-cyan text-xs h-9 btn-hover-effect">
<Plus className="w-4 h-4" />
<span>Add Feature</span>
</button>
</div>
</header>
);
}

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

@@ -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

@@ -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

@@ -0,0 +1,26 @@
import { cn } from '@/lib/utils';
import { HTMLAttributes, forwardRef } from 'react';
export interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'active-blue';
}
export const GlassCard = forwardRef<HTMLDivElement, GlassCardProps>(
({ className, variant = 'default', children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
variant === 'default' && 'glass-card',
variant === 'active-blue' && 'glass-card card-active-blue',
'rounded-xl p-4',
className
)}
{...props}
>
{children}
</div>
);
}
);
GlassCard.displayName = 'GlassCard';

View File

@@ -0,0 +1,28 @@
import { cn } from '@/lib/utils';
import { HTMLAttributes, forwardRef } from 'react';
export interface GlassPanelProps extends HTMLAttributes<HTMLDivElement> {
accent?: 'cyan' | 'blue' | 'orange' | 'green' | 'none';
}
export const GlassPanel = forwardRef<HTMLDivElement, GlassPanelProps>(
({ className, accent = 'none', children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
'glass-panel rounded-2xl flex flex-col',
accent === 'cyan' && 'col-accent-cyan',
accent === 'blue' && 'col-accent-blue',
accent === 'orange' && 'col-accent-orange',
accent === 'green' && 'col-accent-green',
className
)}
{...props}
>
{children}
</div>
);
}
);
GlassPanel.displayName = 'GlassPanel';

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

@@ -1,173 +0,0 @@
/**
* Update Notifier Component
*
* Responsible for displaying toast notifications related to updates.
* Subscribes to the updates store and reacts to state changes.
*
* This component handles the UI notifications, keeping them separate
* from the business logic in the store.
*/
import { useEffect, useRef, useCallback } from 'react';
import { toast } from 'sonner';
import { useUpdatesStore } from '@/store/updates-store';
import { useUpdatePolling } from '@/hooks/use-update-polling';
import { useAppStore } from '@/store/app-store';
import { getRepoDisplayName } from '@/lib/utils';
// ============================================================================
// Types
// ============================================================================
export interface UpdateNotifierProps {
/** Custom handler for update available (for testing/DI) */
onUpdateAvailable?: (remoteVersion: string) => void;
/** Custom handler for update installed (for testing/DI) */
onUpdateInstalled?: (newVersion: string, alreadyUpToDate: boolean) => void;
}
// ============================================================================
// Component
// ============================================================================
/**
* Displays persistent toasts for available and installed application updates.
*
* Shows a persistent "Update Available" toast when a new remote version is detected and,
* after initiating an update, shows success toasts for either "Already up to date!" or
* "Update installed!" with actions to restart now or later.
*
* @param onUpdateAvailable - Optional callback invoked with `remoteVersion` when an update is detected; providing this prevents the default availability toast.
* @param onUpdateInstalled - Optional callback invoked with `(newVersion, alreadyUpToDate)` after attempting to install updates; providing this prevents the default installation toasts.
* @returns Null (this component renders no visible UI; it manages global toast notifications).
*/
export function UpdateNotifier({ onUpdateAvailable, onUpdateInstalled }: UpdateNotifierProps = {}) {
// Store state
const { updateAvailable, remoteVersionShort, pullUpdates, isPulling } = useUpdatesStore();
const { autoUpdate } = useAppStore();
// Start polling
useUpdatePolling();
// Track shown toasts to avoid duplicates
const shownToastForCommitRef = useRef<string | null>(null);
const toastIdRef = useRef<string | number | null>(null);
// Handle "Update Now" click
const handleUpdateNow = useCallback(async () => {
const result = await pullUpdates();
if (result) {
// Dismiss the "update available" toast
if (toastIdRef.current) {
toast.dismiss(toastIdRef.current);
toastIdRef.current = null;
}
// Call custom handler if provided
if (onUpdateInstalled) {
onUpdateInstalled(result.newVersionShort, result.alreadyUpToDate);
return;
}
// Show appropriate toast based on result
if (result.alreadyUpToDate) {
toast.success('Already up to date!');
} else {
toast.success('Update installed!', {
description: result.message,
duration: Infinity,
action: {
label: 'Restart Now',
onClick: () => {
window.location.reload();
},
},
cancel: {
label: 'Later',
onClick: () => {
// Just dismiss - user will restart manually later
},
},
});
}
}
}, [pullUpdates, onUpdateInstalled]);
// Show toast when update becomes available
useEffect(() => {
if (!updateAvailable || !remoteVersionShort) {
return;
}
// Don't show toast if we've already shown it for this version
if (shownToastForCommitRef.current === remoteVersionShort) {
return;
}
shownToastForCommitRef.current = remoteVersionShort;
// Call custom handler if provided
if (onUpdateAvailable) {
onUpdateAvailable(remoteVersionShort);
return;
}
// Dismiss any existing toast
if (toastIdRef.current) {
toast.dismiss(toastIdRef.current);
}
// Extract repo name for display
const repoName = getRepoDisplayName(autoUpdate.upstreamUrl);
// Show persistent toast with update button
toastIdRef.current = toast.info('Update Available', {
description: `New version (${remoteVersionShort}) available from ${repoName}`,
duration: Infinity,
action: {
label: isPulling ? 'Updating...' : 'Update Now',
onClick: handleUpdateNow,
},
cancel: {
label: 'Later',
onClick: () => {
// Dismiss toast - won't show again for this version until a new version appears
shownToastForCommitRef.current = remoteVersionShort;
},
},
});
}, [
updateAvailable,
remoteVersionShort,
autoUpdate.upstreamUrl,
isPulling,
handleUpdateNow,
onUpdateAvailable,
]);
// Clean up toast on unmount
useEffect(() => {
return () => {
if (toastIdRef.current) {
toast.dismiss(toastIdRef.current);
}
};
}, []);
// Reset shown toast when update is no longer available
useEffect(() => {
if (!updateAvailable) {
shownToastForCommitRef.current = null;
if (toastIdRef.current) {
toast.dismiss(toastIdRef.current);
toastIdRef.current = null;
}
}
}, [updateAvailable]);
// This component doesn't render anything visible
return null;
}

View File

@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
import { useAppStore } from '@/store/app-store';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -18,6 +18,9 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { TopHeader } from '@/components/layout/top-header';
import { GlassPanel } from '@/components/ui/glass-panel';
import { GlassCard } from '@/components/ui/glass-card';
interface ToolResult {
success: boolean;
@@ -190,258 +193,285 @@ export function AgentToolsView() {
}
return (
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="agent-tools-view">
{/* Header */}
<div className="flex items-center gap-3 p-4 border-b border-border bg-glass backdrop-blur-md">
<Wrench className="w-5 h-5 text-primary" />
<div>
<h1 className="text-xl font-bold">Agent Tools</h1>
<p className="text-sm text-muted-foreground">
Test file system and terminal tools for {currentProject.name}
</p>
</div>
</div>
{/* Tools Grid */}
<div className="flex-1 overflow-y-auto p-4">
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
{/* Read File Tool */}
<Card data-testid="read-file-tool">
<CardHeader>
<div className="flex items-center gap-2">
<File className="w-5 h-5 text-blue-500" />
<CardTitle className="text-lg">Read File</CardTitle>
<div className="flex-1 flex flex-col overflow-hidden bg-background">
<TopHeader />
<div className="flex-1 flex flex-col overflow-hidden p-6 pt-0">
<GlassPanel className="flex-1 flex flex-col overflow-hidden relative shadow-2xl bg-black/40 backdrop-blur-xl border-white/5">
<div className="flex-1 flex flex-col overflow-hidden p-6">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-purple-500/20 to-blue-600/20 border border-purple-500/30 flex items-center justify-center shadow-inner shadow-purple-500/20">
<Wrench className="w-6 h-6 text-purple-400" />
</div>
<CardDescription>Agent requests to read a file from the filesystem</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="read-file-path">File Path</Label>
<Input
id="read-file-path"
placeholder="/path/to/file.txt"
value={readFilePath}
onChange={(e) => setReadFilePath(e.target.value)}
data-testid="read-file-path-input"
/>
<div>
<h1 className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">
Agent Tools
</h1>
<p className="text-sm text-muted-foreground">
Test file system and terminal tools for {currentProject.name}
</p>
</div>
<Button
onClick={handleReadFile}
disabled={isReadingFile || !readFilePath.trim()}
className="w-full"
data-testid="read-file-button"
>
{isReadingFile ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Reading...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Read
</>
)}
</Button>
{/* Result */}
{readFileResult && (
<div
className={cn(
'p-3 rounded-md border',
readFileResult.success
? 'bg-green-500/10 border-green-500/20'
: 'bg-red-500/10 border-red-500/20'
)}
data-testid="read-file-result"
>
<div className="flex items-center gap-2 mb-2">
{readFileResult.success ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{readFileResult.success ? 'Success' : 'Failed'}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
{readFileResult.success ? readFileResult.output : readFileResult.error}
</pre>
</div>
)}
</CardContent>
</Card>
{/* Write File Tool */}
<Card data-testid="write-file-tool">
<CardHeader>
<div className="flex items-center gap-2">
<Pencil className="w-5 h-5 text-green-500" />
<CardTitle className="text-lg">Write File</CardTitle>
</div>
<CardDescription>Agent requests to write content to a file</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="write-file-path">File Path</Label>
<Input
id="write-file-path"
placeholder="/path/to/file.txt"
value={writeFilePath}
onChange={(e) => setWriteFilePath(e.target.value)}
data-testid="write-file-path-input"
/>
</div>
<div className="space-y-2">
<Label htmlFor="write-file-content">Content</Label>
<textarea
id="write-file-content"
placeholder="File content..."
value={writeFileContent}
onChange={(e) => setWriteFileContent(e.target.value)}
className="w-full min-h-[100px] px-3 py-2 text-sm rounded-md border border-input bg-background resize-y"
data-testid="write-file-content-input"
/>
</div>
<Button
onClick={handleWriteFile}
disabled={isWritingFile || !writeFilePath.trim() || !writeFileContent.trim()}
className="w-full"
data-testid="write-file-button"
>
{isWritingFile ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Writing...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Write
</>
)}
</Button>
{/* Result */}
{writeFileResult && (
<div
className={cn(
'p-3 rounded-md border',
writeFileResult.success
? 'bg-green-500/10 border-green-500/20'
: 'bg-red-500/10 border-red-500/20'
)}
data-testid="write-file-result"
>
<div className="flex items-center gap-2 mb-2">
{writeFileResult.success ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{writeFileResult.success ? 'Success' : 'Failed'}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
{writeFileResult.success ? writeFileResult.output : writeFileResult.error}
</pre>
</div>
)}
</CardContent>
</Card>
{/* Terminal Tool */}
<Card data-testid="terminal-tool">
<CardHeader>
<div className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-purple-500" />
<CardTitle className="text-lg">Run Terminal</CardTitle>
</div>
<CardDescription>Agent requests to execute a terminal command</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="terminal-command">Command</Label>
<Input
id="terminal-command"
placeholder="ls -la"
value={terminalCommand}
onChange={(e) => setTerminalCommand(e.target.value)}
data-testid="terminal-command-input"
/>
</div>
<Button
onClick={handleRunCommand}
disabled={isRunningCommand || !terminalCommand.trim()}
className="w-full"
data-testid="run-terminal-button"
>
{isRunningCommand ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Running...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Command
</>
)}
</Button>
{/* Result */}
{terminalResult && (
<div
className={cn(
'p-3 rounded-md border',
terminalResult.success
? 'bg-green-500/10 border-green-500/20'
: 'bg-red-500/10 border-red-500/20'
)}
data-testid="terminal-result"
>
<div className="flex items-center gap-2 mb-2">
{terminalResult.success ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{terminalResult.success ? 'Success' : 'Failed'}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/50 text-green-400 p-2 rounded">
$ {terminalCommand}
{'\n'}
{terminalResult.success ? terminalResult.output : terminalResult.error}
</pre>
</div>
)}
</CardContent>
</Card>
</div>
{/* Tool Log Section */}
<Card className="mt-6" data-testid="tool-log">
<CardHeader>
<CardTitle className="text-lg">Tool Execution Log</CardTitle>
<CardDescription>View agent tool requests and responses</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<p className="text-muted-foreground">
Open your browser&apos;s developer console to see detailed agent tool logs.
</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Read File - Agent requests file content from filesystem</li>
<li>Write File - Agent writes content to specified path</li>
<li>Run Terminal - Agent executes shell commands</li>
</ul>
</div>
</CardContent>
</Card>
{/* Tools Grid */}
<div className="flex-1 overflow-y-auto">
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 max-w-7xl">
{/* Read File Tool */}
<GlassCard
className="flex flex-col gap-4 bg-white/5 border-white/10"
data-testid="read-file-tool"
>
<div className="flex items-center gap-3 pb-2 border-b border-white/5">
<div className="p-2 rounded-lg bg-blue-500/10">
<File className="w-5 h-5 text-blue-400" />
</div>
<div>
<h3 className="font-semibold text-foreground">Read File</h3>
<p className="text-xs text-muted-foreground">Read from filesystem</p>
</div>
</div>
<div className="space-y-4 flex-1">
<div className="space-y-2">
<Label htmlFor="read-file-path">File Path</Label>
<Input
id="read-file-path"
placeholder="/path/to/file.txt"
value={readFilePath}
onChange={(e) => setReadFilePath(e.target.value)}
data-testid="read-file-path-input"
className="bg-black/20 border-white/10 focus:border-blue-500/50"
/>
</div>
<Button
onClick={handleReadFile}
disabled={isReadingFile || !readFilePath.trim()}
className="w-full bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border border-blue-500/30"
data-testid="read-file-button"
>
{isReadingFile ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Reading...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Read
</>
)}
</Button>
{/* Result */}
{readFileResult && (
<div
className={cn(
'p-3 rounded-lg border text-xs',
readFileResult.success
? 'bg-green-500/10 border-green-500/20 text-green-300'
: 'bg-red-500/10 border-red-500/20 text-red-300'
)}
data-testid="read-file-result"
>
<div className="flex items-center gap-2 mb-2 font-medium">
{readFileResult.success ? (
<CheckCircle className="w-4 h-4 text-green-400" />
) : (
<XCircle className="w-4 h-4 text-red-400" />
)}
<span>{readFileResult.success ? 'Success' : 'Failed'}</span>
</div>
<pre className="overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/30 p-2 rounded border border-white/5">
{readFileResult.success ? readFileResult.output : readFileResult.error}
</pre>
</div>
)}
</div>
</GlassCard>
{/* Write File Tool */}
<GlassCard
className="flex flex-col gap-4 bg-white/5 border-white/10"
data-testid="write-file-tool"
>
<div className="flex items-center gap-3 pb-2 border-b border-white/5">
<div className="p-2 rounded-lg bg-green-500/10">
<Pencil className="w-5 h-5 text-green-400" />
</div>
<div>
<h3 className="font-semibold text-foreground">Write File</h3>
<p className="text-xs text-muted-foreground">Write to filesystem</p>
</div>
</div>
<div className="space-y-4 flex-1">
<div className="space-y-2">
<Label htmlFor="write-file-path">File Path</Label>
<Input
id="write-file-path"
placeholder="/path/to/file.txt"
value={writeFilePath}
onChange={(e) => setWriteFilePath(e.target.value)}
data-testid="write-file-path-input"
className="bg-black/20 border-white/10 focus:border-green-500/50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="write-file-content">Content</Label>
<textarea
id="write-file-content"
placeholder="File content..."
value={writeFileContent}
onChange={(e) => setWriteFileContent(e.target.value)}
className="w-full min-h-[100px] px-3 py-2 text-sm rounded-md border border-white/10 bg-black/20 resize-y focus:outline-none focus:ring-1 focus:ring-green-500/50"
data-testid="write-file-content-input"
/>
</div>
<Button
onClick={handleWriteFile}
disabled={isWritingFile || !writeFilePath.trim() || !writeFileContent.trim()}
className="w-full bg-green-600/20 hover:bg-green-600/30 text-green-400 border border-green-500/30"
data-testid="write-file-button"
>
{isWritingFile ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Writing...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Write
</>
)}
</Button>
{/* Result */}
{writeFileResult && (
<div
className={cn(
'p-3 rounded-lg border text-xs',
writeFileResult.success
? 'bg-green-500/10 border-green-500/20 text-green-300'
: 'bg-red-500/10 border-red-500/20 text-red-300'
)}
data-testid="write-file-result"
>
<div className="flex items-center gap-2 mb-2 font-medium">
{writeFileResult.success ? (
<CheckCircle className="w-4 h-4 text-green-400" />
) : (
<XCircle className="w-4 h-4 text-red-400" />
)}
<span>{writeFileResult.success ? 'Success' : 'Failed'}</span>
</div>
<pre className="overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/30 p-2 rounded border border-white/5">
{writeFileResult.success ? writeFileResult.output : writeFileResult.error}
</pre>
</div>
)}
</div>
</GlassCard>
{/* Terminal Tool */}
<GlassCard
className="flex flex-col gap-4 bg-white/5 border-white/10"
data-testid="terminal-tool"
>
<div className="flex items-center gap-3 pb-2 border-b border-white/5">
<div className="p-2 rounded-lg bg-purple-500/10">
<Terminal className="w-5 h-5 text-purple-400" />
</div>
<div>
<h3 className="font-semibold text-foreground">Run Terminal</h3>
<p className="text-xs text-muted-foreground">Execute shell commands</p>
</div>
</div>
<div className="space-y-4 flex-1">
<div className="space-y-2">
<Label htmlFor="terminal-command">Command</Label>
<Input
id="terminal-command"
placeholder="ls -la"
value={terminalCommand}
onChange={(e) => setTerminalCommand(e.target.value)}
data-testid="terminal-command-input"
className="bg-black/20 border-white/10 focus:border-purple-500/50 font-mono text-sm"
/>
</div>
<Button
onClick={handleRunCommand}
disabled={isRunningCommand || !terminalCommand.trim()}
className="w-full bg-purple-600/20 hover:bg-purple-600/30 text-purple-400 border border-purple-500/30"
data-testid="run-terminal-button"
>
{isRunningCommand ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Running...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Command
</>
)}
</Button>
{/* Result */}
{terminalResult && (
<div
className={cn(
'p-3 rounded-lg border text-xs',
terminalResult.success
? 'bg-green-500/10 border-green-500/20 text-green-300'
: 'bg-red-500/10 border-red-500/20 text-red-300'
)}
data-testid="terminal-result"
>
<div className="flex items-center gap-2 mb-2 font-medium">
{terminalResult.success ? (
<CheckCircle className="w-4 h-4 text-green-400" />
) : (
<XCircle className="w-4 h-4 text-red-400" />
)}
<span>{terminalResult.success ? 'Success' : 'Failed'}</span>
</div>
<pre className="overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/30 p-2 rounded border border-white/5">
$ {terminalCommand}
{'\n'}
{terminalResult.success ? terminalResult.output : terminalResult.error}
</pre>
</div>
)}
</div>
</GlassCard>
</div>
{/* Tool Log Section */}
<GlassCard className="mt-6 bg-white/5 border-white/10" data-testid="tool-log">
<div className="flex flex-col gap-2">
<h3 className="font-semibold text-foreground">Tool Execution Log</h3>
<p className="text-sm text-muted-foreground">
View agent tool requests and responses
</p>
<div className="mt-4 space-y-2 text-sm bg-black/20 p-4 rounded-lg border border-white/5">
<p className="text-muted-foreground">
Open your browser&apos;s developer console to see detailed agent tool logs.
</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Read File - Agent requests file content from filesystem</li>
<li>Write File - Agent writes content to specified path</li>
<li>Run Terminal - Agent executes shell commands</li>
</ul>
</div>
</div>
</GlassCard>
</div>
</div>
</GlassPanel>
</div>
</div>
);

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,7 @@ import {
import { useAppStore, Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
import type { BacklogPlanResult } from '@automaker/types';
import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
import { RefreshCw } from 'lucide-react';
@@ -19,7 +17,8 @@ import { useAutoMode } from '@/hooks/use-auto-mode';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
// Board-view specific imports
import { BoardHeader } from './board-view/board-header';
import { TopHeader } from '@/components/layout/top-header';
// BoardHeader removed
import { BoardSearchBar } from './board-view/board-search-bar';
import { BoardControls } from './board-view/board-controls';
import { KanbanBoard } from './board-view/kanban-board';
@@ -27,7 +26,6 @@ import { GraphView } from './graph-view';
import {
AddFeatureDialog,
AgentOutputModal,
BacklogPlanDialog,
CompletedFeaturesModal,
ArchiveAllVerifiedDialog,
DeleteCompletedFeatureDialog,
@@ -60,6 +58,9 @@ import {
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
/** Delay before starting a newly created feature to allow state to settle */
const FEATURE_CREATION_SETTLE_DELAY_MS = 500;
export function BoardView() {
const {
currentProject,
@@ -107,9 +108,6 @@ export function BoardView() {
// State for viewing plan in read-only mode
const [viewPlanFeature, setViewPlanFeature] = useState<Feature | null>(null);
// State for spawn task mode
const [spawnParentFeature, setSpawnParentFeature] = useState<Feature | null>(null);
// Worktree dialog states
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = useState(false);
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false);
@@ -125,11 +123,6 @@ export function BoardView() {
} | null>(null);
const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0);
// Backlog plan dialog state
const [showPlanDialog, setShowPlanDialog] = useState(false);
const [pendingBacklogPlan, setPendingBacklogPlan] = useState<BacklogPlanResult | null>(null);
const [isGeneratingPlan, setIsGeneratingPlan] = useState(false);
// Follow-up state hook
const {
showFollowUpDialog,
@@ -271,9 +264,9 @@ export function BoardView() {
// Calculate unarchived card counts per branch
const branchCardCounts = useMemo(() => {
return hookFeatures.reduce(
(counts, feature) => {
(counts: Record<string, number>, feature) => {
if (feature.status !== 'completed') {
const branch = feature.branchName ?? 'main';
const branch = (feature.branchName as string) ?? 'main';
counts[branch] = (counts[branch] || 0) + 1;
}
return counts;
@@ -458,22 +451,23 @@ export function BoardView() {
requirePlanApproval: false,
};
// Capture existing feature IDs before adding
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData);
// Find the newly created feature by looking for an ID that wasn't in the original set
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
// Find the newly created feature and start it
// We need to wait a moment for the feature to be created
setTimeout(async () => {
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find(
(f) =>
f.branchName === worktree.branch &&
f.status === 'backlog' &&
f.description.includes(`PR #${prNumber}`)
);
if (newFeature) {
await handleStartImplementation(newFeature);
} else {
console.error('Could not find newly created feature to start it automatically.');
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
}
if (newFeature) {
await handleStartImplementation(newFeature);
}
}, FEATURE_CREATION_SETTLE_DELAY_MS);
},
[handleAddFeature, handleStartImplementation, defaultSkipTests]
);
@@ -499,49 +493,26 @@ export function BoardView() {
requirePlanApproval: false,
};
// Capture existing feature IDs before adding
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData);
// Find the newly created feature by looking for an ID that wasn't in the original set
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
// Find the newly created feature and start it
setTimeout(async () => {
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find(
(f) =>
f.branchName === worktree.branch &&
f.status === 'backlog' &&
f.description.includes('Pull latest from origin/main')
);
if (newFeature) {
await handleStartImplementation(newFeature);
} else {
console.error('Could not find newly created feature to start it automatically.');
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
}
if (newFeature) {
await handleStartImplementation(newFeature);
}
}, FEATURE_CREATION_SETTLE_DELAY_MS);
},
[handleAddFeature, handleStartImplementation, defaultSkipTests]
);
// Handler for "Make" button - creates a feature and immediately starts it
const handleAddAndStartFeature = useCallback(
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
// Capture existing feature IDs before adding
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData);
// Find the newly created feature by looking for an ID that wasn't in the original set
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
if (newFeature) {
await handleStartImplementation(newFeature);
} else {
console.error('Could not find newly created feature to start it automatically.');
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
}
},
[handleAddFeature, handleStartImplementation]
);
// Client-side auto mode: periodically check for backlog items and move them to in-progress
// Use a ref to track the latest auto mode state so async operations always check the current value
const autoModeRunningRef = useRef(autoMode.isRunning);
@@ -605,37 +576,6 @@ export function BoardView() {
return unsubscribe;
}, [currentProject]);
// Listen for backlog plan events (for background generation)
useEffect(() => {
const api = getElectronAPI();
if (!api?.backlogPlan) return;
const unsubscribe = api.backlogPlan.onEvent(
(event: { type: string; result?: BacklogPlanResult; error?: string }) => {
if (event.type === 'backlog_plan_complete') {
setIsGeneratingPlan(false);
if (event.result && event.result.changes?.length > 0) {
setPendingBacklogPlan(event.result);
toast.success('Plan ready! Click to review.', {
duration: 10000,
action: {
label: 'Review',
onClick: () => setShowPlanDialog(true),
},
});
} else {
toast.info('No changes generated. Try again with a different prompt.');
}
} else if (event.type === 'backlog_plan_error') {
setIsGeneratingPlan(false);
toast.error(`Plan generation failed: ${event.error}`);
}
}
);
return unsubscribe;
}, []);
useEffect(() => {
if (!autoMode.isRunning || !currentProject) {
return;
@@ -979,28 +919,8 @@ export function BoardView() {
data-testid="board-view"
>
{/* Header */}
<BoardHeader
projectName={currentProject.name}
maxConcurrency={maxConcurrency}
runningAgentsCount={runningAutoTasks.length}
onConcurrencyChange={setMaxConcurrency}
isAutoModeRunning={autoMode.isRunning}
onAutoModeToggle={(enabled) => {
if (enabled) {
autoMode.start();
} else {
autoMode.stop();
}
}}
onAddFeature={() => setShowAddDialog(true)}
onOpenPlanDialog={() => setShowPlanDialog(true)}
addFeatureShortcut={{
key: shortcuts.addFeature,
action: () => setShowAddDialog(true),
description: 'Add new feature',
}}
isMounted={isMounted}
/>
{/* Top Header */}
<TopHeader />
{/* Worktree Panel */}
<WorktreePanel
@@ -1083,10 +1003,6 @@ export function BoardView() {
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
shortcuts={shortcuts}
@@ -1109,12 +1025,6 @@ export function BoardView() {
onStartTask={handleStartImplementation}
onStopTask={handleForceStopFeature}
onResumeTask={handleResumeFeature}
onUpdateFeature={updateFeature}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
/>
)}
</div>
@@ -1149,14 +1059,8 @@ export function BoardView() {
{/* Add Feature Dialog */}
<AddFeatureDialog
open={showAddDialog}
onOpenChange={(open) => {
setShowAddDialog(open);
if (!open) {
setSpawnParentFeature(null);
}
}}
onOpenChange={setShowAddDialog}
onAdd={handleAddFeature}
onAddAndStart={handleAddAndStartFeature}
categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
@@ -1166,8 +1070,6 @@ export function BoardView() {
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
parentFeature={spawnParentFeature}
allFeatures={hookFeatures}
/>
{/* Edit Feature Dialog */}
@@ -1232,18 +1134,6 @@ export function BoardView() {
setIsGenerating={setIsGeneratingSuggestions}
/>
{/* Backlog Plan Dialog */}
<BacklogPlanDialog
open={showPlanDialog}
onClose={() => setShowPlanDialog(false)}
projectPath={currentProject.path}
onPlanApplied={loadFeatures}
pendingPlanResult={pendingBacklogPlan}
setPendingPlanResult={setPendingBacklogPlan}
isGeneratingPlan={isGeneratingPlan}
setIsGeneratingPlan={setIsGeneratingPlan}
/>
{/* Plan Approval Dialog */}
<PlanApprovalDialog
open={pendingPlanApproval !== null}

View File

@@ -1,9 +1,8 @@
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Plus, Bot, Wand2 } from 'lucide-react';
import { Plus, Bot } from 'lucide-react';
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
import { useAppStore } from '@/store/app-store';
@@ -16,7 +15,6 @@ interface BoardHeaderProps {
isAutoModeRunning: boolean;
onAutoModeToggle: (enabled: boolean) => void;
onAddFeature: () => void;
onOpenPlanDialog: () => void;
addFeatureShortcut: KeyboardShortcut;
isMounted: boolean;
}
@@ -29,7 +27,6 @@ export function BoardHeader({
isAutoModeRunning,
onAutoModeToggle,
onAddFeature,
onOpenPlanDialog,
addFeatureShortcut,
isMounted,
}: BoardHeaderProps) {
@@ -92,16 +89,6 @@ export function BoardHeader({
</div>
)}
<Button
size="sm"
variant="outline"
onClick={onOpenPlanDialog}
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 mr-2" />
Plan
</Button>
<HotkeyButton
size="sm"
onClick={onAddFeature}

View File

@@ -19,7 +19,6 @@ import {
ChevronDown,
ChevronUp,
Cpu,
GitFork,
} from 'lucide-react';
import { CountUpTimer } from '@/components/ui/count-up-timer';
import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
@@ -32,7 +31,7 @@ interface CardHeaderProps {
onEdit: () => void;
onDelete: () => void;
onViewOutput?: () => void;
onSpawnTask?: () => void;
hideActions?: boolean;
}
export function CardHeaderSection({
@@ -42,7 +41,7 @@ export function CardHeaderSection({
onEdit,
onDelete,
onViewOutput,
onSpawnTask,
hideActions,
}: CardHeaderProps) {
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
@@ -95,17 +94,6 @@ export function CardHeaderSection({
<Edit className="w-3 h-3 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
data-testid={`spawn-running-${feature.id}`}
className="text-xs"
>
<GitFork className="w-3 h-3 mr-2" />
Spawn Sub-Task
</DropdownMenuItem>
{/* Model info in dropdown */}
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1">
@@ -120,21 +108,7 @@ export function CardHeaderSection({
{/* Backlog header */}
{!isCurrentAutoTask && feature.status === 'backlog' && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`spawn-backlog-${feature.id}`}
title="Spawn Sub-Task"
>
<GitFork className="w-4 h-4" />
</Button>
<div className="absolute top-2 right-2">
<Button
variant="ghost"
size="sm"
@@ -169,22 +143,6 @@ export function CardHeaderSection({
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`spawn-${
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Spawn Sub-Task"
>
<GitFork className="w-4 h-4" />
</Button>
{onViewOutput && (
<Button
variant="ghost"
@@ -273,17 +231,6 @@ export function CardHeaderSection({
View Logs
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
data-testid={`spawn-feature-${feature.id}`}
className="text-xs"
>
<GitFork className="w-3 h-3 mr-2" />
Spawn Sub-Task
</DropdownMenuItem>
{/* Model info in dropdown */}
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1">

View File

@@ -2,7 +2,7 @@ import React, { memo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import { GlassCard } from '@/components/ui/glass-card';
import { Feature, useAppStore } from '@/store/app-store';
import { CardBadges, PriorityBadges } from './card-badges';
import { CardHeaderSection } from './card-header';
@@ -25,7 +25,6 @@ interface KanbanCardProps {
onComplete?: () => void;
onViewPlan?: () => void;
onApprovePlan?: () => void;
onSpawnTask?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
shortcutKey?: string;
@@ -52,16 +51,11 @@ export const KanbanCard = memo(function KanbanCard({
onComplete,
onViewPlan,
onApprovePlan,
onSpawnTask,
hasContext,
isCurrentAutoTask,
shortcutKey,
contextContent,
summary,
opacity = 100,
glassmorphism = true,
cardBorderEnabled = true,
cardBorderOpacity = 100,
}: KanbanCardProps) {
const { useWorktrees } = useAppStore();
@@ -70,6 +64,7 @@ export const KanbanCard = memo(function KanbanCard({
feature.status === 'waiting_approval' ||
feature.status === 'verified' ||
(feature.status === 'in_progress' && !isCurrentAutoTask);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: feature.id,
disabled: !isDraggable,
@@ -81,36 +76,15 @@ export const KanbanCard = memo(function KanbanCard({
opacity: isDragging ? 0.5 : undefined,
};
const borderStyle: React.CSSProperties = { ...style };
if (!cardBorderEnabled) {
(borderStyle as Record<string, string>).borderWidth = '0px';
(borderStyle as Record<string, string>).borderColor = 'transparent';
} else if (cardBorderOpacity !== 100) {
(borderStyle as Record<string, string>).borderWidth = '1px';
(borderStyle as Record<string, string>).borderColor =
`color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
}
const cardElement = (
<Card
<GlassCard
ref={setNodeRef}
style={isCurrentAutoTask ? style : borderStyle}
variant={isCurrentAutoTask ? 'active-blue' : 'default'}
style={style}
className={cn(
'cursor-grab active:cursor-grabbing relative kanban-card-content select-none',
'transition-all duration-200 ease-out',
// Premium shadow system
'shadow-sm hover:shadow-md hover:shadow-black/10',
// Subtle lift on hover
'hover:-translate-y-0.5',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity === 100 && 'border-border/50',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity !== 100 && 'border',
!isDragging && 'bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
'group relative min-h-[140px] flex flex-col',
isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1',
// Error state - using CSS variable
feature.error &&
!isCurrentAutoTask &&
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
feature.error && 'border-brand-red border-2 shadow-glow-red',
!isDraggable && 'cursor-default'
)}
data-testid={`kanban-card-${feature.id}`}
@@ -118,52 +92,79 @@ export const KanbanCard = memo(function KanbanCard({
{...attributes}
{...(isDraggable ? listeners : {})}
>
{/* Background overlay with opacity */}
{!isDragging && (
<div
className={cn(
'absolute inset-0 rounded-xl bg-card -z-10',
glassmorphism && 'backdrop-blur-sm'
)}
style={{ opacity: opacity / 100 }}
/>
)}
{/* Status Badges Row */}
<CardBadges feature={feature} />
{/* Category row */}
<div className="px-3 pt-4">
<span className="text-[11px] text-muted-foreground/70 font-medium">{feature.category}</span>
{/* Top Row: Empty space + Delete (on hover) */}
<div className="flex justify-between items-start mb-2 h-5">
<div className="flex flex-wrap gap-1">
<CardBadges feature={feature} />
</div>
{/* Delete/Actions on hover */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-2">
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-slate-600 hover:text-brand-red transition"
>
<i data-lucide="trash" className="w-3.5 h-3.5"></i>
{/* Fallback to SVG if i tag fails */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-trash w-3.5 h-3.5"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
</button>
</div>
</div>
{/* Priority and Manual Verification badges */}
<PriorityBadges feature={feature} />
{/* Description */}
<div className="mb-4">
<CardHeaderSection
feature={feature}
isDraggable={isDraggable}
isCurrentAutoTask={!!isCurrentAutoTask}
onEdit={onEdit}
onDelete={onDelete}
onViewOutput={onViewOutput}
hideActions={true} // We handle actions via hover/bottom bar
/>
</div>
{/* Card Header */}
<CardHeaderSection
feature={feature}
isDraggable={isDraggable}
isCurrentAutoTask={!!isCurrentAutoTask}
onEdit={onEdit}
onDelete={onDelete}
onViewOutput={onViewOutput}
onSpawnTask={onSpawnTask}
/>
{/* Middle Grid: Priority, etc */}
<div className="flex items-center justify-between mb-4">
<PriorityBadges feature={feature} />
<div className="flex items-center gap-2">
{/* Category / Model info */}
<span className="text-[10px] text-brand-cyan font-mono">
{feature.model || 'Opus 4.2'}
</span>
</div>
</div>
<CardContent className="px-3 pt-0 pb-0">
{/* Content Sections */}
{/* Content & Agent Info */}
<div className="mb-2">
<CardContentSections feature={feature} useWorktrees={useWorktrees} />
{/* Agent Info Panel */}
<AgentInfoPanel
feature={feature}
contextContent={contextContent}
summary={summary}
isCurrentAutoTask={isCurrentAutoTask}
/>
</div>
{/* Actions */}
{/* Buttons Grid */}
<div className="mt-auto pt-2">
<CardActions
feature={feature}
isCurrentAutoTask={!!isCurrentAutoTask}
@@ -181,14 +182,9 @@ export const KanbanCard = memo(function KanbanCard({
onViewPlan={onViewPlan}
onApprovePlan={onApprovePlan}
/>
</CardContent>
</Card>
</div>
</GlassCard>
);
// Wrap with animated border when in progress
if (isCurrentAutoTask) {
return <div className="animated-border-wrapper">{cardElement}</div>;
}
return cardElement;
});

View File

@@ -2,31 +2,30 @@ import { memo } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import type { ReactNode } from 'react';
import { GlassPanel } from '@/components/ui/glass-panel';
interface KanbanColumnProps {
id: string;
title: string;
colorClass: string;
accent: 'cyan' | 'blue' | 'orange' | 'green' | 'none';
count: number;
children: ReactNode;
headerAction?: ReactNode;
width?: number;
// Legacy props ignored or used for compatibility
colorClass?: string;
opacity?: number;
showBorder?: boolean;
hideScrollbar?: boolean;
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
width?: number;
}
export const KanbanColumn = memo(function KanbanColumn({
id,
title,
colorClass,
accent,
count,
children,
headerAction,
opacity = 100,
showBorder = true,
hideScrollbar = false,
width,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
@@ -35,60 +34,63 @@ export const KanbanColumn = memo(function KanbanColumn({
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
return (
<div
<GlassPanel
ref={setNodeRef}
accent={accent}
className={cn(
'relative flex flex-col h-full rounded-xl',
// Only transition ring/shadow for drag-over effect, not width
'transition-[box-shadow,ring] duration-200',
!width && 'w-72', // Only apply w-72 if no custom width
showBorder && 'border border-border/60',
'relative flex flex-col h-full min-w-[300px] transition-[box-shadow,ring] duration-200',
!width && 'w-72',
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
)}
style={widthStyle}
data-testid={`kanban-column-${id}`}
>
{/* Background layer with opacity */}
<div
className={cn(
'absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200',
isOver ? 'bg-accent/80' : 'bg-card/80'
)}
style={{ opacity: opacity / 100 }}
/>
{/* Subtle Glow Top (Only for Blue/Orange/Green to match design, could make generic) */}
{(accent === 'blue' || accent === 'orange' || accent === 'green') && (
<div
className={cn(
'absolute top-0 left-0 w-full h-32 bg-gradient-to-b pointer-events-none rounded-t-2xl',
accent === 'blue' && 'from-brand-blue/10 to-transparent',
accent === 'orange' && 'from-brand-orange/10 to-transparent',
accent === 'green' && 'from-brand-green/10 to-transparent'
)}
></div>
)}
{/* Column Header */}
<div
className={cn(
'relative z-10 flex items-center gap-3 px-3 py-2.5',
showBorder && 'border-b border-border/40'
)}
>
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', colorClass)} />
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
{headerAction}
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
<div className="flex items-center justify-between p-4 border-b border-white/5 relative z-10">
<div className="flex items-center gap-2">
{/* Status Dot */}
<div
className={cn(
'w-2 h-2 rounded-full',
accent === 'cyan' && 'bg-slate-400', // Backlog is neutral in design
accent === 'blue' && 'bg-brand-orange shadow-glow-orange', // In Progress has orange dot in design
accent === 'orange' && 'bg-brand-orange shadow-glow-orange',
accent === 'green' && 'bg-brand-green shadow-glow-green'
)}
></div>
<span className="font-bold text-slate-200 text-sm">{title}</span>
{/* Action container (like "Make") */}
{headerAction}
</div>
{/* Count Badge */}
<span className="text-[10px] bg-dark-700 text-slate-400 px-2 py-0.5 rounded border border-white/5 font-medium">
{count}
</span>
</div>
{/* Column Content */}
<div
className={cn(
'relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5',
hideScrollbar &&
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
// Smooth scrolling
'scroll-smooth'
)}
>
<div className="flex-1 overflow-y-auto p-3 space-y-3 custom-scrollbar relative z-10">
{children}
</div>
{/* Drop zone indicator when dragging over */}
{isOver && (
<div className="absolute inset-0 rounded-xl bg-primary/5 pointer-events-none z-5 border-2 border-dashed border-primary/20" />
<div className="absolute inset-0 rounded-2xl bg-white/5 pointer-events-none z-20 border-2 border-dashed border-white/10" />
)}
</div>
</GlassPanel>
);
});

View File

@@ -2,21 +2,29 @@ import { Feature } from '@/store/app-store';
export type ColumnId = Feature['status'];
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' },
export const COLUMNS: {
id: ColumnId;
title: string;
colorClass: string;
accent: 'cyan' | 'blue' | 'orange' | 'green';
}[] = [
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]', accent: 'cyan' },
{
id: 'in_progress',
title: 'In Progress',
colorClass: 'bg-[var(--status-in-progress)]',
accent: 'blue',
},
{
id: 'waiting_approval',
title: 'Waiting Approval',
colorClass: 'bg-[var(--status-waiting)]',
accent: 'orange',
},
{
id: 'verified',
title: 'Verified',
colorClass: 'bg-[var(--status-success)]',
accent: 'green',
},
];

View File

@@ -23,9 +23,9 @@ import {
MessageSquare,
Settings2,
SlidersHorizontal,
FlaskConical,
Sparkles,
ChevronDown,
Play,
} from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
@@ -37,7 +37,6 @@ import {
FeatureImage,
AIProfile,
PlanningMode,
Feature,
} from '@/store/app-store';
import {
ModelSelector,
@@ -47,7 +46,6 @@ import {
PrioritySelector,
BranchSelector,
PlanningModeSelector,
AncestorContextSection,
} from '../shared';
import {
DropdownMenu,
@@ -56,34 +54,25 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useNavigate } from '@tanstack/react-router';
import {
getAncestors,
formatAncestorContextForPrompt,
type AncestorContext,
} from '@automaker/dependency-resolver';
type FeatureData = {
title: string;
category: string;
description: string;
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string; // Can be empty string to use current branch
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
dependencies?: string[];
};
interface AddFeatureDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (feature: FeatureData) => void;
onAddAndStart?: (feature: FeatureData) => void;
onAdd: (feature: {
title: string;
category: string;
description: string;
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string; // Can be empty string to use current branch
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
}) => void;
categorySuggestions: string[];
branchSuggestions: string[];
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
@@ -93,16 +82,12 @@ interface AddFeatureDialogProps {
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
// Spawn task mode props
parentFeature?: Feature | null;
allFeatures?: Feature[];
}
export function AddFeatureDialog({
open,
onOpenChange,
onAdd,
onAddAndStart,
categorySuggestions,
branchSuggestions,
branchCardCounts,
@@ -112,10 +97,7 @@ export function AddFeatureDialog({
isMaximized,
showProfilesOnly,
aiProfiles,
parentFeature = null,
allFeatures = [],
}: AddFeatureDialogProps) {
const isSpawnMode = !!parentFeature;
const navigate = useNavigate();
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
const [newFeature, setNewFeature] = useState({
@@ -143,10 +125,6 @@ export function AddFeatureDialog({
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
// Spawn mode state
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
// Get enhancement model, planning mode defaults, and worktrees setting from store
const {
enhancementModel,
@@ -175,17 +153,6 @@ export function AddFeatureDialog({
setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
// Initialize ancestors for spawn mode
if (parentFeature) {
const ancestorList = getAncestors(parentFeature, allFeatures);
setAncestors(ancestorList);
// Only select parent by default - ancestors are optional context
setSelectedAncestorIds(new Set([parentFeature.id]));
} else {
setAncestors([]);
setSelectedAncestorIds(new Set());
}
}
}, [
open,
@@ -195,20 +162,18 @@ export function AddFeatureDialog({
defaultRequirePlanApproval,
defaultAIProfileId,
aiProfiles,
parentFeature,
allFeatures,
]);
const buildFeatureData = (): FeatureData | null => {
const handleAdd = () => {
if (!newFeature.description.trim()) {
setDescriptionError(true);
return null;
return;
}
// Validate branch selection when "other branch" is selected
if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
toast.error('Please select a branch name');
return null;
return;
}
const category = newFeature.category || 'Uncategorized';
@@ -222,34 +187,10 @@ export function AddFeatureDialog({
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
const finalBranchName = useCurrentBranch ? currentBranch || '' : newFeature.branchName || '';
// Build final description - prepend ancestor context in spawn mode
let finalDescription = newFeature.description;
if (isSpawnMode && parentFeature && selectedAncestorIds.size > 0) {
// Create parent context as an AncestorContext
const parentContext: AncestorContext = {
id: parentFeature.id,
title: parentFeature.title,
description: parentFeature.description,
spec: parentFeature.spec,
summary: parentFeature.summary,
depth: -1,
};
const allAncestorsWithParent = [parentContext, ...ancestors];
const contextText = formatAncestorContextForPrompt(
allAncestorsWithParent,
selectedAncestorIds
);
if (contextText) {
finalDescription = `${contextText}\n\n---\n\n## Task Description\n\n${newFeature.description}`;
}
}
return {
onAdd({
title: newFeature.title,
category,
description: finalDescription,
description: newFeature.description,
images: newFeature.images,
imagePaths: newFeature.imagePaths,
textFilePaths: newFeature.textFilePaths,
@@ -260,12 +201,9 @@ export function AddFeatureDialog({
priority: newFeature.priority,
planningMode,
requirePlanApproval,
// In spawn mode, automatically add parent as dependency
dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined,
};
};
});
const resetForm = () => {
// Reset form
setNewFeature({
title: '',
category: '',
@@ -288,20 +226,6 @@ export function AddFeatureDialog({
onOpenChange(false);
};
const handleAction = (actionFn?: (data: FeatureData) => void) => {
if (!actionFn) return;
const featureData = buildFeatureData();
if (!featureData) return;
actionFn(featureData);
resetForm();
};
const handleAdd = () => handleAction(onAdd);
const handleAddAndStart = () => handleAction(onAddAndStart);
const handleDialogClose = (open: boolean) => {
onOpenChange(open);
if (!open) {
@@ -375,12 +299,8 @@ export function AddFeatureDialog({
}}
>
<DialogHeader>
<DialogTitle>{isSpawnMode ? 'Spawn Sub-Task' : 'Add New Feature'}</DialogTitle>
<DialogDescription>
{isSpawnMode
? `Create a sub-task that depends on "${parentFeature?.title || parentFeature?.description.slice(0, 50)}..."`
: 'Create a new feature card for the Kanban board.'}
</DialogDescription>
<DialogTitle>Add New Feature</DialogTitle>
<DialogDescription>Create a new feature card for the Kanban board.</DialogDescription>
</DialogHeader>
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
<TabsList className="w-full grid grid-cols-3 mb-4">
@@ -400,22 +320,6 @@ export function AddFeatureDialog({
{/* Prompt Tab */}
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
{/* Ancestor Context Section - only in spawn mode */}
{isSpawnMode && parentFeature && (
<AncestorContextSection
parentFeature={{
id: parentFeature.id,
title: parentFeature.title,
description: parentFeature.description,
spec: parentFeature.spec,
summary: parentFeature.summary,
}}
ancestors={ancestors}
selectedAncestorIds={selectedAncestorIds}
onSelectionChange={setSelectedAncestorIds}
/>
)}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<DescriptionImageDropZone
@@ -601,17 +505,6 @@ export function AddFeatureDialog({
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
{onAddAndStart && (
<Button
onClick={handleAddAndStart}
variant="secondary"
data-testid="confirm-add-and-start-feature"
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
>
<Play className="w-4 h-4 mr-2" />
Make
</Button>
)}
<HotkeyButton
onClick={handleAdd}
hotkey={{ key: 'Enter', cmdCtrl: true }}
@@ -619,7 +512,7 @@ export function AddFeatureDialog({
data-testid="confirm-add-feature"
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
>
{isSpawnMode ? 'Spawn Task' : 'Add Feature'}
Add Feature
</HotkeyButton>
</DialogFooter>
</DialogContent>

View File

@@ -45,7 +45,7 @@ export function ArchiveAllVerifiedDialog({
</Button>
<Button variant="default" onClick={onConfirm} data-testid="confirm-archive-all-verified">
<Archive className="w-4 h-4 mr-2" />
Complete All
Archive All
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,418 +0,0 @@
import { useEffect, useState, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import {
Loader2,
Wand2,
Check,
Plus,
Pencil,
Trash2,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { BacklogPlanResult, BacklogChange } from '@automaker/types';
interface BacklogPlanDialogProps {
open: boolean;
onClose: () => void;
projectPath: string;
onPlanApplied?: () => void;
// Props for background generation
pendingPlanResult: BacklogPlanResult | null;
setPendingPlanResult: (result: BacklogPlanResult | null) => void;
isGeneratingPlan: boolean;
setIsGeneratingPlan: (generating: boolean) => void;
}
type DialogMode = 'input' | 'review' | 'applying';
export function BacklogPlanDialog({
open,
onClose,
projectPath,
onPlanApplied,
pendingPlanResult,
setPendingPlanResult,
isGeneratingPlan,
setIsGeneratingPlan,
}: BacklogPlanDialogProps) {
const [mode, setMode] = useState<DialogMode>('input');
const [prompt, setPrompt] = useState('');
const [expandedChanges, setExpandedChanges] = useState<Set<number>>(new Set());
const [selectedChanges, setSelectedChanges] = useState<Set<number>>(new Set());
// Set mode based on whether we have a pending result
useEffect(() => {
if (open) {
if (pendingPlanResult) {
setMode('review');
// Select all changes by default
setSelectedChanges(new Set(pendingPlanResult.changes.map((_, i) => i)));
setExpandedChanges(new Set());
} else {
setMode('input');
}
}
}, [open, pendingPlanResult]);
const handleGenerate = useCallback(async () => {
if (!prompt.trim()) {
toast.error('Please enter a prompt describing the changes you want');
return;
}
const api = getElectronAPI();
if (!api?.backlogPlan) {
toast.error('API not available');
return;
}
// Start generation in background
setIsGeneratingPlan(true);
const result = await api.backlogPlan.generate(projectPath, prompt);
if (!result.success) {
setIsGeneratingPlan(false);
toast.error(result.error || 'Failed to start plan generation');
return;
}
// Show toast and close dialog - generation runs in background
toast.info('Generating plan... This will be ready soon!', {
duration: 3000,
});
setPrompt('');
onClose();
}, [projectPath, prompt, setIsGeneratingPlan, onClose]);
const handleApply = useCallback(async () => {
if (!pendingPlanResult) return;
// Filter to only selected changes
const selectedChangesList = pendingPlanResult.changes.filter((_, index) =>
selectedChanges.has(index)
);
if (selectedChangesList.length === 0) {
toast.error('Please select at least one change to apply');
return;
}
const api = getElectronAPI();
if (!api?.backlogPlan) {
toast.error('API not available');
return;
}
setMode('applying');
// Create a filtered plan result with only selected changes
const filteredPlanResult: BacklogPlanResult = {
...pendingPlanResult,
changes: selectedChangesList,
// Filter dependency updates to only include those for selected features
dependencyUpdates:
pendingPlanResult.dependencyUpdates?.filter((update) => {
const isDeleting = selectedChangesList.some(
(c) => c.type === 'delete' && c.featureId === update.featureId
);
return !isDeleting;
}) || [],
};
const result = await api.backlogPlan.apply(projectPath, filteredPlanResult);
if (result.success) {
toast.success(`Applied ${result.appliedChanges?.length || 0} changes`);
setPendingPlanResult(null);
onPlanApplied?.();
onClose();
} else {
toast.error(result.error || 'Failed to apply plan');
setMode('review');
}
}, [
projectPath,
pendingPlanResult,
selectedChanges,
setPendingPlanResult,
onPlanApplied,
onClose,
]);
const handleDiscard = useCallback(() => {
setPendingPlanResult(null);
setMode('input');
}, [setPendingPlanResult]);
const toggleChangeExpanded = (index: number) => {
setExpandedChanges((prev) => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
};
const toggleChangeSelected = (index: number) => {
setSelectedChanges((prev) => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
};
const toggleAllChanges = () => {
if (!pendingPlanResult) return;
if (selectedChanges.size === pendingPlanResult.changes.length) {
setSelectedChanges(new Set());
} else {
setSelectedChanges(new Set(pendingPlanResult.changes.map((_, i) => i)));
}
};
const getChangeIcon = (type: BacklogChange['type']) => {
switch (type) {
case 'add':
return <Plus className="w-4 h-4 text-green-500" />;
case 'update':
return <Pencil className="w-4 h-4 text-yellow-500" />;
case 'delete':
return <Trash2 className="w-4 h-4 text-red-500" />;
}
};
const getChangeLabel = (change: BacklogChange) => {
switch (change.type) {
case 'add':
return change.feature?.title || 'New Feature';
case 'update':
return `Update: ${change.featureId}`;
case 'delete':
return `Delete: ${change.featureId}`;
}
};
const renderContent = () => {
switch (mode) {
case 'input':
return (
<div className="space-y-4">
<div className="text-sm text-muted-foreground">
Describe the changes you want to make to your backlog. The AI will analyze your
current features and propose additions, updates, or deletions.
</div>
<Textarea
placeholder="e.g., Add authentication features with login, signup, and password reset. Also add a dashboard feature that depends on authentication."
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[150px] resize-none"
autoFocus
/>
<div className="text-xs text-muted-foreground">
The AI will automatically handle dependency graph updates when adding or removing
features.
</div>
{isGeneratingPlan && (
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 rounded-lg p-3">
<Loader2 className="w-4 h-4 animate-spin" />A plan is currently being generated in
the background...
</div>
)}
</div>
);
case 'review':
if (!pendingPlanResult) return null;
const additions = pendingPlanResult.changes.filter((c) => c.type === 'add');
const updates = pendingPlanResult.changes.filter((c) => c.type === 'update');
const deletions = pendingPlanResult.changes.filter((c) => c.type === 'delete');
const allSelected = selectedChanges.size === pendingPlanResult.changes.length;
const someSelected = selectedChanges.size > 0 && !allSelected;
return (
<div className="space-y-4">
{/* Summary */}
<div className="rounded-lg border bg-muted/30 p-4">
<h4 className="font-medium mb-2">Summary</h4>
<p className="text-sm text-muted-foreground">{pendingPlanResult.summary}</p>
</div>
{/* Stats */}
<div className="flex gap-4 text-sm">
{additions.length > 0 && (
<span className="flex items-center gap-1 text-green-600">
<Plus className="w-4 h-4" /> {additions.length} additions
</span>
)}
{updates.length > 0 && (
<span className="flex items-center gap-1 text-yellow-600">
<Pencil className="w-4 h-4" /> {updates.length} updates
</span>
)}
{deletions.length > 0 && (
<span className="flex items-center gap-1 text-red-600">
<Trash2 className="w-4 h-4" /> {deletions.length} deletions
</span>
)}
</div>
{/* Select all */}
<div className="flex items-center gap-2 pb-2 border-b">
<Checkbox
id="select-all"
checked={allSelected}
// @ts-expect-error - indeterminate is valid but not in types
indeterminate={someSelected}
onCheckedChange={toggleAllChanges}
/>
<label htmlFor="select-all" className="text-sm font-medium cursor-pointer">
{allSelected ? 'Deselect all' : 'Select all'} ({selectedChanges.size}/
{pendingPlanResult.changes.length})
</label>
</div>
{/* Changes list */}
<div className="max-h-[300px] overflow-y-auto space-y-2">
{pendingPlanResult.changes.map((change, index) => (
<div
key={index}
className={cn(
'rounded-lg border p-3',
change.type === 'add' && 'border-green-500/30 bg-green-500/5',
change.type === 'update' && 'border-yellow-500/30 bg-yellow-500/5',
change.type === 'delete' && 'border-red-500/30 bg-red-500/5',
!selectedChanges.has(index) && 'opacity-50'
)}
>
<div className="flex items-center gap-2">
<Checkbox
checked={selectedChanges.has(index)}
onCheckedChange={() => toggleChangeSelected(index)}
/>
<button
className="flex-1 flex items-center gap-2 text-left"
onClick={() => toggleChangeExpanded(index)}
>
{expandedChanges.has(index) ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
{getChangeIcon(change.type)}
<span className="font-medium text-sm">{getChangeLabel(change)}</span>
</button>
</div>
{expandedChanges.has(index) && (
<div className="mt-3 pl-10 space-y-2 text-sm">
<p className="text-muted-foreground">{change.reason}</p>
{change.feature && (
<div className="rounded bg-background/50 p-2 text-xs font-mono">
{change.feature.description && (
<p className="text-foreground">{change.feature.description}</p>
)}
{change.feature.dependencies &&
change.feature.dependencies.length > 0 && (
<p className="text-muted-foreground mt-1">
Dependencies: {change.feature.dependencies.join(', ')}
</p>
)}
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
);
case 'applying':
return (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary mb-4" />
<p className="text-muted-foreground">Applying changes...</p>
</div>
);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Wand2 className="w-5 h-5 text-primary" />
{mode === 'review' ? 'Review Plan' : 'Plan Backlog Changes'}
</DialogTitle>
<DialogDescription>
{mode === 'review'
? 'Select which changes to apply to your backlog'
: 'Use AI to add, update, or remove features from your backlog'}
</DialogDescription>
</DialogHeader>
<div className="py-4">{renderContent()}</div>
<DialogFooter>
{mode === 'input' && (
<>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleGenerate} disabled={!prompt.trim() || isGeneratingPlan}>
{isGeneratingPlan ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Generating...
</>
) : (
<>
<Wand2 className="w-4 h-4 mr-2" />
Generate Plan
</>
)}
</Button>
</>
)}
{mode === 'review' && (
<>
<Button variant="outline" onClick={handleDiscard}>
Discard
</Button>
<Button variant="outline" onClick={onClose}>
Review Later
</Button>
<Button onClick={handleApply} disabled={selectedChanges.size === 0}>
<Check className="w-4 h-4 mr-2" />
Apply {selectedChanges.size} Change{selectedChanges.size !== 1 ? 's' : ''}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -21,8 +21,6 @@ import {
RefreshCw,
Shield,
Zap,
List,
FileText,
} from 'lucide-react';
import {
getElectronAPI,
@@ -32,7 +30,6 @@ import {
} from '@/lib/electron';
import { useAppStore, Feature } from '@/store/app-store';
import { toast } from 'sonner';
import { LogViewer } from '@/components/ui/log-viewer';
interface FeatureSuggestionsDialogProps {
open: boolean;
@@ -95,7 +92,6 @@ export function FeatureSuggestionsDialog({
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [isImporting, setIsImporting] = useState(false);
const [currentSuggestionType, setCurrentSuggestionType] = useState<SuggestionType | null>(null);
const [viewMode, setViewMode] = useState<'parsed' | 'raw'>('parsed');
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
@@ -127,9 +123,7 @@ export function FeatureSuggestionsDialog({
setProgress((prev) => [...prev, event.content || '']);
} else if (event.type === 'suggestions_tool') {
const toolName = event.tool || 'Unknown Tool';
const toolInput = event.input ? JSON.stringify(event.input, null, 2) : '';
const formattedTool = `\n🔧 Tool: ${toolName}\n${toolInput ? `Input: ${toolInput}\n` : ''}`;
setProgress((prev) => [...prev, formattedTool]);
setProgress((prev) => [...prev, `Using tool: ${toolName}\n`]);
} else if (event.type === 'suggestions_complete') {
setIsGenerating(false);
if (event.suggestions && event.suggestions.length > 0) {
@@ -251,7 +245,6 @@ export function FeatureSuggestionsDialog({
id: `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
category: s.category,
description: s.description,
steps: [], // Required empty steps array for new features
status: 'backlog' as const,
skipTests: true, // As specified, testing mode true
priority: s.priority, // Preserve priority from suggestion
@@ -304,7 +297,7 @@ export function FeatureSuggestionsDialog({
setCurrentSuggestionType(null);
}, [setSuggestions]);
const hasStarted = isGenerating || progress.length > 0 || suggestions.length > 0;
const hasStarted = progress.length > 0 || suggestions.length > 0;
const hasSuggestions = suggestions.length > 0;
const currentConfig = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType] : null;
@@ -378,56 +371,19 @@ export function FeatureSuggestionsDialog({
<Loader2 className="w-4 h-4 animate-spin" />
Analyzing project...
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<button
onClick={() => setViewMode('parsed')}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-all ${
viewMode === 'parsed'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-parsed"
>
<List className="w-3 h-3" />
Logs
</button>
<button
onClick={() => setViewMode('raw')}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-all ${
viewMode === 'raw'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-raw"
>
<FileText className="w-3 h-3" />
Raw
</button>
</div>
<Button variant="destructive" size="sm" onClick={handleStop}>
<StopCircle className="w-4 h-4 mr-2" />
Stop
</Button>
</div>
<Button variant="destructive" size="sm" onClick={handleStop}>
<StopCircle className="w-4 h-4 mr-2" />
Stop
</Button>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[200px] max-h-[400px]"
>
{progress.length === 0 ? (
<div className="flex items-center justify-center min-h-[168px] text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Waiting for AI response...
</div>
) : viewMode === 'parsed' ? (
<LogViewer output={progress.join('')} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">
{progress.join('')}
</div>
)}
<div className="whitespace-pre-wrap break-words text-zinc-300">
{progress.join('')}
</div>
</div>
</div>
) : hasSuggestions ? (

View File

@@ -1,6 +1,5 @@
export { AddFeatureDialog } from './add-feature-dialog';
export { AgentOutputModal } from './agent-output-modal';
export { BacklogPlanDialog } from './backlog-plan-dialog';
export { CompletedFeaturesModal } from './completed-features-modal';
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';

View File

@@ -98,7 +98,6 @@ export function useBoardActions({
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
dependencies?: string[];
}) => {
// Empty string means "unassigned" (show only on primary worktree) - convert to undefined
// Non-empty string is the actual branch name (for non-primary worktrees)
@@ -151,7 +150,6 @@ export function useBoardActions({
titleGenerating: needsTitleGeneration,
status: 'backlog' as const,
branchName: finalBranchName,
dependencies: featureData.dependencies || [],
};
const createdFeature = addFeature(newFeatureData);
// Must await to ensure feature exists on server before user can drag it

View File

@@ -109,7 +109,9 @@ export function useBoardColumnFeatures({
// This ensures features appear in dependency order (dependencies before dependents)
// Within the same dependency level, features are sorted by priority
if (map.backlog.length > 0) {
const { orderedFeatures } = resolveDependencies(map.backlog);
const { orderedFeatures } = resolveDependencies(map.backlog as any) as {
orderedFeatures: Feature[];
};
// Get all features to check blocking dependencies against
const allFeatures = features;
@@ -122,7 +124,7 @@ export function useBoardColumnFeatures({
const blocked: Feature[] = [];
for (const f of orderedFeatures) {
if (getBlockingDependencies(f, allFeatures).length > 0) {
if (getBlockingDependencies(f as any, allFeatures as any).length > 0) {
blocked.push(f);
} else {
unblocked.push(f);

View File

@@ -41,7 +41,6 @@ interface KanbanBoardProps {
onImplement: (feature: Feature) => void;
onViewPlan: (feature: Feature) => void;
onApprovePlan: (feature: Feature) => void;
onSpawnTask?: (feature: Feature) => void;
featuresWithContext: Set<string>;
runningAutoTasks: string[];
shortcuts: ReturnType<typeof useKeyboardShortcutsConfig>;
@@ -74,7 +73,6 @@ export function KanbanBoard({
onImplement,
onViewPlan,
onApprovePlan,
onSpawnTask,
featuresWithContext,
runningAutoTasks,
shortcuts,
@@ -103,6 +101,7 @@ export function KanbanBoard({
key={column.id}
id={column.id}
title={column.title}
accent={column.accent}
colorClass={column.colorClass}
count={columnFeatures.length}
width={columnWidth}
@@ -119,7 +118,7 @@ export function KanbanBoard({
data-testid="archive-all-verified-button"
>
<Archive className="w-3 h-3 mr-1" />
Complete All
Archive All
</Button>
) : column.id === 'backlog' ? (
<div className="flex items-center gap-1">
@@ -186,7 +185,6 @@ export function KanbanBoard({
onImplement={() => onImplement(feature)}
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
onSpawnTask={() => onSpawnTask?.(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}

View File

@@ -1,211 +0,0 @@
import { useState } from 'react';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { ChevronDown, ChevronRight, Users, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { AncestorContext } from '@automaker/dependency-resolver';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
interface ParentFeatureContext {
id: string;
title?: string;
description: string;
spec?: string;
summary?: string;
}
interface AncestorContextSectionProps {
parentFeature: ParentFeatureContext;
ancestors: AncestorContext[];
selectedAncestorIds: Set<string>;
onSelectionChange: (ids: Set<string>) => void;
}
export function AncestorContextSection({
parentFeature,
ancestors,
selectedAncestorIds,
onSelectionChange,
}: AncestorContextSectionProps) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const toggleExpanded = (id: string) => {
const newExpanded = new Set(expandedIds);
if (newExpanded.has(id)) {
newExpanded.delete(id);
} else {
newExpanded.add(id);
}
setExpandedIds(newExpanded);
};
const toggleSelected = (id: string) => {
const newSelected = new Set(selectedAncestorIds);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
onSelectionChange(newSelected);
};
const selectAll = () => {
const allIds = new Set([parentFeature.id, ...ancestors.map((a) => a.id)]);
onSelectionChange(allIds);
};
const selectNone = () => {
onSelectionChange(new Set());
};
// Combine parent and ancestors into a single list
const allAncestorItems: Array<
(AncestorContext | ParentFeatureContext) & { isParent: boolean; depth: number }
> = [
{ ...parentFeature, depth: -1, isParent: true },
...ancestors.map((a) => ({ ...a, isParent: false })),
];
const totalCount = allAncestorItems.length;
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-muted-foreground" />
<Label className="text-sm font-medium">Ancestor Context</Label>
<span className="text-xs text-muted-foreground">
({selectedAncestorIds.size}/{totalCount} selected)
</span>
</div>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={selectAll}
className="h-6 px-2 text-xs"
>
All
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={selectNone}
className="h-6 px-2 text-xs"
>
None
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
The parent task context will be included to help the AI understand the background.
Additional ancestors can optionally be included for more context.
</p>
<div className="space-y-1 max-h-[200px] overflow-y-auto border rounded-lg p-2 bg-muted/20">
{allAncestorItems.map((item) => {
const isSelected = selectedAncestorIds.has(item.id);
const isExpanded = expandedIds.has(item.id);
const hasContent =
item.description ||
('spec' in item && item.spec) ||
('summary' in item && item.summary);
const displayTitle =
item.title ||
item.description.slice(0, 50) + (item.description.length > 50 ? '...' : '');
return (
<Collapsible key={item.id} open={isExpanded}>
<div
className={cn(
'flex items-start gap-2 p-2 rounded-md transition-colors',
item.isParent
? isSelected
? 'bg-[var(--status-success-bg)] border border-[var(--status-success)]/30'
: 'bg-muted/30 border border-border hover:bg-muted/50'
: isSelected
? 'bg-primary/10'
: 'hover:bg-muted/50'
)}
style={{ marginLeft: item.isParent ? 0 : `${item.depth * 12}px` }}
>
<Checkbox
id={`ancestor-${item.id}`}
checked={isSelected}
onCheckedChange={() => toggleSelected(item.id)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
{hasContent && (
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => toggleExpanded(item.id)}
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</Button>
</CollapsibleTrigger>
)}
<label
htmlFor={`ancestor-${item.id}`}
className="text-sm font-medium cursor-pointer truncate flex-1"
>
{displayTitle}
</label>
{item.isParent && (
<span className="ml-2 inline-flex items-center gap-1 text-xs text-[var(--status-success)] font-medium">
<CheckCircle2 className="w-3 h-3" />
Completed Parent
</span>
)}
</div>
<CollapsibleContent>
<div className="mt-2 space-y-2 text-xs text-muted-foreground pl-5">
{item.description && (
<div>
<span className="font-medium text-foreground">Description:</span>
<p className="mt-0.5 line-clamp-3">{item.description}</p>
</div>
)}
{'spec' in item && item.spec && (
<div>
<span className="font-medium text-foreground">Specification:</span>
<p className="mt-0.5 line-clamp-3">{item.spec}</p>
</div>
)}
{'summary' in item && item.summary && (
<div>
<span className="font-medium text-foreground">Summary:</span>
<p className="mt-0.5 line-clamp-3">{item.summary}</p>
</div>
)}
</div>
</CollapsibleContent>
</div>
</div>
</Collapsible>
);
})}
{ancestors.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-2">
Parent task has no additional ancestors
</p>
)}
</div>
</div>
);
}

View File

@@ -6,4 +6,3 @@ export * from './testing-tab-content';
export * from './priority-selector';
export * from './branch-selector';
export * from './planning-mode-selector';
export * from './ancestor-context-section';

View File

@@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button';
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
import React from 'react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
@@ -87,7 +88,7 @@ export function WorktreeTab({
onStopDevServer,
onOpenDevServerUrl,
}: WorktreeTabProps) {
let prBadge: JSX.Element | null = null;
let prBadge: React.ReactNode | null = null;
if (worktree.pr) {
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
const prStateClasses = (() => {

View File

@@ -1,126 +1,93 @@
import { useState, useCallback, useMemo } from 'react';
import { CircleDot, RefreshCw } from 'lucide-react';
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
import { useState, useEffect, useCallback } from 'react';
import { CircleDot, Loader2, RefreshCw, ExternalLink, CheckCircle2, Circle, X } from 'lucide-react';
import { getElectronAPI, GitHubIssue } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { LoadingState } from '@/components/ui/loading-state';
import { ErrorState } from '@/components/ui/error-state';
import { cn, pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks';
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
import { ValidationDialog } from './github-issues-view/dialogs';
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
import { Button } from '@/components/ui/button';
import { Markdown } from '@/components/ui/markdown';
import { cn } from '@/lib/utils';
export function GitHubIssuesView() {
const [openIssues, setOpenIssues] = useState<GitHubIssue[]>([]);
const [closedIssues, setClosedIssues] = useState<GitHubIssue[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
const [validationResult, setValidationResult] = useState<IssueValidationResult | null>(null);
const [showValidationDialog, setShowValidationDialog] = useState(false);
const [showRevalidateConfirm, setShowRevalidateConfirm] = useState(false);
const { currentProject } = useAppStore();
const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } =
useAppStore();
const fetchIssues = useCallback(async () => {
if (!currentProject?.path) {
setError('No project selected');
setLoading(false);
return;
}
const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues();
try {
setError(null);
const api = getElectronAPI();
if (api.github) {
const result = await api.github.listIssues(currentProject.path);
if (result.success) {
setOpenIssues(result.openIssues || []);
setClosedIssues(result.closedIssues || []);
} else {
setError(result.error || 'Failed to fetch issues');
}
}
} catch (err) {
console.error('[GitHubIssuesView] Error fetching issues:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch issues');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [currentProject?.path]);
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } =
useIssueValidation({
selectedIssue,
showValidationDialog,
onValidationResultChange: setValidationResult,
onShowValidationDialogChange: setShowValidationDialog,
});
useEffect(() => {
fetchIssues();
}, [fetchIssues]);
// Get default AI profile for task creation
const defaultProfile = useMemo(() => {
if (!defaultAIProfileId) return null;
return aiProfiles.find((p) => p.id === defaultAIProfileId) ?? null;
}, [defaultAIProfileId, aiProfiles]);
// Get current branch from selected worktree
const currentBranch = useMemo(() => {
if (!currentProject?.path) return '';
const currentWorktreeInfo = getCurrentWorktree(currentProject.path);
const worktrees = worktreesByProject[currentProject.path] ?? [];
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
const selectedWorktree =
currentWorktreePath === null
? worktrees.find((w) => w.isMain)
: worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
return selectedWorktree?.branch || worktrees.find((w) => w.isMain)?.branch || '';
}, [currentProject?.path, getCurrentWorktree, worktreesByProject]);
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchIssues();
}, [fetchIssues]);
const handleOpenInGitHub = useCallback((url: string) => {
const api = getElectronAPI();
api.openExternalLink(url);
}, []);
const handleConvertToTask = useCallback(
async (issue: GitHubIssue, validation: IssueValidationResult) => {
if (!currentProject?.path) {
toast.error('No project selected');
return;
}
try {
const api = getElectronAPI();
if (api.features?.create) {
// Build description from issue body + validation info
const description = [
`**From GitHub Issue #${issue.number}**`,
'',
issue.body || 'No description provided.',
'',
'---',
'',
'**AI Validation Analysis:**',
validation.reasoning,
validation.suggestedFix ? `\n**Suggested Approach:**\n${validation.suggestedFix}` : '',
validation.relatedFiles?.length
? `\n**Related Files:**\n${validation.relatedFiles.map((f) => `- \`${f}\``).join('\n')}`
: '',
]
.filter(Boolean)
.join('\n');
const feature = {
id: `issue-${issue.number}-${crypto.randomUUID()}`,
title: issue.title,
description,
category: 'From GitHub',
status: 'backlog' as const,
passes: false,
priority: getFeaturePriority(validation.estimatedComplexity),
model: defaultProfile?.model ?? 'opus',
thinkingLevel: defaultProfile?.thinkingLevel ?? 'none',
branchName: currentBranch,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const result = await api.features.create(currentProject.path, feature);
if (result.success) {
toast.success(`Created task: ${issue.title}`);
} else {
toast.error(result.error || 'Failed to create task');
}
}
} catch (err) {
console.error('[GitHubIssuesView] Convert to task error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to create task');
}
},
[currentProject?.path, defaultProfile, currentBranch]
);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
if (loading) {
return <LoadingState />;
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return <ErrorState error={error} title="Failed to Load Issues" onRetry={refresh} />;
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">Failed to Load Issues</h2>
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
);
}
const totalIssues = openIssues.length + closedIssues.length;
@@ -135,12 +102,24 @@ export function GitHubIssuesView() {
)}
>
{/* Header */}
<IssuesListHeader
openCount={openIssues.length}
closedCount={closedIssues.length}
refreshing={refreshing}
onRefresh={refresh}
/>
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-green-500/10">
<CircleDot className="h-5 w-5 text-green-500" />
</div>
<div>
<h1 className="text-lg font-bold">Issues</h1>
<p className="text-xs text-muted-foreground">
{totalIssues === 0
? 'No issues found'
: `${openIssues.length} open, ${closedIssues.length} closed`}
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
</Button>
</div>
{/* Issues List */}
<div className="flex-1 overflow-auto">
@@ -163,8 +142,6 @@ export function GitHubIssuesView() {
onClick={() => setSelectedIssue(issue)}
onOpenExternal={() => handleOpenInGitHub(issue.url)}
formatDate={formatDate}
cachedValidation={cachedValidations.get(issue.number)}
isValidating={validatingIssues.has(issue.number)}
/>
))}
@@ -182,8 +159,6 @@ export function GitHubIssuesView() {
onClick={() => setSelectedIssue(issue)}
onOpenExternal={() => handleOpenInGitHub(issue.url)}
formatDate={formatDate}
cachedValidation={cachedValidations.get(issue.number)}
isValidating={validatingIssues.has(issue.number)}
/>
))}
</>
@@ -195,43 +170,164 @@ export function GitHubIssuesView() {
{/* Issue Detail Panel */}
{selectedIssue && (
<IssueDetailPanel
issue={selectedIssue}
validatingIssues={validatingIssues}
cachedValidations={cachedValidations}
onValidateIssue={handleValidateIssue}
onViewCachedValidation={handleViewCachedValidation}
onOpenInGitHub={handleOpenInGitHub}
onClose={() => setSelectedIssue(null)}
onShowRevalidateConfirm={() => setShowRevalidateConfirm(true)}
formatDate={formatDate}
/>
<div className="flex-1 flex flex-col overflow-hidden">
{/* Detail Header */}
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2 min-w-0">
{selectedIssue.state === 'OPEN' ? (
<Circle className="h-4 w-4 text-green-500 flex-shrink-0" />
) : (
<CheckCircle2 className="h-4 w-4 text-purple-500 flex-shrink-0" />
)}
<span className="text-sm font-medium truncate">
#{selectedIssue.number} {selectedIssue.title}
</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => handleOpenInGitHub(selectedIssue.url)}
>
<ExternalLink className="h-4 w-4 mr-1" />
Open in GitHub
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelectedIssue(null)}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Issue Detail Content */}
<div className="flex-1 overflow-auto p-6">
{/* Title */}
<h1 className="text-xl font-bold mb-2">{selectedIssue.title}</h1>
{/* Meta info */}
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4">
<span
className={cn(
'px-2 py-0.5 rounded-full text-xs font-medium',
selectedIssue.state === 'OPEN'
? 'bg-green-500/10 text-green-500'
: 'bg-purple-500/10 text-purple-500'
)}
>
{selectedIssue.state === 'OPEN' ? 'Open' : 'Closed'}
</span>
<span>
#{selectedIssue.number} opened {formatDate(selectedIssue.createdAt)} by{' '}
<span className="font-medium text-foreground">{selectedIssue.author.login}</span>
</span>
</div>
{/* Labels */}
{selectedIssue.labels.length > 0 && (
<div className="flex items-center gap-2 mb-6 flex-wrap">
{selectedIssue.labels.map((label) => (
<span
key={label.name}
className="px-2 py-0.5 text-xs font-medium rounded-full"
style={{
backgroundColor: `#${label.color}20`,
color: `#${label.color}`,
border: `1px solid #${label.color}40`,
}}
>
{label.name}
</span>
))}
</div>
)}
{/* Body */}
{selectedIssue.body ? (
<Markdown className="text-sm">{selectedIssue.body}</Markdown>
) : (
<p className="text-sm text-muted-foreground italic">No description provided.</p>
)}
{/* Open in GitHub CTA */}
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground mb-3">
View comments, add reactions, and more on GitHub.
</p>
<Button onClick={() => handleOpenInGitHub(selectedIssue.url)}>
<ExternalLink className="h-4 w-4 mr-2" />
View Full Issue on GitHub
</Button>
</div>
</div>
</div>
)}
{/* Validation Dialog */}
<ValidationDialog
open={showValidationDialog}
onOpenChange={setShowValidationDialog}
issue={selectedIssue}
validationResult={validationResult}
onConvertToTask={handleConvertToTask}
/>
{/* Revalidate Confirmation Dialog */}
<ConfirmDialog
open={showRevalidateConfirm}
onOpenChange={setShowRevalidateConfirm}
title="Re-validate Issue"
description={`Are you sure you want to re-validate issue #${selectedIssue?.number}? This will run a new AI analysis and replace the existing validation result.`}
icon={RefreshCw}
iconClassName="text-primary"
confirmText="Re-validate"
onConfirm={() => {
if (selectedIssue) {
handleValidateIssue(selectedIssue, { forceRevalidate: true });
}
}}
/>
</div>
);
}
interface IssueRowProps {
issue: GitHubIssue;
isSelected: boolean;
onClick: () => void;
onOpenExternal: () => void;
formatDate: (date: string) => string;
}
function IssueRow({ issue, isSelected, onClick, onOpenExternal, formatDate }: IssueRowProps) {
return (
<div
className={cn(
'flex items-start gap-3 p-3 cursor-pointer hover:bg-accent/50 transition-colors',
isSelected && 'bg-accent'
)}
onClick={onClick}
>
{issue.state === 'OPEN' ? (
<Circle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
) : (
<CheckCircle2 className="h-4 w-4 text-purple-500 mt-0.5 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{issue.title}</span>
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs text-muted-foreground">
#{issue.number} opened {formatDate(issue.createdAt)} by {issue.author.login}
</span>
</div>
{issue.labels.length > 0 && (
<div className="flex items-center gap-1 mt-2 flex-wrap">
{issue.labels.map((label) => (
<span
key={label.name}
className="px-1.5 py-0.5 text-[10px] font-medium rounded-full"
style={{
backgroundColor: `#${label.color}20`,
color: `#${label.color}`,
border: `1px solid #${label.color}40`,
}}
>
{label.name}
</span>
))}
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
className="flex-shrink-0 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onOpenExternal();
}}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
);
}

View File

@@ -1,3 +0,0 @@
export { IssueRow } from './issue-row';
export { IssueDetailPanel } from './issue-detail-panel';
export { IssuesListHeader } from './issues-list-header';

View File

@@ -1,242 +0,0 @@
import {
Circle,
CheckCircle2,
X,
Wand2,
ExternalLink,
Loader2,
CheckCircle,
Clock,
GitPullRequest,
User,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Markdown } from '@/components/ui/markdown';
import { cn } from '@/lib/utils';
import type { IssueDetailPanelProps } from '../types';
import { isValidationStale } from '../utils';
export function IssueDetailPanel({
issue,
validatingIssues,
cachedValidations,
onValidateIssue,
onViewCachedValidation,
onOpenInGitHub,
onClose,
onShowRevalidateConfirm,
formatDate,
}: IssueDetailPanelProps) {
const isValidating = validatingIssues.has(issue.number);
const cached = cachedValidations.get(issue.number);
const isStale = cached ? isValidationStale(cached.validatedAt) : false;
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Detail Header */}
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2 min-w-0">
{issue.state === 'OPEN' ? (
<Circle className="h-4 w-4 text-green-500 shrink-0" />
) : (
<CheckCircle2 className="h-4 w-4 text-purple-500 shrink-0" />
)}
<span className="text-sm font-medium truncate">
#{issue.number} {issue.title}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{(() => {
if (isValidating) {
return (
<Button variant="default" size="sm" disabled>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Validating...
</Button>
);
}
if (cached && !isStale) {
return (
<>
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
<CheckCircle className="h-4 w-4 mr-1 text-green-500" />
View Result
</Button>
<Button
variant="ghost"
size="sm"
onClick={onShowRevalidateConfirm}
title="Re-validate"
>
<RefreshCw className="h-4 w-4" />
</Button>
</>
);
}
if (cached && isStale) {
return (
<>
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
<Clock className="h-4 w-4 mr-1 text-yellow-500" />
View (stale)
</Button>
<Button
variant="default"
size="sm"
onClick={() => onValidateIssue(issue, { forceRevalidate: true })}
>
<Wand2 className="h-4 w-4 mr-1" />
Re-validate
</Button>
</>
);
}
return (
<Button variant="default" size="sm" onClick={() => onValidateIssue(issue)}>
<Wand2 className="h-4 w-4 mr-1" />
Validate with AI
</Button>
);
})()}
<Button variant="outline" size="sm" onClick={() => onOpenInGitHub(issue.url)}>
<ExternalLink className="h-4 w-4 mr-1" />
Open in GitHub
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Issue Detail Content */}
<div className="flex-1 overflow-auto p-6">
{/* Title */}
<h1 className="text-xl font-bold mb-2">{issue.title}</h1>
{/* Meta info */}
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4">
<span
className={cn(
'px-2 py-0.5 rounded-full text-xs font-medium',
issue.state === 'OPEN'
? 'bg-green-500/10 text-green-500'
: 'bg-purple-500/10 text-purple-500'
)}
>
{issue.state === 'OPEN' ? 'Open' : 'Closed'}
</span>
<span>
#{issue.number} opened {formatDate(issue.createdAt)} by{' '}
<span className="font-medium text-foreground">{issue.author.login}</span>
</span>
</div>
{/* Labels */}
{issue.labels.length > 0 && (
<div className="flex items-center gap-2 mb-4 flex-wrap">
{issue.labels.map((label) => (
<span
key={label.name}
className="px-2 py-0.5 text-xs font-medium rounded-full"
style={{
backgroundColor: `#${label.color}20`,
color: `#${label.color}`,
border: `1px solid #${label.color}40`,
}}
>
{label.name}
</span>
))}
</div>
)}
{/* Assignees */}
{issue.assignees && issue.assignees.length > 0 && (
<div className="flex items-center gap-2 mb-4">
<User className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Assigned to:</span>
<div className="flex items-center gap-2">
{issue.assignees.map((assignee) => (
<span
key={assignee.login}
className="inline-flex items-center gap-1.5 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-500/10 text-blue-500 border border-blue-500/20"
>
{assignee.avatarUrl && (
<img
src={assignee.avatarUrl}
alt={assignee.login}
className="h-4 w-4 rounded-full"
/>
)}
{assignee.login}
</span>
))}
</div>
</div>
)}
{/* Linked Pull Requests */}
{issue.linkedPRs && issue.linkedPRs.length > 0 && (
<div className="mb-6 p-3 rounded-lg bg-muted/30 border border-border">
<div className="flex items-center gap-2 mb-2">
<GitPullRequest className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Linked Pull Requests</span>
</div>
<div className="space-y-2">
{issue.linkedPRs.map((pr) => (
<div key={pr.number} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 min-w-0">
<span
className={cn(
'px-1.5 py-0.5 text-xs font-medium rounded',
pr.state === 'open'
? 'bg-green-500/10 text-green-500'
: pr.state === 'merged'
? 'bg-purple-500/10 text-purple-500'
: 'bg-red-500/10 text-red-500'
)}
>
{pr.state === 'open' ? 'Open' : pr.state === 'merged' ? 'Merged' : 'Closed'}
</span>
<span className="text-muted-foreground">#{pr.number}</span>
<span className="truncate">{pr.title}</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 shrink-0"
onClick={() => onOpenInGitHub(pr.url)}
>
<ExternalLink className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
{/* Body */}
{issue.body ? (
<Markdown className="text-sm">{issue.body}</Markdown>
) : (
<p className="text-sm text-muted-foreground italic">No description provided.</p>
)}
{/* Open in GitHub CTA */}
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground mb-3">
View comments, add reactions, and more on GitHub.
</p>
<Button onClick={() => onOpenInGitHub(issue.url)}>
<ExternalLink className="h-4 w-4 mr-2" />
View Full Issue on GitHub
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,136 +0,0 @@
import {
Circle,
CheckCircle2,
ExternalLink,
Loader2,
CheckCircle,
Sparkles,
GitPullRequest,
User,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { IssueRowProps } from '../types';
import { isValidationStale } from '../utils';
export function IssueRow({
issue,
isSelected,
onClick,
onOpenExternal,
formatDate,
cachedValidation,
isValidating,
}: IssueRowProps) {
// Check if validation exists and calculate staleness
const validationHoursSince = cachedValidation
? (Date.now() - new Date(cachedValidation.validatedAt).getTime()) / (1000 * 60 * 60)
: null;
const isValidationStaleValue =
validationHoursSince !== null && isValidationStale(cachedValidation!.validatedAt);
// Check if validation is unviewed (exists, not stale, not viewed)
const hasUnviewedValidation =
cachedValidation && !cachedValidation.viewedAt && !isValidationStaleValue;
// Check if validation has been viewed (exists and was viewed)
const hasViewedValidation =
cachedValidation && cachedValidation.viewedAt && !isValidationStaleValue;
return (
<div
className={cn(
'group flex items-start gap-3 p-3 cursor-pointer hover:bg-accent/50 transition-colors',
isSelected && 'bg-accent'
)}
onClick={onClick}
>
{issue.state === 'OPEN' ? (
<Circle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
) : (
<CheckCircle2 className="h-4 w-4 text-purple-500 mt-0.5 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{issue.title}</span>
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs text-muted-foreground">
#{issue.number} opened {formatDate(issue.createdAt)} by {issue.author.login}
</span>
</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
{/* Labels */}
{issue.labels.map((label) => (
<span
key={label.name}
className="px-1.5 py-0.5 text-[10px] font-medium rounded-full"
style={{
backgroundColor: `#${label.color}20`,
color: `#${label.color}`,
border: `1px solid #${label.color}40`,
}}
>
{label.name}
</span>
))}
{/* Linked PR indicator */}
{issue.linkedPRs && issue.linkedPRs.length > 0 && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-purple-500/10 text-purple-500 border border-purple-500/20">
<GitPullRequest className="h-3 w-3" />
{issue.linkedPRs.length} PR{issue.linkedPRs.length > 1 ? 's' : ''}
</span>
)}
{/* Assignee indicator */}
{issue.assignees && issue.assignees.length > 0 && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-blue-500/10 text-blue-500 border border-blue-500/20">
<User className="h-3 w-3" />
{issue.assignees.map((a) => a.login).join(', ')}
</span>
)}
{/* Validating indicator */}
{isValidating && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-primary/10 text-primary border border-primary/20 animate-in fade-in duration-200">
<Loader2 className="h-3 w-3 animate-spin" />
Analyzing...
</span>
)}
{/* Unviewed validation indicator */}
{!isValidating && hasUnviewedValidation && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-amber-500/10 text-amber-500 border border-amber-500/20 animate-in fade-in duration-200">
<Sparkles className="h-3 w-3" />
Analysis Ready
</span>
)}
{/* Viewed validation indicator */}
{!isValidating && hasViewedValidation && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-green-500/10 text-green-500 border border-green-500/20">
<CheckCircle className="h-3 w-3" />
Validated
</span>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
className="flex-shrink-0 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onOpenExternal();
}}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
);
}

View File

@@ -1,38 +0,0 @@
import { CircleDot, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface IssuesListHeaderProps {
openCount: number;
closedCount: number;
refreshing: boolean;
onRefresh: () => void;
}
export function IssuesListHeader({
openCount,
closedCount,
refreshing,
onRefresh,
}: IssuesListHeaderProps) {
const totalIssues = openCount + closedCount;
return (
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-green-500/10">
<CircleDot className="h-5 w-5 text-green-500" />
</div>
<div>
<h1 className="text-lg font-bold">Issues</h1>
<p className="text-xs text-muted-foreground">
{totalIssues === 0 ? 'No issues found' : `${openCount} open, ${closedCount} closed`}
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
</Button>
</div>
);
}

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