refactor: restructure project to monorepo with apps directory

This commit is contained in:
Cody Seibert
2025-12-11 20:34:49 -05:00
parent 7cb5a6a4df
commit 1bb20e5070
164 changed files with 12215 additions and 14 deletions

50
apps/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
# Electron
/dist/

123
apps/app/README.md Normal file
View File

@@ -0,0 +1,123 @@
# Automaker
Automaker is an autonomous AI development studio that helps you build software faster using AI-powered agents. It provides a visual Kanban board interface to manage features, automatically assigns AI agents to implement them, and tracks progress through an intuitive workflow from backlog to verified completion.
---
> **[!CAUTION]**
>
> ## Security Disclaimer
>
> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.**
>
> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it.
>
> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine.
>
> **[Read the full disclaimer](../DISCLAIMER.md)**
---
## Getting Started
**Step 1:** Clone this repository:
```bash
git clone git@github.com:AutoMaker-Org/automaker.git
cd automaker
```
**Step 2:** Install dependencies:
```bash
npm install
```
### Windows notes (in-app Claude auth)
- Node.js 22.x
- Prebuilt PTY is bundled; Visual Studio build tools are not required for Claude auth.
- If you prefer the external terminal flow, set `CLAUDE_AUTH_DISABLE_PTY=1`.
- If you later add native modules beyond the prebuilt PTY, you may still need VS Build Tools + Python to rebuild those.
**Step 3:** Run the Claude Code setup token command:
```bash
claude setup-token
```
> **⚠️ Warning:** This command will print your token to your terminal. Be careful if you're streaming or sharing your screen, as the token will be visible to anyone watching.
**Step 4:** Export the Claude Code OAuth token in your shell:
```bash
export CLAUDE_CODE_OAUTH_TOKEN="your-token-here"
```
**Step 5:** Start the development server:
```bash
npm run dev:electron
```
This will start both the Next.js development server and the Electron application.
### Auth smoke test (Windows)
1. Ensure dependencies are installed (prebuilt pty is included).
2. Run `npm run dev:electron` and open the Setup modal.
3. Click Start on Claude auth; watch the embedded terminal stream logs.
4. Successful runs show “Token captured automatically.”; otherwise copy/paste the token from the log.
5. Optional: `node --test tests/claude-cli-detector.test.js` to verify token parsing.
**Step 6:** MOST IMPORTANT: Run the Following after all is setup
```bash
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
```
## Features
- 📋 **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"
- 🧠 **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
- [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
To learn more about Next.js, take a look at the following resources:
- [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
See [LICENSE](../LICENSE) for details.

22
apps/app/components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -0,0 +1,285 @@
# Agent Architecture - Surviving Next.js Restarts
## Problem Statement
When using the Automaker app to iterate on itself:
1. Agent modifies code files
2. Next.js hot-reloads and restarts
3. API routes are killed
4. Agent conversation is lost
## Solution: Electron Main Process Agent
The agent now runs in the **Electron main process** instead of Next.js API routes. This provides:
-**Survives Next.js restarts** - Main process is independent of renderer
-**Persistent state** - Conversations saved to disk automatically
-**Real-time streaming** - IPC events for live updates
-**Session recovery** - Reconnects automatically after restart
## Architecture Overview
```
┌─────────────────────────────────────────┐
│ Electron Main Process │
│ ┌───────────────────────────────┐ │
│ │ Agent Service │ │
│ │ - Manages sessions │ │
│ │ - Runs Claude Agent SDK │ │
│ │ - Persists to disk │ │
│ │ - Streams via IPC │ │
│ └───────────────────────────────┘ │
└──────────────┬──────────────────────────┘
│ IPC (survives restarts)
┌──────────────┴──────────────────────────┐
│ Electron Renderer (Next.js) │
│ ┌───────────────────────────────┐ │
│ │ React Frontend │ │
│ │ - useElectronAgent hook │ │
│ │ - Auto-reconnects │ │
│ │ - Real-time updates │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────────┘
```
## Key Components
### 1. Agent Service (`electron/agent-service.js`)
The core service running in the Electron main process:
- **Session Management**: Tracks multiple conversations by session ID
- **State Persistence**: Saves conversations to `userData/agent-sessions/*.json`
- **Streaming**: Sends real-time updates to renderer via IPC
- **Tool Support**: Full Read/Write/Edit/Bash/Grep/Glob capabilities
- **Error Recovery**: Continues after errors, saves state
### 2. IPC Handlers (`electron/main.js`)
Electron main process handlers:
- `agent:start` - Initialize or resume a session
- `agent:send` - Send a message (returns immediately)
- `agent:getHistory` - Retrieve conversation history
- `agent:stop` - Stop current execution
- `agent:clear` - Clear conversation
- `agent:stream` - Event emitted for streaming updates
### 3. Preload Bridge (`electron/preload.js`)
Secure IPC bridge exposed to renderer:
```javascript
window.electronAPI.agent.start(sessionId, workingDir);
window.electronAPI.agent.send(sessionId, message, workingDir);
window.electronAPI.agent.onStream(callback);
```
### 4. React Hook (`src/hooks/use-electron-agent.ts`)
Easy-to-use React hook:
```typescript
const {
messages, // Conversation history
isProcessing, // Agent is working
isConnected, // Session initialized
sendMessage, // Send user message
stopExecution, // Stop current task
clearHistory, // Clear conversation
error, // Error state
} = useElectronAgent({
sessionId: "project_xyz",
workingDirectory: "/path/to/project",
onToolUse: (tool) => console.log("Using:", tool),
});
```
### 5. Frontend Component (`src/components/views/agent-view.tsx`)
Updated to use IPC instead of HTTP:
- Generates session ID from project path
- Auto-reconnects on mount
- Shows tool usage in real-time
- Displays connection status
## Data Flow
### Sending a Message
1. User types message in React UI
2. `sendMessage()` calls `window.electronAPI.agent.send()`
3. IPC handler in main process receives message
4. Agent service starts processing
5. Main process streams updates via `agent:stream` events
6. React hook receives events and updates UI
7. Conversation saved to disk
### Surviving a Restart
1. Agent is modifying code → Next.js restarts
2. React component unmounts
3. **Main process keeps running** (agent continues)
4. React component remounts after restart
5. Calls `agent:start` with same session ID
6. Main process returns full conversation history
7. Subscribes to `agent:stream` events
8. UI shows complete conversation + live updates
## Session Storage
Sessions are stored in:
```
<userData>/agent-sessions/<sessionId>.json
```
Each session file contains:
```json
[
{
"id": "msg_1234_abc",
"role": "user",
"content": "Add a new feature...",
"timestamp": "2024-12-07T12:00:00.000Z"
},
{
"id": "msg_1235_def",
"role": "assistant",
"content": "I'll help you add that feature...",
"timestamp": "2024-12-07T12:00:05.000Z"
}
]
```
## Session ID Generation
Session IDs are generated from project paths:
```typescript
const sessionId = `project_${projectPath.replace(/[^a-zA-Z0-9]/g, "_")}`;
```
This ensures:
- Each project has its own conversation
- Conversations persist across app restarts
- Multiple projects can run simultaneously
## Streaming Events
The agent emits these event types:
### `message`
User message added to conversation
### `stream`
Assistant response streaming (updates in real-time)
### `tool_use`
Agent is using a tool (Read, Write, Edit, etc.)
### `complete`
Agent finished processing
### `error`
Error occurred during processing
## Configuration
The agent is configured with:
```javascript
{
model: "claude-opus-4-5-20251101",
maxTurns: 20,
cwd: workingDirectory,
allowedTools: [
"Read", "Write", "Edit", "Glob", "Grep",
"Bash", "WebSearch", "WebFetch"
],
permissionMode: "acceptEdits", // Auto-approve file edits
sandbox: {
enabled: true, // Sandboxed bash execution
autoAllowBashIfSandboxed: true
}
}
```
## Benefits
### For Self-Iteration
Now you can ask the agent to modify Automaker itself:
```
User: "Add a dark mode toggle to the settings"
Agent: *modifies files*
→ Next.js restarts
→ Agent continues working
→ UI reconnects automatically
→ Shows full conversation history
```
### For Long-Running Tasks
The agent can work on complex tasks that take multiple turns:
```
User: "Implement authentication with GitHub OAuth"
Agent:
1. Creates auth API routes
2. Next.js restarts
3. Agent continues: Adds middleware
4. Next.js restarts again
5. Agent continues: Updates UI components
6. All changes tracked, conversation preserved
```
## Testing
To test the architecture:
1. Open a project in Automaker
2. Ask the agent to modify a file in `src/`
3. Watch Next.js restart
4. Verify the conversation continues
5. Check that history is preserved
6. Restart the entire Electron app
7. Verify conversation loads from disk
## Troubleshooting
### "Electron API not available"
- Make sure you're running in Electron, not browser
- Check `window.isElectron` is `true`
### Session not persisting
- Check userData directory exists
- Verify write permissions
- Look for errors in Electron console
### Next.js restart kills agent
- Verify agent service is in `electron/main.js`
- Check IPC handlers are registered
- Ensure not using HTTP `/api/chat` route
## Future Enhancements
- [ ] Multiple concurrent sessions
- [ ] Export conversation history
- [ ] Undo/redo for agent actions
- [ ] Progress bars for long-running tasks
- [ ] Voice input/output
- [ ] Agent memory across sessions

View File

@@ -0,0 +1,375 @@
# Session Management Guide
## Overview
The Automaker Agent Chat now supports multiple concurrent sessions, allowing you to organize different conversations by topic, feature, or task. Each session is independently managed and persisted.
## Features
### ✨ Multiple Sessions
- Create unlimited agent sessions per project
- Each session has its own conversation history
- Switch between sessions instantly
- Sessions persist across app restarts
### 📋 Session Organization
- Custom names for easy identification
- Last message preview
- Message count tracking
- Sort by most recently updated
### 🗄️ Archive & Delete
- Archive old sessions to declutter
- Unarchive when needed
- Permanently delete sessions
- Confirm before destructive actions
### 💾 Automatic Persistence
- All sessions auto-save to disk
- Survive Next.js restarts
- Survive Electron app restarts
- Never lose your conversations
## User Interface
### Session Manager Sidebar
Located on the left side of the Agent Chat view:
```
┌──────────────────────────┬────────────────────────┐
│ Session Manager │ Chat Messages │
│ │ │
│ [+ New] [Archive] │ User: Hello │
│ │ Agent: Hi there! │
│ 📝 Feature: Auth │ │
│ "Add OAuth login..." │ [Input field] │
│ 42 messages │ │
│ │ │
│ 📝 Bug: Payment │ │
│ "Fix stripe inte..." │ │
│ 15 messages │ │
│ │ │
└──────────────────────────┴────────────────────────┘
```
### Toggle Sidebar
Click the panel icon in the header to show/hide the session manager.
## How to Use
### Creating a Session
1. Click the **"+ New"** button
2. Enter a descriptive name
3. Press Enter or click ✓
4. The new session is immediately active
**Example session names:**
- "Feature: Dark Mode"
- "Bug: Login redirect"
- "Refactor: API layer"
- "Docs: Getting started"
### Switching Sessions
Simply click on any session in the list to switch to it. The conversation history loads instantly.
### Renaming a Session
1. Click the edit icon (✏️) next to the session name
2. Type the new name
3. Press Enter or click ✓
### Clearing a Session
Click the **"Clear"** button in the chat header to delete all messages from the current session while keeping the session itself.
### Archiving a Session
1. Click the archive icon (📦) next to the session
2. The session moves to the archived list
3. Toggle **"Show Archived"** to view archived sessions
**When to archive:**
- Completed features
- Resolved bugs
- Old experiments
- Historical reference
### Unarchiving a Session
1. Toggle **"Show Archived"** to see archived sessions
2. Click the unarchive icon (📤)
3. The session returns to the active list
### Deleting a Session
1. Archive the session first
2. View archived sessions
3. Click the delete icon (🗑️)
4. Confirm the deletion
5. **This is permanent!**
## Storage Location
Sessions are stored in your user data directory:
**macOS:**
```
~/Library/Application Support/automaker/agent-sessions/
```
**Windows:**
```
%APPDATA%/automaker/agent-sessions/
```
**Linux:**
```
~/.config/automaker/agent-sessions/
```
### File Structure
```
agent-sessions/
├── session_1234567890_abc.json # Session conversation
├── session_1234567891_def.json # Another session
└── sessions-metadata.json # Session metadata
```
### Session File Format
Each session file contains an array of messages:
```json
[
{
"id": "msg_1234567890_xyz",
"role": "user",
"content": "Add authentication to the app",
"timestamp": "2024-12-07T12:00:00.000Z"
},
{
"id": "msg_1234567891_abc",
"role": "assistant",
"content": "I'll help you add authentication...",
"timestamp": "2024-12-07T12:00:05.000Z"
}
]
```
### Metadata File Format
The metadata file tracks all sessions:
```json
{
"session_1234567890_abc": {
"name": "Feature: Authentication",
"projectPath": "/path/to/project",
"createdAt": "2024-12-07T12:00:00.000Z",
"updatedAt": "2024-12-07T12:30:00.000Z",
"isArchived": false,
"tags": []
}
}
```
## Best Practices
### Naming Conventions
Use prefixes to organize sessions by type:
- **Feature:** New functionality
- "Feature: Dark mode toggle"
- "Feature: User profiles"
- **Bug:** Issue resolution
- "Bug: Memory leak in dashboard"
- "Bug: Form validation errors"
- **Refactor:** Code improvements
- "Refactor: Database layer"
- "Refactor: Component structure"
- **Docs:** Documentation work
- "Docs: API documentation"
- "Docs: README updates"
- **Experiment:** Try new ideas
- "Experiment: WebGL renderer"
- "Experiment: New state management"
### Session Lifecycle
1. **Create** → Start a new feature or task
2. **Work** → Have conversation, iterate on code
3. **Complete** → Finish the task
4. **Archive** → Keep for reference
5. **Delete** → Remove when no longer needed
### When to Create Multiple Sessions
**Do create separate sessions for:**
- ✅ Different features
- ✅ Unrelated bugs
- ✅ Experimental work
- ✅ Different contexts or approaches
**Don't create separate sessions for:**
- ❌ Same feature, different iterations
- ❌ Related bug fixes
- ❌ Continuation of previous work
### Managing Session Clutter
- Archive completed work weekly
- Delete archived sessions after 30 days
- Use clear naming conventions
- Consolidate related sessions
## Integration with Project Workflow
### Feature Development
```
1. Create: "Feature: User notifications"
2. Agent: Design the notification system
3. Agent: Implement backend
4. Next.js restarts (agent continues)
5. Agent: Implement frontend
6. Agent: Add tests
7. Complete & Archive
```
### Bug Fixing
```
1. Create: "Bug: Payment processing timeout"
2. Agent: Investigate the issue
3. Agent: Identify root cause
4. Agent: Implement fix
5. Agent: Add regression test
6. Complete & Archive
```
### Refactoring
```
1. Create: "Refactor: API error handling"
2. Agent: Analyze current implementation
3. Agent: Design new approach
4. Agent: Refactor service layer
5. Next.js restarts (agent continues)
6. Agent: Refactor controller layer
7. Agent: Update tests
8. Complete & Archive
```
## Keyboard Shortcuts
*(Coming soon)*
- `Cmd/Ctrl + K` - Create new session
- `Cmd/Ctrl + [` - Previous session
- `Cmd/Ctrl + ]` - Next session
- `Cmd/Ctrl + Shift + A` - Toggle archive view
## Troubleshooting
### Session Not Saving
**Check:**
- Electron has write permissions
- Disk space available
- Check Electron console for errors
**Solution:**
```bash
# macOS - Check permissions
ls -la ~/Library/Application\ Support/automaker/
# Fix permissions if needed
chmod -R u+w ~/Library/Application\ Support/automaker/
```
### Can't Switch Sessions
**Check:**
- Session is not archived
- No errors in console
- Agent is not currently processing
**Solution:**
- Wait for current message to complete
- Check for error messages
- Try clearing and reloading
### Session Disappeared
**Check:**
- Not filtered by archive status
- Not accidentally deleted
- Check backup files
**Recovery:**
- Toggle "Show Archived"
- Check filesystem for `.json` files
- Restore from backup if available
## API Reference
For developers integrating session management:
### Create Session
```typescript
const result = await window.electronAPI.sessions.create(
"Session Name",
"/project/path",
"/working/directory"
);
```
### List Sessions
```typescript
const { sessions } = await window.electronAPI.sessions.list(
false // includeArchived
);
```
### Update Session
```typescript
await window.electronAPI.sessions.update(
sessionId,
"New Name",
["tag1", "tag2"]
);
```
### Archive/Unarchive
```typescript
await window.electronAPI.sessions.archive(sessionId);
await window.electronAPI.sessions.unarchive(sessionId);
```
### Delete Session
```typescript
await window.electronAPI.sessions.delete(sessionId);
```
## Future Enhancements
- [ ] Tag system for categorization
- [ ] Search sessions by content
- [ ] Export session to markdown
- [ ] Share sessions with team
- [ ] Session templates
- [ ] Keyboard shortcuts
- [ ] Drag & drop to reorder
- [ ] Favorite/pin sessions
- [ ] Session statistics
- [ ] Automatic archiving rules

View File

@@ -0,0 +1,5 @@
module.exports = {
rules: {
"@typescript-eslint/no-require-imports": "off",
},
};

View File

@@ -0,0 +1,682 @@
const { query, AbortError } = require("@anthropic-ai/claude-agent-sdk");
const path = require("path");
const fs = require("fs/promises");
/**
* Agent Service - Runs Claude agents in the Electron main process
* This service survives Next.js restarts and maintains conversation state
*/
class AgentService {
constructor() {
this.sessions = new Map(); // sessionId -> { messages, isRunning, abortController }
this.stateDir = null; // Will be set when app is ready
}
/**
* Initialize the service with app data directory
*/
async initialize(appDataPath) {
this.stateDir = path.join(appDataPath, "agent-sessions");
this.metadataFile = path.join(appDataPath, "sessions-metadata.json");
await fs.mkdir(this.stateDir, { recursive: true });
}
/**
* Start or resume a conversation
*/
async startConversation({ sessionId, workingDirectory }) {
// Initialize session if it doesn't exist
if (!this.sessions.has(sessionId)) {
const messages = await this.loadSession(sessionId);
this.sessions.set(sessionId, {
messages,
isRunning: false,
abortController: null,
workingDirectory: workingDirectory || process.cwd(),
});
}
const session = this.sessions.get(sessionId);
return {
success: true,
messages: session.messages,
sessionId,
};
}
/**
* Send a message to the agent and stream responses
*/
async sendMessage({
sessionId,
message,
workingDirectory,
imagePaths,
sendToRenderer,
}) {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
if (session.isRunning) {
throw new Error("Agent is already processing a message");
}
// Read images from temp files and convert to base64 for storage
const images = [];
if (imagePaths && imagePaths.length > 0) {
const fs = require("fs/promises");
const path = require("path");
for (const imagePath of imagePaths) {
try {
const imageBuffer = await fs.readFile(imagePath);
const base64Data = imageBuffer.toString("base64");
// Determine media type from file extension
const ext = path.extname(imagePath).toLowerCase();
const mimeTypeMap = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
};
const mediaType = mimeTypeMap[ext] || "image/png";
images.push({
data: base64Data,
mimeType: mediaType,
filename: path.basename(imagePath),
});
console.log(
`[AgentService] Loaded image from ${imagePath} for storage`
);
} catch (error) {
console.error(
`[AgentService] Failed to load image from ${imagePath}:`,
error
);
}
}
}
// Add user message to conversation with base64 images
const userMessage = {
id: this.generateId(),
role: "user",
content: message,
images: images.length > 0 ? images : undefined,
timestamp: new Date().toISOString(),
};
session.messages.push(userMessage);
session.isRunning = true;
session.abortController = new AbortController();
// Send initial user message to renderer
sendToRenderer({
type: "message",
message: userMessage,
});
// Save state with base64 images
await this.saveSession(sessionId, session.messages);
try {
// Configure Claude Agent SDK options
const options = {
// model: "claude-sonnet-4-20250514",
model: "claude-opus-4-5-20251101",
systemPrompt: this.getSystemPrompt(),
maxTurns: 20,
cwd: workingDirectory || session.workingDirectory,
allowedTools: [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
],
permissionMode: "acceptEdits",
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
abortController: session.abortController,
};
// Build prompt content with text and images
let promptContent = message;
// If there are images, create a content array
if (imagePaths && imagePaths.length > 0) {
const contentBlocks = [];
// Add text block
if (message && message.trim()) {
contentBlocks.push({
type: "text",
text: message,
});
}
// Add image blocks
const fs = require("fs");
for (const imagePath of imagePaths) {
try {
const imageBuffer = fs.readFileSync(imagePath);
const base64Data = imageBuffer.toString("base64");
const ext = path.extname(imagePath).toLowerCase();
const mimeTypeMap = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
};
const mediaType = mimeTypeMap[ext] || "image/png";
contentBlocks.push({
type: "image",
source: {
type: "base64",
media_type: mediaType,
data: base64Data,
},
});
} catch (error) {
console.error(
`[AgentService] Failed to load image ${imagePath}:`,
error
);
}
}
// Use content blocks if we have images
if (
contentBlocks.length > 1 ||
(contentBlocks.length === 1 && contentBlocks[0].type === "image")
) {
promptContent = contentBlocks;
}
}
// Build payload for the SDK
const promptPayload = Array.isArray(promptContent)
? (async function* () {
yield {
type: "user",
session_id: "",
message: {
role: "user",
content: promptContent,
},
parent_tool_use_id: null,
};
})()
: promptContent;
// Send the query via the SDK (conversation state handled by the SDK)
const stream = query({ prompt: promptPayload, options });
let currentAssistantMessage = null;
let responseText = "";
const toolUses = [];
// Stream responses from the SDK
for await (const msg of stream) {
if (msg.type === "assistant") {
if (msg.message.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
responseText += block.text;
// Create or update assistant message
if (!currentAssistantMessage) {
currentAssistantMessage = {
id: this.generateId(),
role: "assistant",
content: responseText,
timestamp: new Date().toISOString(),
};
session.messages.push(currentAssistantMessage);
} else {
currentAssistantMessage.content = responseText;
}
// Stream to renderer
sendToRenderer({
type: "stream",
messageId: currentAssistantMessage.id,
content: responseText,
isComplete: false,
});
} else if (block.type === "tool_use") {
const toolUse = {
name: block.name,
input: block.input,
};
toolUses.push(toolUse);
// Send tool use notification
sendToRenderer({
type: "tool_use",
tool: toolUse,
});
}
}
}
} else if (msg.type === "result") {
if (msg.subtype === "success" && msg.result) {
// Use the final result
if (currentAssistantMessage) {
currentAssistantMessage.content = msg.result;
responseText = msg.result;
}
}
// Send completion
sendToRenderer({
type: "complete",
messageId: currentAssistantMessage?.id,
content: responseText,
toolUses,
});
}
}
// Save final state
await this.saveSession(sessionId, session.messages);
session.isRunning = false;
session.abortController = null;
return {
success: true,
message: currentAssistantMessage,
};
} catch (error) {
if (error instanceof AbortError || error?.name === "AbortError") {
// Query aborted
session.isRunning = false;
session.abortController = null;
return { success: false, aborted: true };
}
console.error("[AgentService] Error:", error);
session.isRunning = false;
session.abortController = null;
// Add error message
const errorMessage = {
id: this.generateId(),
role: "assistant",
content: `Error: ${error.message}`,
timestamp: new Date().toISOString(),
isError: true,
};
session.messages.push(errorMessage);
await this.saveSession(sessionId, session.messages);
sendToRenderer({
type: "error",
error: error.message,
message: errorMessage,
});
throw error;
}
}
/**
* Get conversation history
*/
getHistory(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: "Session not found" };
}
return {
success: true,
messages: session.messages,
isRunning: session.isRunning,
};
}
/**
* Stop current agent execution
*/
async stopExecution(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: "Session not found" };
}
if (session.abortController) {
session.abortController.abort();
session.isRunning = false;
session.abortController = null;
}
return { success: true };
}
/**
* Clear conversation history
*/
async clearSession(sessionId) {
const session = this.sessions.get(sessionId);
if (session) {
session.messages = [];
session.isRunning = false;
await this.saveSession(sessionId, []);
}
return { success: true };
}
/**
* Load session from disk
*/
async loadSession(sessionId) {
if (!this.stateDir) return [];
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
const data = await fs.readFile(sessionFile, "utf-8");
const parsed = JSON.parse(data);
console.log(
`[AgentService] Loaded ${parsed.length} messages for ${sessionId}`
);
return parsed;
} catch (error) {
// Session doesn't exist yet
return [];
}
}
/**
* Save session to disk
*/
async saveSession(sessionId, messages) {
if (!this.stateDir) return;
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
await fs.writeFile(
sessionFile,
JSON.stringify(messages, null, 2),
"utf-8"
);
console.log(
`[AgentService] Saved ${messages.length} messages for ${sessionId}`
);
// Update timestamp
await this.updateSessionTimestamp(sessionId);
} catch (error) {
console.error("[AgentService] Failed to save session:", error);
}
}
/**
* Get system prompt
*/
getSystemPrompt() {
return `You are an AI assistant helping users build software. You are part of the Automaker application,
which is designed to help developers plan, design, and implement software projects autonomously.
**Feature Storage:**
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
Use the UpdateFeatureStatus tool to manage features, not direct file edits.
Your role is to:
- Help users define their project requirements and specifications
- Ask clarifying questions to better understand their needs
- Suggest technical approaches and architectures
- Guide them through the development process
- Be conversational and helpful
- Write, edit, and modify code files as requested
- Execute commands and tests
- Search and analyze the codebase
When discussing projects, help users think through:
- Core functionality and features
- Technical stack choices
- Data models and architecture
- User experience considerations
- Testing strategies
You have full access to the codebase and can:
- Read files to understand existing code
- Write new files
- Edit existing files
- Run bash commands
- Search for code patterns
- Execute tests and builds
IMPORTANT: When making file changes, be aware that the Next.js development server may restart.
This is normal and expected. Your conversation state is preserved across these restarts.`;
}
/**
* Generate unique ID
*/
generateId() {
return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}
// ============================================================================
// Session Management
// ============================================================================
/**
* Load all session metadata
*/
async loadMetadata() {
if (!this.metadataFile) return {};
try {
const data = await fs.readFile(this.metadataFile, "utf-8");
return JSON.parse(data);
} catch (error) {
return {};
}
}
/**
* Save session metadata
*/
async saveMetadata(metadata) {
if (!this.metadataFile) return;
try {
await fs.writeFile(
this.metadataFile,
JSON.stringify(metadata, null, 2),
"utf-8"
);
} catch (error) {
console.error("[AgentService] Failed to save metadata:", error);
}
}
/**
* List all sessions
*/
async listSessions({ includeArchived = false } = {}) {
const metadata = await this.loadMetadata();
const sessions = [];
for (const [sessionId, meta] of Object.entries(metadata)) {
if (!includeArchived && meta.isArchived) continue;
const messages = await this.loadSession(sessionId);
const lastMessage = messages[messages.length - 1];
sessions.push({
id: sessionId,
name: meta.name || sessionId,
projectPath: meta.projectPath || "",
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
messageCount: messages.length,
isArchived: meta.isArchived || false,
tags: meta.tags || [],
preview: lastMessage?.content.substring(0, 100) || "",
});
}
// Sort by most recently updated
sessions.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
return sessions;
}
/**
* Create a new session
*/
async createSession({ name, projectPath, workingDirectory }) {
const sessionId = `session_${Date.now()}_${Math.random()
.toString(36)
.substring(2, 11)}`;
const metadata = await this.loadMetadata();
metadata[sessionId] = {
name,
projectPath,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isArchived: false,
tags: [],
};
await this.saveMetadata(metadata);
this.sessions.set(sessionId, {
messages: [],
isRunning: false,
abortController: null,
workingDirectory: workingDirectory || projectPath,
});
await this.saveSession(sessionId, []);
return {
success: true,
sessionId,
session: metadata[sessionId],
};
}
/**
* Update session metadata
*/
async updateSession({ sessionId, name, tags }) {
const metadata = await this.loadMetadata();
if (!metadata[sessionId]) {
return { success: false, error: "Session not found" };
}
if (name !== undefined) metadata[sessionId].name = name;
if (tags !== undefined) metadata[sessionId].tags = tags;
metadata[sessionId].updatedAt = new Date().toISOString();
await this.saveMetadata(metadata);
return { success: true };
}
/**
* Archive a session
*/
async archiveSession(sessionId) {
const metadata = await this.loadMetadata();
if (!metadata[sessionId]) {
return { success: false, error: "Session not found" };
}
metadata[sessionId].isArchived = true;
metadata[sessionId].updatedAt = new Date().toISOString();
await this.saveMetadata(metadata);
return { success: true };
}
/**
* Unarchive a session
*/
async unarchiveSession(sessionId) {
const metadata = await this.loadMetadata();
if (!metadata[sessionId]) {
return { success: false, error: "Session not found" };
}
metadata[sessionId].isArchived = false;
metadata[sessionId].updatedAt = new Date().toISOString();
await this.saveMetadata(metadata);
return { success: true };
}
/**
* Delete a session permanently
*/
async deleteSession(sessionId) {
const metadata = await this.loadMetadata();
if (!metadata[sessionId]) {
return { success: false, error: "Session not found" };
}
// Remove from metadata
delete metadata[sessionId];
await this.saveMetadata(metadata);
// Remove from memory
this.sessions.delete(sessionId);
// Delete session file
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
await fs.unlink(sessionFile);
} catch (error) {
console.warn("[AgentService] Failed to delete session file:", error);
}
return { success: true };
}
/**
* Update session metadata when messages change
*/
async updateSessionTimestamp(sessionId) {
const metadata = await this.loadMetadata();
if (metadata[sessionId]) {
metadata[sessionId].updatedAt = new Date().toISOString();
await this.saveMetadata(metadata);
}
}
}
// Export singleton instance
module.exports = new AgentService();

File diff suppressed because it is too large Load Diff

1836
apps/app/electron/main.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,404 @@
const { contextBridge, ipcRenderer } = require("electron");
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld("electronAPI", {
// IPC test
ping: () => ipcRenderer.invoke("ping"),
// Shell APIs
openExternalLink: (url) => ipcRenderer.invoke("shell:openExternal", url),
// Dialog APIs
openDirectory: () => ipcRenderer.invoke("dialog:openDirectory"),
openFile: (options) => ipcRenderer.invoke("dialog:openFile", options),
// File system APIs
readFile: (filePath) => ipcRenderer.invoke("fs:readFile", filePath),
writeFile: (filePath, content) =>
ipcRenderer.invoke("fs:writeFile", filePath, content),
mkdir: (dirPath) => ipcRenderer.invoke("fs:mkdir", dirPath),
readdir: (dirPath) => ipcRenderer.invoke("fs:readdir", dirPath),
exists: (filePath) => ipcRenderer.invoke("fs:exists", filePath),
stat: (filePath) => ipcRenderer.invoke("fs:stat", filePath),
deleteFile: (filePath) => ipcRenderer.invoke("fs:deleteFile", filePath),
trashItem: (filePath) => ipcRenderer.invoke("fs:trashItem", filePath),
// App APIs
getPath: (name) => ipcRenderer.invoke("app:getPath", name),
saveImageToTemp: (data, filename, mimeType, projectPath) =>
ipcRenderer.invoke("app:saveImageToTemp", {
data,
filename,
mimeType,
projectPath,
}),
// Agent APIs
agent: {
// Start or resume a conversation
start: (sessionId, workingDirectory) =>
ipcRenderer.invoke("agent:start", { sessionId, workingDirectory }),
// Send a message to the agent
send: (sessionId, message, workingDirectory, imagePaths) =>
ipcRenderer.invoke("agent:send", {
sessionId,
message,
workingDirectory,
imagePaths,
}),
// Get conversation history
getHistory: (sessionId) =>
ipcRenderer.invoke("agent:getHistory", { sessionId }),
// Stop current execution
stop: (sessionId) => ipcRenderer.invoke("agent:stop", { sessionId }),
// Clear conversation
clear: (sessionId) => ipcRenderer.invoke("agent:clear", { sessionId }),
// Subscribe to streaming events
onStream: (callback) => {
const subscription = (_, data) => callback(data);
ipcRenderer.on("agent:stream", subscription);
// Return unsubscribe function
return () => ipcRenderer.removeListener("agent:stream", subscription);
},
},
// Session Management APIs
sessions: {
// List all sessions
list: (includeArchived) =>
ipcRenderer.invoke("sessions:list", { includeArchived }),
// Create a new session
create: (name, projectPath, workingDirectory) =>
ipcRenderer.invoke("sessions:create", {
name,
projectPath,
workingDirectory,
}),
// Update session metadata
update: (sessionId, name, tags) =>
ipcRenderer.invoke("sessions:update", { sessionId, name, tags }),
// Archive a session
archive: (sessionId) =>
ipcRenderer.invoke("sessions:archive", { sessionId }),
// Unarchive a session
unarchive: (sessionId) =>
ipcRenderer.invoke("sessions:unarchive", { sessionId }),
// Delete a session permanently
delete: (sessionId) => ipcRenderer.invoke("sessions:delete", { sessionId }),
},
// Auto Mode API
autoMode: {
// Start auto mode for a specific project
start: (projectPath, maxConcurrency) =>
ipcRenderer.invoke("auto-mode:start", { projectPath, maxConcurrency }),
// Stop auto mode for a specific project
stop: (projectPath) => ipcRenderer.invoke("auto-mode:stop", { projectPath }),
// Get auto mode status (optionally for a specific project)
status: (projectPath) => ipcRenderer.invoke("auto-mode:status", { projectPath }),
// Run a specific feature
runFeature: (projectPath, featureId, useWorktrees) =>
ipcRenderer.invoke("auto-mode:run-feature", {
projectPath,
featureId,
useWorktrees,
}),
// Verify a specific feature by running its tests
verifyFeature: (projectPath, featureId) =>
ipcRenderer.invoke("auto-mode:verify-feature", {
projectPath,
featureId,
}),
// Resume a specific feature with previous context
resumeFeature: (projectPath, featureId) =>
ipcRenderer.invoke("auto-mode:resume-feature", {
projectPath,
featureId,
}),
// Check if context file exists for a feature
contextExists: (projectPath, featureId) =>
ipcRenderer.invoke("auto-mode:context-exists", {
projectPath,
featureId,
}),
// Analyze a new project - kicks off an agent to analyze codebase
analyzeProject: (projectPath) =>
ipcRenderer.invoke("auto-mode:analyze-project", { projectPath }),
// Stop a specific feature
stopFeature: (featureId) =>
ipcRenderer.invoke("auto-mode:stop-feature", { featureId }),
// Follow-up on a feature with additional prompt
followUpFeature: (projectPath, featureId, prompt, imagePaths) =>
ipcRenderer.invoke("auto-mode:follow-up-feature", {
projectPath,
featureId,
prompt,
imagePaths,
}),
// Commit changes for a feature
commitFeature: (projectPath, featureId) =>
ipcRenderer.invoke("auto-mode:commit-feature", {
projectPath,
featureId,
}),
// Listen for auto mode events
onEvent: (callback) => {
const subscription = (_, data) => callback(data);
ipcRenderer.on("auto-mode:event", subscription);
// Return unsubscribe function
return () => {
ipcRenderer.removeListener("auto-mode:event", subscription);
};
},
},
// Claude CLI Detection API
checkClaudeCli: () => ipcRenderer.invoke("claude:check-cli"),
// Codex CLI Detection API
checkCodexCli: () => ipcRenderer.invoke("codex:check-cli"),
// Model Management APIs
model: {
// Get all available models from all providers
getAvailable: () => ipcRenderer.invoke("model:get-available"),
// Check all provider installation status
checkProviders: () => ipcRenderer.invoke("model:check-providers"),
},
// OpenAI API
testOpenAIConnection: (apiKey) =>
ipcRenderer.invoke("openai:test-connection", { apiKey }),
// Worktree Management APIs
worktree: {
// Revert feature changes by removing the worktree
revertFeature: (projectPath, featureId) =>
ipcRenderer.invoke("worktree:revert-feature", { projectPath, featureId }),
// Merge feature worktree changes back to main branch
mergeFeature: (projectPath, featureId, options) =>
ipcRenderer.invoke("worktree:merge-feature", {
projectPath,
featureId,
options,
}),
// Get worktree info for a feature
getInfo: (projectPath, featureId) =>
ipcRenderer.invoke("worktree:get-info", { projectPath, featureId }),
// Get worktree status (changed files, commits)
getStatus: (projectPath, featureId) =>
ipcRenderer.invoke("worktree:get-status", { projectPath, featureId }),
// List all feature worktrees
list: (projectPath) => ipcRenderer.invoke("worktree:list", { projectPath }),
// Get file diffs for a feature worktree
getDiffs: (projectPath, featureId) =>
ipcRenderer.invoke("worktree:get-diffs", { projectPath, featureId }),
// Get diff for a specific file in a worktree
getFileDiff: (projectPath, featureId, filePath) =>
ipcRenderer.invoke("worktree:get-file-diff", {
projectPath,
featureId,
filePath,
}),
},
// Git Operations APIs (for non-worktree operations)
git: {
// Get file diffs for the main project
getDiffs: (projectPath) =>
ipcRenderer.invoke("git:get-diffs", { projectPath }),
// Get diff for a specific file in the main project
getFileDiff: (projectPath, filePath) =>
ipcRenderer.invoke("git:get-file-diff", { projectPath, filePath }),
},
// Feature Suggestions API
suggestions: {
// Generate feature suggestions
// suggestionType can be: "features", "refactoring", "security", "performance"
generate: (projectPath, suggestionType = "features") =>
ipcRenderer.invoke("suggestions:generate", { projectPath, suggestionType }),
// Stop generating suggestions
stop: () => ipcRenderer.invoke("suggestions:stop"),
// Get suggestions status
status: () => ipcRenderer.invoke("suggestions:status"),
// Listen for suggestions events
onEvent: (callback) => {
const subscription = (_, data) => callback(data);
ipcRenderer.on("suggestions:event", subscription);
// Return unsubscribe function
return () => {
ipcRenderer.removeListener("suggestions:event", subscription);
};
},
},
// Spec Regeneration API
specRegeneration: {
// Create initial app spec for a new project
create: (projectPath, projectOverview, generateFeatures = true) =>
ipcRenderer.invoke("spec-regeneration:create", {
projectPath,
projectOverview,
generateFeatures,
}),
// Regenerate the app spec
generate: (projectPath, projectDefinition) =>
ipcRenderer.invoke("spec-regeneration:generate", {
projectPath,
projectDefinition,
}),
// Generate features from existing app_spec.txt
generateFeatures: (projectPath) =>
ipcRenderer.invoke("spec-regeneration:generate-features", {
projectPath,
}),
// Stop regenerating spec
stop: () => ipcRenderer.invoke("spec-regeneration:stop"),
// Get regeneration status
status: () => ipcRenderer.invoke("spec-regeneration:status"),
// Listen for regeneration events
onEvent: (callback) => {
const subscription = (_, data) => callback(data);
ipcRenderer.on("spec-regeneration:event", subscription);
// Return unsubscribe function
return () => {
ipcRenderer.removeListener("spec-regeneration:event", subscription);
};
},
},
// Setup & CLI Management API
setup: {
// Get comprehensive Claude CLI status
getClaudeStatus: () => ipcRenderer.invoke("setup:claude-status"),
// Get comprehensive Codex CLI status
getCodexStatus: () => ipcRenderer.invoke("setup:codex-status"),
// Install Claude CLI
installClaude: () => ipcRenderer.invoke("setup:install-claude"),
// Install Codex CLI
installCodex: () => ipcRenderer.invoke("setup:install-codex"),
// Authenticate Claude CLI
authClaude: () => ipcRenderer.invoke("setup:auth-claude"),
// Authenticate Codex CLI with optional API key
authCodex: (apiKey) => ipcRenderer.invoke("setup:auth-codex", { apiKey }),
// Store API key securely
storeApiKey: (provider, apiKey) =>
ipcRenderer.invoke("setup:store-api-key", { provider, apiKey }),
// Get stored API keys status
getApiKeys: () => ipcRenderer.invoke("setup:get-api-keys"),
// Configure Codex MCP server for a project
configureCodexMcp: (projectPath) =>
ipcRenderer.invoke("setup:configure-codex-mcp", { projectPath }),
// Get platform information
getPlatform: () => ipcRenderer.invoke("setup:get-platform"),
// Listen for installation progress
onInstallProgress: (callback) => {
const subscription = (_, data) => callback(data);
ipcRenderer.on("setup:install-progress", subscription);
return () => {
ipcRenderer.removeListener("setup:install-progress", subscription);
};
},
// Listen for auth progress
onAuthProgress: (callback) => {
const subscription = (_, data) => callback(data);
ipcRenderer.on("setup:auth-progress", subscription);
return () => {
ipcRenderer.removeListener("setup:auth-progress", subscription);
};
},
},
// Features API
features: {
// Get all features for a project
getAll: (projectPath) =>
ipcRenderer.invoke("features:getAll", { projectPath }),
// Get a single feature by ID
get: (projectPath, featureId) =>
ipcRenderer.invoke("features:get", { projectPath, featureId }),
// Create a new feature
create: (projectPath, feature) =>
ipcRenderer.invoke("features:create", { projectPath, feature }),
// Update a feature (partial updates supported)
update: (projectPath, featureId, updates) =>
ipcRenderer.invoke("features:update", {
projectPath,
featureId,
updates,
}),
// Delete a feature and its folder
delete: (projectPath, featureId) =>
ipcRenderer.invoke("features:delete", { projectPath, featureId }),
// Get agent output for a feature
getAgentOutput: (projectPath, featureId) =>
ipcRenderer.invoke("features:getAgentOutput", { projectPath, featureId }),
},
// Running Agents API
runningAgents: {
// Get all running agents across all projects
getAll: () => ipcRenderer.invoke("running-agents:getAll"),
},
});
// Also expose a flag to detect if we're in Electron
contextBridge.exposeInMainWorld("isElectron", true);

View File

@@ -0,0 +1,721 @@
const { execSync, spawn } = require("child_process");
const fs = require("fs");
const path = require("path");
const os = require("os");
let runPtyCommand = null;
try {
({ runPtyCommand } = require("./pty-runner"));
} catch (error) {
console.warn(
"[ClaudeCliDetector] node-pty unavailable, will fall back to external terminal:",
error?.message || error
);
}
const ANSI_REGEX =
// eslint-disable-next-line no-control-regex
/\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b[@-_]|\u001b\][^\u0007]*\u0007/g;
const stripAnsi = (text = "") => text.replace(ANSI_REGEX, "");
/**
* Claude CLI Detector
*
* Authentication options:
* 1. OAuth Token (Subscription): User runs `claude setup-token` and provides the token to the app
* 2. API Key (Pay-per-use): User provides their Anthropic API key directly
*/
class ClaudeCliDetector {
/**
* Check if Claude Code CLI is installed and accessible
* @returns {Object} { installed: boolean, path: string|null, version: string|null, method: 'cli'|'none' }
*/
/**
* Try to get updated PATH from shell config files
* This helps detect CLI installations that modify shell config but haven't updated the current process PATH
*/
static getUpdatedPathFromShellConfig() {
const homeDir = os.homedir();
const shell = process.env.SHELL || "/bin/bash";
const shellName = path.basename(shell);
const configFiles = [];
if (shellName.includes("zsh")) {
configFiles.push(path.join(homeDir, ".zshrc"));
configFiles.push(path.join(homeDir, ".zshenv"));
configFiles.push(path.join(homeDir, ".zprofile"));
} else if (shellName.includes("bash")) {
configFiles.push(path.join(homeDir, ".bashrc"));
configFiles.push(path.join(homeDir, ".bash_profile"));
configFiles.push(path.join(homeDir, ".profile"));
}
const commonPaths = [
path.join(homeDir, ".local", "bin"),
path.join(homeDir, ".cargo", "bin"),
"/usr/local/bin",
"/opt/homebrew/bin",
path.join(homeDir, "bin"),
];
for (const configFile of configFiles) {
if (fs.existsSync(configFile)) {
try {
const content = fs.readFileSync(configFile, "utf-8");
const pathMatches = content.match(
/export\s+PATH=["']?([^"'\n]+)["']?/g
);
if (pathMatches) {
for (const match of pathMatches) {
const pathValue = match
.replace(/export\s+PATH=["']?/, "")
.replace(/["']?$/, "");
const paths = pathValue
.split(":")
.filter((p) => p && !p.includes("$"));
commonPaths.push(...paths);
}
}
} catch (error) {
// Ignore errors reading config files
}
}
}
return [...new Set(commonPaths)];
}
static detectClaudeInstallation() {
try {
// Check if 'claude' command is in PATH (Unix)
if (process.platform !== "win32") {
try {
const claudePath = execSync("which claude 2>/dev/null", {
encoding: "utf-8",
}).trim();
if (claudePath) {
const version = this.getClaudeVersion(claudePath);
return {
installed: true,
path: claudePath,
version: version,
method: "cli",
};
}
} catch (error) {
// CLI not in PATH
}
}
// Check Windows path
if (process.platform === "win32") {
try {
const claudePath = execSync("where claude 2>nul", {
encoding: "utf-8",
})
.trim()
.split("\n")[0];
if (claudePath) {
const version = this.getClaudeVersion(claudePath);
return {
installed: true,
path: claudePath,
version: version,
method: "cli",
};
}
} catch (error) {
// Not found on Windows
}
}
// Check for local installation
const localClaudePath = path.join(
os.homedir(),
".claude",
"local",
"claude"
);
if (fs.existsSync(localClaudePath)) {
const version = this.getClaudeVersion(localClaudePath);
return {
installed: true,
path: localClaudePath,
version: version,
method: "cli-local",
};
}
// Check common installation locations
const commonPaths = this.getUpdatedPathFromShellConfig();
const binaryNames = ["claude", "claude-code"];
for (const basePath of commonPaths) {
for (const binaryName of binaryNames) {
const claudePath = path.join(basePath, binaryName);
if (fs.existsSync(claudePath)) {
try {
const version = this.getClaudeVersion(claudePath);
return {
installed: true,
path: claudePath,
version: version,
method: "cli",
};
} catch (error) {
// File exists but can't get version
}
}
}
}
// Try to source shell config and check PATH again (Unix)
if (process.platform !== "win32") {
try {
const shell = process.env.SHELL || "/bin/bash";
const shellName = path.basename(shell);
const homeDir = os.homedir();
let sourceCmd = "";
if (shellName.includes("zsh")) {
sourceCmd = `source ${homeDir}/.zshrc 2>/dev/null && which claude`;
} else if (shellName.includes("bash")) {
sourceCmd = `source ${homeDir}/.bashrc 2>/dev/null && which claude`;
}
if (sourceCmd) {
const claudePath = execSync(`bash -c "${sourceCmd}"`, {
encoding: "utf-8",
timeout: 2000,
}).trim();
if (claudePath && claudePath.startsWith("/")) {
const version = this.getClaudeVersion(claudePath);
return {
installed: true,
path: claudePath,
version: version,
method: "cli",
};
}
}
} catch (error) {
// Failed to source shell config
}
}
return {
installed: false,
path: null,
version: null,
method: "none",
};
} catch (error) {
return {
installed: false,
path: null,
version: null,
method: "none",
error: error.message,
};
}
}
/**
* Get Claude CLI version
* @param {string} claudePath Path to claude executable
* @returns {string|null} Version string or null
*/
static getClaudeVersion(claudePath) {
try {
const version = execSync(`"${claudePath}" --version 2>/dev/null`, {
encoding: "utf-8",
timeout: 5000,
}).trim();
return version || null;
} catch (error) {
return null;
}
}
/**
* Get authentication status
* Checks for:
* 1. OAuth token stored in app's credentials (from `claude setup-token`)
* 2. API key stored in app's credentials
* 3. API key in environment variable
*
* @param {string} appCredentialsPath Path to app's credentials.json
* @returns {Object} Authentication status
*/
static getAuthStatus(appCredentialsPath) {
const envApiKey = process.env.ANTHROPIC_API_KEY;
const envOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
let storedOAuthToken = null;
let storedApiKey = null;
if (appCredentialsPath && fs.existsSync(appCredentialsPath)) {
try {
const content = fs.readFileSync(appCredentialsPath, "utf-8");
const credentials = JSON.parse(content);
storedOAuthToken = credentials.anthropic_oauth_token || null;
storedApiKey =
credentials.anthropic || credentials.anthropic_api_key || null;
} catch (error) {
// Ignore credential read errors
}
}
// Authentication priority (highest to lowest):
// 1. Environment OAuth Token (CLAUDE_CODE_OAUTH_TOKEN)
// 2. Stored OAuth Token (from credentials file)
// 3. Stored API Key (from credentials file)
// 4. Environment API Key (ANTHROPIC_API_KEY)
let authenticated = false;
let method = "none";
if (envOAuthToken) {
authenticated = true;
method = "oauth_token_env";
} else if (storedOAuthToken) {
authenticated = true;
method = "oauth_token";
} else if (storedApiKey) {
authenticated = true;
method = "api_key";
} else if (envApiKey) {
authenticated = true;
method = "api_key_env";
}
return {
authenticated,
method,
hasStoredOAuthToken: !!storedOAuthToken,
hasStoredApiKey: !!storedApiKey,
hasEnvApiKey: !!envApiKey,
hasEnvOAuthToken: !!envOAuthToken,
};
}
/**
* Get installation info (installation status only, no auth)
* @returns {Object} Installation info with status property
*/
static getInstallationInfo() {
const installation = this.detectClaudeInstallation();
return {
status: installation.installed ? "installed" : "not_installed",
installed: installation.installed,
path: installation.path,
version: installation.version,
method: installation.method,
};
}
/**
* Get full status including installation and auth
* @param {string} appCredentialsPath Path to app's credentials.json
* @returns {Object} Full status
*/
static getFullStatus(appCredentialsPath) {
const installation = this.detectClaudeInstallation();
const auth = this.getAuthStatus(appCredentialsPath);
return {
success: true,
status: installation.installed ? "installed" : "not_installed",
installed: installation.installed,
path: installation.path,
version: installation.version,
method: installation.method,
auth,
};
}
/**
* Get installation info and recommendations
* @returns {Object} Installation status and recommendations
*/
static getInstallationInfo() {
const detection = this.detectClaudeInstallation();
if (detection.installed) {
return {
status: 'installed',
method: detection.method,
version: detection.version,
path: detection.path,
recommendation: 'Claude Code CLI is ready for ultrathink'
};
}
return {
status: 'not_installed',
recommendation: 'Install Claude Code CLI for optimal ultrathink performance',
installCommands: this.getInstallCommands()
};
}
/**
* Get installation commands for different platforms
* @returns {Object} Installation commands
*/
static getInstallCommands() {
return {
macos: "curl -fsSL https://claude.ai/install.sh | bash",
windows: "irm https://claude.ai/install.ps1 | iex",
linux: "curl -fsSL https://claude.ai/install.sh | bash",
};
}
/**
* Install Claude CLI using the official script
* @param {Function} onProgress Callback for progress updates
* @returns {Promise<Object>} Installation result
*/
static async installCli(onProgress) {
return new Promise((resolve, reject) => {
const platform = process.platform;
let command, args;
if (platform === "win32") {
command = "powershell";
args = ["-Command", "irm https://claude.ai/install.ps1 | iex"];
} else {
command = "bash";
args = ["-c", "curl -fsSL https://claude.ai/install.sh | bash"];
}
console.log("[ClaudeCliDetector] Installing Claude CLI...");
const proc = spawn(command, args, {
stdio: ["pipe", "pipe", "pipe"],
shell: false,
});
let output = "";
let errorOutput = "";
proc.stdout.on("data", (data) => {
const text = data.toString();
output += text;
if (onProgress) {
onProgress({ type: "stdout", data: text });
}
});
proc.stderr.on("data", (data) => {
const text = data.toString();
errorOutput += text;
if (onProgress) {
onProgress({ type: "stderr", data: text });
}
});
proc.on("close", (code) => {
if (code === 0) {
console.log(
"[ClaudeCliDetector] Installation completed successfully"
);
resolve({
success: true,
output,
message: "Claude CLI installed successfully",
});
} else {
console.error(
"[ClaudeCliDetector] Installation failed with code:",
code
);
reject({
success: false,
error: errorOutput || `Installation failed with code ${code}`,
output,
});
}
});
proc.on("error", (error) => {
console.error("[ClaudeCliDetector] Installation error:", error);
reject({
success: false,
error: error.message,
output,
});
});
});
}
/**
* Get instructions for setup-token command
* @returns {Object} Setup token instructions
*/
static getSetupTokenInstructions() {
const detection = this.detectClaudeInstallation();
if (!detection.installed) {
return {
success: false,
error: "Claude CLI is not installed. Please install it first.",
installCommands: this.getInstallCommands(),
};
}
return {
success: true,
command: "claude setup-token",
instructions: [
"1. Open your terminal",
"2. Run: claude setup-token",
"3. Follow the prompts to authenticate",
"4. Copy the token that is displayed",
"5. Paste the token in the field below",
],
note: "This token is from your Claude subscription and allows you to use Claude without API charges.",
};
}
/**
* Extract OAuth token from command output
* Tries multiple patterns to find the token
* @param {string} output The command output
* @returns {string|null} Extracted token or null
*/
static extractTokenFromOutput(output) {
// Pattern 1: CLAUDE_CODE_OAUTH_TOKEN=<token> or CLAUDE_CODE_OAUTH_TOKEN: <token>
const envMatch = output.match(
/CLAUDE_CODE_OAUTH_TOKEN[=:]\s*["']?([a-zA-Z0-9_\-\.]+)["']?/i
);
if (envMatch) return envMatch[1];
// Pattern 2: "Token: <token>" or "token: <token>"
const tokenLabelMatch = output.match(
/\btoken[:\s]+["']?([a-zA-Z0-9_\-\.]{40,})["']?/i
);
if (tokenLabelMatch) return tokenLabelMatch[1];
// Pattern 3: Look for token after success/authenticated message
const successMatch = output.match(
/(?:success|authenticated|generated|token is)[^\n]*\n\s*([a-zA-Z0-9_\-\.]{40,})/i
);
if (successMatch) return successMatch[1];
// Pattern 4: Standalone long alphanumeric string on its own line (last resort)
// This catches tokens that are printed on their own line
const lines = output.split("\n");
for (const line of lines) {
const trimmed = line.trim();
// Token should be 40+ chars, alphanumeric with possible hyphens/underscores/dots
if (/^[a-zA-Z0-9_\-\.]{40,}$/.test(trimmed)) {
return trimmed;
}
}
return null;
}
/**
* Run claude setup-token command to generate OAuth token
* Opens an external terminal window since Claude CLI requires TTY for its Ink-based UI
* @param {Function} onProgress Callback for progress updates
* @returns {Promise<Object>} Result indicating terminal was opened
*/
static async runSetupToken(onProgress) {
const detection = this.detectClaudeInstallation();
if (!detection.installed) {
throw {
success: false,
error: "Claude CLI is not installed. Please install it first.",
requiresManualAuth: false,
};
}
const claudePath = detection.path;
const platform = process.platform;
const preferPty =
(platform === "win32" ||
platform === "darwin" ||
process.env.CLAUDE_AUTH_FORCE_PTY === "1") &&
process.env.CLAUDE_AUTH_DISABLE_PTY !== "1";
const send = (data) => {
if (onProgress && data) {
onProgress({ type: "stdout", data });
}
};
if (preferPty && runPtyCommand) {
try {
send("Starting in-app terminal session for Claude auth...\n");
send("If your browser opens, complete sign-in and return here.\n\n");
const ptyResult = await runPtyCommand(claudePath, ["setup-token"], {
cols: 120,
rows: 30,
onData: (chunk) => send(chunk),
env: {
FORCE_COLOR: "1",
},
});
const cleanedOutput = stripAnsi(ptyResult.output || "");
const token = this.extractTokenFromOutput(cleanedOutput);
if (ptyResult.success && token) {
send("\nCaptured token automatically.\n");
return {
success: true,
token,
requiresManualAuth: false,
terminalOpened: false,
};
}
if (ptyResult.success && !token) {
send(
"\nCLI completed but token was not detected automatically. You can copy it above or retry.\n"
);
return {
success: true,
requiresManualAuth: true,
terminalOpened: false,
error: "Could not capture token automatically",
output: cleanedOutput,
};
}
send(
`\nClaude CLI exited with code ${ptyResult.exitCode}. Falling back to manual copy.\n`
);
return {
success: false,
error: `Claude CLI exited with code ${ptyResult.exitCode}`,
requiresManualAuth: true,
output: cleanedOutput,
};
} catch (error) {
console.error("[ClaudeCliDetector] PTY auth failed, falling back:", error);
send(
`In-app terminal failed (${error?.message || "unknown error"}). Falling back to external terminal...\n`
);
}
}
// Fallback: external terminal window
if (preferPty && !runPtyCommand) {
send("In-app terminal unavailable (node-pty not loaded).");
} else if (!preferPty) {
send("Using system terminal for authentication on this platform.");
}
send("Opening system terminal for authentication...\n");
// Helper function to check if a command exists asynchronously
const commandExists = (cmd) => {
return new Promise((resolve) => {
require("child_process").exec(
`which ${cmd}`,
{ timeout: 1000 },
(error) => {
resolve(!error);
}
);
});
};
// For Linux, find available terminal first (async)
let linuxTerminal = null;
if (platform !== "win32" && platform !== "darwin") {
const terminals = [
["gnome-terminal", ["--", claudePath, "setup-token"]],
["konsole", ["-e", claudePath, "setup-token"]],
["xterm", ["-e", claudePath, "setup-token"]],
["x-terminal-emulator", ["-e", `${claudePath} setup-token`]],
];
for (const [term, termArgs] of terminals) {
const exists = await commandExists(term);
if (exists) {
linuxTerminal = { command: term, args: termArgs };
break;
}
}
}
return new Promise((resolve, reject) => {
// Open command in external terminal since Claude CLI requires TTY
let command, args;
if (platform === "win32") {
// Windows: Open new cmd window that stays open
command = "cmd";
args = ["/c", "start", "cmd", "/k", `"${claudePath}" setup-token`];
} else if (platform === "darwin") {
// macOS: Open Terminal.app
command = "osascript";
args = [
"-e",
`tell application "Terminal" to do script "${claudePath} setup-token"`,
"-e",
'tell application "Terminal" to activate',
];
} else {
// Linux: Use the terminal we found earlier
if (!linuxTerminal) {
reject({
success: false,
error:
"Could not find a terminal emulator. Please run 'claude setup-token' manually in your terminal.",
requiresManualAuth: true,
});
return;
}
command = linuxTerminal.command;
args = linuxTerminal.args;
}
console.log(
"[ClaudeCliDetector] Spawning terminal:",
command,
args.join(" ")
);
const proc = spawn(command, args, {
detached: true,
stdio: "ignore",
shell: platform === "win32",
});
proc.unref();
proc.on("error", (error) => {
console.error("[ClaudeCliDetector] Failed to open terminal:", error);
reject({
success: false,
error: `Failed to open terminal: ${error.message}`,
requiresManualAuth: true,
});
});
// Give the terminal a moment to open
setTimeout(() => {
send("Terminal window opened!\n\n");
send("1. Complete the sign-in in your browser\n");
send("2. Copy the token from the terminal\n");
send("3. Paste it below\n");
// Resolve with manual auth required since we can't capture from external terminal
resolve({
success: true,
requiresManualAuth: true,
terminalOpened: true,
message:
"Terminal opened. Complete authentication and paste the token below.",
});
}, 500);
});
}
}
module.exports = ClaudeCliDetector;

View File

@@ -0,0 +1,566 @@
const { execSync, spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
/**
* Codex CLI Detector - Checks if OpenAI Codex CLI is installed
*
* Codex CLI is OpenAI's agent CLI tool that allows users to use
* GPT-5.1 Codex models (gpt-5.1-codex-max, gpt-5.1-codex, etc.)
* for code generation and agentic tasks.
*/
class CodexCliDetector {
/**
* Get the path to Codex config directory
* @returns {string} Path to .codex directory
*/
static getConfigDir() {
return path.join(os.homedir(), '.codex');
}
/**
* Get the path to Codex auth file
* @returns {string} Path to auth.json
*/
static getAuthPath() {
return path.join(this.getConfigDir(), 'auth.json');
}
/**
* Check Codex authentication status
* @returns {Object} Authentication status
*/
static checkAuth() {
try {
const authPath = this.getAuthPath();
const envApiKey = process.env.OPENAI_API_KEY;
// Try to verify authentication using codex CLI command if available
try {
const detection = this.detectCodexInstallation();
if (detection.installed) {
try {
const statusOutput = execSync(`"${detection.path || 'codex'}" login status 2>/dev/null`, {
encoding: 'utf-8',
timeout: 5000
});
if (statusOutput && (statusOutput.includes('Logged in') || statusOutput.includes('Authenticated'))) {
return {
authenticated: true,
method: 'cli_verified',
hasAuthFile: fs.existsSync(authPath),
hasEnvKey: !!envApiKey,
authPath
};
}
} catch (statusError) {
// status command failed, continue with file-based check
}
}
} catch (verifyError) {
// CLI verification failed, continue with file-based check
}
// Check if auth file exists
if (fs.existsSync(authPath)) {
let auth = null;
try {
const content = fs.readFileSync(authPath, 'utf-8');
auth = JSON.parse(content);
// Check for token object structure
if (auth.token && typeof auth.token === 'object') {
const token = auth.token;
if (token.Id_token || token.access_token || token.refresh_token || token.id_token) {
return {
authenticated: true,
method: 'cli_tokens',
hasAuthFile: true,
hasEnvKey: !!envApiKey,
authPath
};
}
}
// Check for tokens at root level
if (auth.access_token || auth.refresh_token || auth.Id_token || auth.id_token) {
return {
authenticated: true,
method: 'cli_tokens',
hasAuthFile: true,
hasEnvKey: !!envApiKey,
authPath
};
}
// Check for API key fields
if (auth.api_key || auth.openai_api_key || auth.apiKey) {
return {
authenticated: true,
method: 'auth_file',
hasAuthFile: true,
hasEnvKey: !!envApiKey,
authPath
};
}
} catch (error) {
return {
authenticated: false,
method: 'none',
hasAuthFile: false,
hasEnvKey: !!envApiKey,
authPath
};
}
if (!auth) {
return {
authenticated: false,
method: 'none',
hasAuthFile: true,
hasEnvKey: !!envApiKey,
authPath
};
}
const keys = Object.keys(auth);
if (keys.length > 0) {
const hasTokens = keys.some(key =>
key.toLowerCase().includes('token') ||
key.toLowerCase().includes('refresh') ||
(auth[key] && typeof auth[key] === 'object' && (
auth[key].access_token || auth[key].refresh_token || auth[key].Id_token || auth[key].id_token
))
);
if (hasTokens) {
return {
authenticated: true,
method: 'cli_tokens',
hasAuthFile: true,
hasEnvKey: !!envApiKey,
authPath
};
}
// File exists and has content - check if it's tokens or API key
const likelyTokens = keys.some(key => key.toLowerCase().includes('token') || key.toLowerCase().includes('refresh'));
return {
authenticated: true,
method: likelyTokens ? 'cli_tokens' : 'auth_file',
hasAuthFile: true,
hasEnvKey: !!envApiKey,
authPath
};
}
}
// Check environment variable
if (envApiKey) {
return {
authenticated: true,
method: 'env_var',
hasAuthFile: false,
hasEnvKey: true,
authPath
};
}
return {
authenticated: false,
method: 'none',
hasAuthFile: false,
hasEnvKey: false,
authPath
};
} catch (error) {
return {
authenticated: false,
method: 'none',
error: error.message
};
}
}
/**
* Check if Codex CLI is installed and accessible
* @returns {Object} { installed: boolean, path: string|null, version: string|null, method: 'cli'|'npm'|'brew'|'none' }
*/
static detectCodexInstallation() {
try {
// Method 1: Check if 'codex' command is in PATH
try {
const codexPath = execSync('which codex 2>/dev/null', { encoding: 'utf-8' }).trim();
if (codexPath) {
const version = this.getCodexVersion(codexPath);
return {
installed: true,
path: codexPath,
version: version,
method: 'cli'
};
}
} catch (error) {
// CLI not in PATH, continue checking other methods
}
// Method 2: Check for npm global installation
try {
const npmListOutput = execSync('npm list -g @openai/codex --depth=0 2>/dev/null', { encoding: 'utf-8' });
if (npmListOutput && npmListOutput.includes('@openai/codex')) {
// Get the path from npm bin
const npmBinPath = execSync('npm bin -g', { encoding: 'utf-8' }).trim();
const codexPath = path.join(npmBinPath, 'codex');
const version = this.getCodexVersion(codexPath);
return {
installed: true,
path: codexPath,
version: version,
method: 'npm'
};
}
} catch (error) {
// npm global not found
}
// Method 3: Check for Homebrew installation on macOS
if (process.platform === 'darwin') {
try {
const brewList = execSync('brew list --formula 2>/dev/null', { encoding: 'utf-8' });
if (brewList.includes('codex')) {
const brewPrefixOutput = execSync('brew --prefix codex 2>/dev/null', { encoding: 'utf-8' }).trim();
const codexPath = path.join(brewPrefixOutput, 'bin', 'codex');
const version = this.getCodexVersion(codexPath);
return {
installed: true,
path: codexPath,
version: version,
method: 'brew'
};
}
} catch (error) {
// Homebrew not found or codex not installed via brew
}
}
// Method 4: Check Windows path
if (process.platform === 'win32') {
try {
const codexPath = execSync('where codex 2>nul', { encoding: 'utf-8' }).trim().split('\n')[0];
if (codexPath) {
const version = this.getCodexVersion(codexPath);
return {
installed: true,
path: codexPath,
version: version,
method: 'cli'
};
}
} catch (error) {
// Not found on Windows
}
}
// Method 5: Check common installation paths
const commonPaths = [
path.join(os.homedir(), '.local', 'bin', 'codex'),
path.join(os.homedir(), '.npm-global', 'bin', 'codex'),
'/usr/local/bin/codex',
'/opt/homebrew/bin/codex',
];
for (const checkPath of commonPaths) {
if (fs.existsSync(checkPath)) {
const version = this.getCodexVersion(checkPath);
return {
installed: true,
path: checkPath,
version: version,
method: 'cli'
};
}
}
// Method 6: Check if OPENAI_API_KEY is set (can use Codex API directly)
if (process.env.OPENAI_API_KEY) {
return {
installed: false,
path: null,
version: null,
method: 'api-key-only',
hasApiKey: true
};
}
return {
installed: false,
path: null,
version: null,
method: 'none'
};
} catch (error) {
// Error detecting Codex installation
return {
installed: false,
path: null,
version: null,
method: 'none',
error: error.message
};
}
}
/**
* Get Codex CLI version from executable path
* @param {string} codexPath Path to codex executable
* @returns {string|null} Version string or null
*/
static getCodexVersion(codexPath) {
try {
const version = execSync(`"${codexPath}" --version 2>/dev/null`, { encoding: 'utf-8' }).trim();
return version || null;
} catch (error) {
return null;
}
}
/**
* Get installation info and recommendations
* @returns {Object} Installation status and recommendations
*/
static getInstallationInfo() {
const detection = this.detectCodexInstallation();
if (detection.installed) {
return {
status: 'installed',
method: detection.method,
version: detection.version,
path: detection.path,
recommendation: detection.method === 'cli'
? 'Using Codex CLI - ready for GPT-5.1 Codex models'
: `Using Codex CLI via ${detection.method} - ready for GPT-5.1 Codex models`
};
}
// Not installed but has API key
if (detection.method === 'api-key-only') {
return {
status: 'api_key_only',
method: 'api-key-only',
recommendation: 'OPENAI_API_KEY detected but Codex CLI not installed. Install Codex CLI for full agentic capabilities.',
installCommands: this.getInstallCommands()
};
}
return {
status: 'not_installed',
recommendation: 'Install OpenAI Codex CLI to use GPT-5.1 Codex models for agentic tasks',
installCommands: this.getInstallCommands()
};
}
/**
* Get installation commands for different platforms
* @returns {Object} Installation commands by platform
*/
static getInstallCommands() {
return {
npm: 'npm install -g @openai/codex@latest',
macos: 'brew install codex',
linux: 'npm install -g @openai/codex@latest',
windows: 'npm install -g @openai/codex@latest'
};
}
/**
* Check if Codex CLI supports a specific model
* @param {string} model Model name to check
* @returns {boolean} Whether the model is supported
*/
static isModelSupported(model) {
const supportedModels = [
'gpt-5.1-codex-max',
'gpt-5.1-codex',
'gpt-5.1-codex-mini',
'gpt-5.1'
];
return supportedModels.includes(model);
}
/**
* Get default model for Codex CLI
* @returns {string} Default model name
*/
static getDefaultModel() {
return 'gpt-5.1-codex-max';
}
/**
* Get comprehensive installation info including auth status
* @returns {Object} Full status object
*/
static getFullStatus() {
const installation = this.detectCodexInstallation();
const auth = this.checkAuth();
const info = this.getInstallationInfo();
return {
...info,
auth,
installation
};
}
/**
* Install Codex CLI using npm
* @param {Function} onProgress Callback for progress updates
* @returns {Promise<Object>} Installation result
*/
static async installCli(onProgress) {
return new Promise((resolve, reject) => {
const command = 'npm';
const args = ['install', '-g', '@openai/codex@latest'];
const proc = spawn(command, args, {
stdio: ['pipe', 'pipe', 'pipe'],
shell: true
});
let output = '';
let errorOutput = '';
proc.stdout.on('data', (data) => {
const text = data.toString();
output += text;
if (onProgress) {
onProgress({ type: 'stdout', data: text });
}
});
proc.stderr.on('data', (data) => {
const text = data.toString();
errorOutput += text;
// npm often outputs progress to stderr
if (onProgress) {
onProgress({ type: 'stderr', data: text });
}
});
proc.on('close', (code) => {
if (code === 0) {
resolve({
success: true,
output,
message: 'Codex CLI installed successfully'
});
} else {
reject({
success: false,
error: errorOutput || `Installation failed with code ${code}`,
output
});
}
});
proc.on('error', (error) => {
reject({
success: false,
error: error.message,
output
});
});
});
}
/**
* Authenticate Codex CLI - opens browser for OAuth or stores API key
* @param {string} apiKey Optional API key to store
* @param {Function} onProgress Callback for progress updates
* @returns {Promise<Object>} Authentication result
*/
static async authenticate(apiKey, onProgress) {
return new Promise((resolve, reject) => {
const detection = this.detectCodexInstallation();
if (!detection.installed) {
reject({
success: false,
error: 'Codex CLI is not installed'
});
return;
}
const codexPath = detection.path || 'codex';
if (apiKey) {
// Store API key directly using codex auth command
const proc = spawn(codexPath, ['auth', 'login', '--api-key', apiKey], {
stdio: ['pipe', 'pipe', 'pipe'],
shell: false
});
let output = '';
let errorOutput = '';
proc.stdout.on('data', (data) => {
const text = data.toString();
output += text;
if (onProgress) {
onProgress({ type: 'stdout', data: text });
}
});
proc.stderr.on('data', (data) => {
const text = data.toString();
errorOutput += text;
if (onProgress) {
onProgress({ type: 'stderr', data: text });
}
});
proc.on('close', (code) => {
if (code === 0) {
resolve({
success: true,
output,
message: 'Codex CLI authenticated successfully'
});
} else {
reject({
success: false,
error: errorOutput || `Authentication failed with code ${code}`,
output
});
}
});
proc.on('error', (error) => {
reject({
success: false,
error: error.message,
output
});
});
} else {
// Require manual authentication
if (onProgress) {
onProgress({
type: 'info',
data: 'Please run the following command in your terminal to authenticate:\n\ncodex auth login\n\nThen return here to continue setup.'
});
}
resolve({
success: true,
requiresManualAuth: true,
command: `${codexPath} auth login`,
message: 'Please authenticate Codex CLI manually'
});
}
});
}
}
module.exports = CodexCliDetector;

View File

@@ -0,0 +1,353 @@
/**
* Codex TOML Configuration Manager
*
* Manages Codex CLI's TOML configuration file to add/update MCP server settings.
* Codex CLI looks for config at:
* - ~/.codex/config.toml (user-level)
* - .codex/config.toml (project-level, takes precedence)
*/
const fs = require('fs/promises');
const path = require('path');
const os = require('os');
class CodexConfigManager {
constructor() {
this.userConfigPath = path.join(os.homedir(), '.codex', 'config.toml');
this.projectConfigPath = null; // Will be set per project
}
/**
* Set the project path for project-level config
*/
setProjectPath(projectPath) {
this.projectConfigPath = path.join(projectPath, '.codex', 'config.toml');
}
/**
* Get the effective config path (project-level if exists, otherwise user-level)
*/
async getConfigPath() {
if (this.projectConfigPath) {
try {
await fs.access(this.projectConfigPath);
return this.projectConfigPath;
} catch (e) {
// Project config doesn't exist, fall back to user config
}
}
// Ensure user config directory exists
const userConfigDir = path.dirname(this.userConfigPath);
try {
await fs.mkdir(userConfigDir, { recursive: true });
} catch (e) {
// Directory might already exist
}
return this.userConfigPath;
}
/**
* Read existing TOML config (simple parser for our needs)
*/
async readConfig(configPath) {
try {
const content = await fs.readFile(configPath, 'utf-8');
return this.parseToml(content);
} catch (e) {
if (e.code === 'ENOENT') {
return {};
}
throw e;
}
}
/**
* Simple TOML parser for our specific use case
* This is a minimal parser that handles the MCP server config structure
*/
parseToml(content) {
const config = {};
let currentSection = null;
let currentSubsection = null;
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
// Skip comments and empty lines
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
// Section header: [section]
const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
const sectionName = sectionMatch[1];
const parts = sectionName.split('.');
if (parts.length === 1) {
currentSection = parts[0];
currentSubsection = null;
if (!config[currentSection]) {
config[currentSection] = {};
}
} else if (parts.length === 2) {
currentSection = parts[0];
currentSubsection = parts[1];
if (!config[currentSection]) {
config[currentSection] = {};
}
if (!config[currentSection][currentSubsection]) {
config[currentSection][currentSubsection] = {};
}
}
continue;
}
// Key-value pair: key = value
const kvMatch = trimmed.match(/^([^=]+)=(.+)$/);
if (kvMatch) {
const key = kvMatch[1].trim();
let value = kvMatch[2].trim();
// Remove quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
// Parse boolean
if (value === 'true') value = true;
else if (value === 'false') value = false;
// Parse number
else if (/^-?\d+$/.test(value)) value = parseInt(value, 10);
else if (/^-?\d+\.\d+$/.test(value)) value = parseFloat(value);
if (currentSubsection) {
if (!config[currentSection][currentSubsection]) {
config[currentSection][currentSubsection] = {};
}
config[currentSection][currentSubsection][key] = value;
} else if (currentSection) {
if (!config[currentSection]) {
config[currentSection] = {};
}
config[currentSection][key] = value;
} else {
config[key] = value;
}
}
}
return config;
}
/**
* Convert config object back to TOML format
*/
stringifyToml(config, indent = 0) {
const indentStr = ' '.repeat(indent);
let result = '';
for (const [key, value] of Object.entries(config)) {
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// Section
result += `${indentStr}[${key}]\n`;
result += this.stringifyToml(value, indent);
} else {
// Key-value
let valueStr = value;
if (typeof value === 'string') {
// Escape quotes and wrap in quotes if needed
if (value.includes('"') || value.includes("'") || value.includes(' ')) {
valueStr = `"${value.replace(/"/g, '\\"')}"`;
}
} else if (typeof value === 'boolean') {
valueStr = value.toString();
}
result += `${indentStr}${key} = ${valueStr}\n`;
}
}
return result;
}
/**
* Configure the automaker-tools MCP server
*/
async configureMcpServer(projectPath, mcpServerScriptPath) {
this.setProjectPath(projectPath);
const configPath = await this.getConfigPath();
// Read existing config
const config = await this.readConfig(configPath);
// Ensure mcp_servers section exists
if (!config.mcp_servers) {
config.mcp_servers = {};
}
// Configure automaker-tools server
config.mcp_servers['automaker-tools'] = {
command: 'node',
args: [mcpServerScriptPath],
env: {
AUTOMAKER_PROJECT_PATH: projectPath
},
startup_timeout_sec: 10,
tool_timeout_sec: 60,
enabled_tools: ['UpdateFeatureStatus']
};
// Ensure experimental_use_rmcp_client is enabled (if needed)
if (!config.experimental_use_rmcp_client) {
config.experimental_use_rmcp_client = true;
}
// Write config back
await this.writeConfig(configPath, config);
console.log(`[CodexConfigManager] Configured automaker-tools MCP server in ${configPath}`);
return configPath;
}
/**
* Write config to TOML file
*/
async writeConfig(configPath, config) {
let content = '';
// Write top-level keys first (preserve existing non-MCP config)
for (const [key, value] of Object.entries(config)) {
if (key === 'mcp_servers' || key === 'experimental_use_rmcp_client') {
continue; // Handle these separately
}
if (typeof value !== 'object') {
content += `${key} = ${this.formatValue(value)}\n`;
}
}
// Write experimental flag if enabled
if (config.experimental_use_rmcp_client) {
if (content && !content.endsWith('\n\n')) {
content += '\n';
}
content += `experimental_use_rmcp_client = true\n`;
}
// Write mcp_servers section
if (config.mcp_servers && Object.keys(config.mcp_servers).length > 0) {
if (content && !content.endsWith('\n\n')) {
content += '\n';
}
for (const [serverName, serverConfig] of Object.entries(config.mcp_servers)) {
content += `\n[mcp_servers.${serverName}]\n`;
// Write command first
if (serverConfig.command) {
content += `command = "${this.escapeTomlString(serverConfig.command)}"\n`;
}
// Write args
if (serverConfig.args && Array.isArray(serverConfig.args)) {
const argsStr = serverConfig.args.map(a => `"${this.escapeTomlString(a)}"`).join(', ');
content += `args = [${argsStr}]\n`;
}
// Write timeouts (must be before env subsection)
if (serverConfig.startup_timeout_sec !== undefined) {
content += `startup_timeout_sec = ${serverConfig.startup_timeout_sec}\n`;
}
if (serverConfig.tool_timeout_sec !== undefined) {
content += `tool_timeout_sec = ${serverConfig.tool_timeout_sec}\n`;
}
// Write enabled_tools (must be before env subsection - at server level, not env level)
if (serverConfig.enabled_tools && Array.isArray(serverConfig.enabled_tools)) {
const toolsStr = serverConfig.enabled_tools.map(t => `"${this.escapeTomlString(t)}"`).join(', ');
content += `enabled_tools = [${toolsStr}]\n`;
}
// Write env section last (as a separate subsection)
// IMPORTANT: In TOML, once we start [mcp_servers.server_name.env],
// everything after belongs to that subsection until a new section starts
if (serverConfig.env && typeof serverConfig.env === 'object' && Object.keys(serverConfig.env).length > 0) {
content += `\n[mcp_servers.${serverName}.env]\n`;
for (const [envKey, envValue] of Object.entries(serverConfig.env)) {
content += `${envKey} = "${this.escapeTomlString(String(envValue))}"\n`;
}
}
}
}
// Ensure directory exists
const configDir = path.dirname(configPath);
await fs.mkdir(configDir, { recursive: true });
// Write file
await fs.writeFile(configPath, content, 'utf-8');
}
/**
* Escape special characters in TOML strings
*/
escapeTomlString(str) {
return str
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t');
}
/**
* Format a value for TOML output
*/
formatValue(value) {
if (typeof value === 'string') {
// Escape quotes
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return `"${escaped}"`;
} else if (typeof value === 'boolean') {
return value.toString();
} else if (typeof value === 'number') {
return value.toString();
}
return `"${String(value)}"`;
}
/**
* Remove automaker-tools MCP server configuration
*/
async removeMcpServer(projectPath) {
this.setProjectPath(projectPath);
const configPath = await this.getConfigPath();
try {
const config = await this.readConfig(configPath);
if (config.mcp_servers && config.mcp_servers['automaker-tools']) {
delete config.mcp_servers['automaker-tools'];
// If no more MCP servers, remove the section
if (Object.keys(config.mcp_servers).length === 0) {
delete config.mcp_servers;
}
await this.writeConfig(configPath, config);
console.log(`[CodexConfigManager] Removed automaker-tools MCP server from ${configPath}`);
}
} catch (e) {
console.error(`[CodexConfigManager] Error removing MCP server config:`, e);
}
}
}
module.exports = new CodexConfigManager();

View File

@@ -0,0 +1,610 @@
/**
* Codex CLI Execution Wrapper
*
* This module handles spawning and managing Codex CLI processes
* for executing OpenAI model queries.
*/
const { spawn } = require('child_process');
const { EventEmitter } = require('events');
const readline = require('readline');
const path = require('path');
const CodexCliDetector = require('./codex-cli-detector');
const codexConfigManager = require('./codex-config-manager');
/**
* Message types from Codex CLI JSON output
*/
const CODEX_EVENT_TYPES = {
THREAD_STARTED: 'thread.started',
ITEM_STARTED: 'item.started',
ITEM_COMPLETED: 'item.completed',
THREAD_COMPLETED: 'thread.completed',
ERROR: 'error'
};
/**
* Codex Executor - Manages Codex CLI process execution
*/
class CodexExecutor extends EventEmitter {
constructor() {
super();
this.currentProcess = null;
this.codexPath = null;
}
/**
* Find and cache the Codex CLI path
* @returns {string|null} Path to codex executable
*/
findCodexPath() {
if (this.codexPath) {
return this.codexPath;
}
const installation = CodexCliDetector.detectCodexInstallation();
if (installation.installed && installation.path) {
this.codexPath = installation.path;
return this.codexPath;
}
return null;
}
/**
* Execute a Codex CLI query
* @param {Object} options Execution options
* @param {string} options.prompt The prompt to execute
* @param {string} options.model Model to use (default: gpt-5.1-codex-max)
* @param {string} options.cwd Working directory
* @param {string} options.systemPrompt System prompt (optional, will be prepended to prompt)
* @param {number} options.maxTurns Not used - Codex CLI doesn't support this parameter
* @param {string[]} options.allowedTools Not used - Codex CLI doesn't support this parameter
* @param {Object} options.env Environment variables
* @param {Object} options.mcpServers MCP servers configuration (for configuring Codex TOML)
* @returns {AsyncGenerator} Generator yielding messages
*/
async *execute(options) {
const {
prompt,
model = 'gpt-5.1-codex-max',
cwd = process.cwd(),
systemPrompt,
maxTurns, // Not used by Codex CLI
allowedTools, // Not used by Codex CLI
env = {},
mcpServers = null
} = options;
const codexPath = this.findCodexPath();
if (!codexPath) {
yield {
type: 'error',
error: 'Codex CLI not found. Please install it with: npm install -g @openai/codex@latest'
};
return;
}
// Configure MCP server if provided
if (mcpServers && mcpServers['automaker-tools']) {
try {
// Get the absolute path to the MCP server script
const mcpServerScriptPath = path.resolve(__dirname, 'mcp-server-stdio.js');
// Verify the script exists
const fs = require('fs');
if (!fs.existsSync(mcpServerScriptPath)) {
console.warn(`[CodexExecutor] MCP server script not found at ${mcpServerScriptPath}, skipping MCP configuration`);
} else {
// Configure Codex TOML to use the MCP server
await codexConfigManager.configureMcpServer(cwd, mcpServerScriptPath);
console.log('[CodexExecutor] Configured automaker-tools MCP server for Codex CLI');
}
} catch (error) {
console.error('[CodexExecutor] Failed to configure MCP server:', error);
// Continue execution even if MCP config fails - Codex will work without MCP tools
}
}
// Combine system prompt with main prompt if provided
// Codex CLI doesn't support --system-prompt argument, so we prepend it to the prompt
let combinedPrompt = prompt;
console.log('[CodexExecutor] Original prompt length:', prompt?.length || 0);
if (systemPrompt) {
combinedPrompt = `${systemPrompt}\n\n---\n\n${prompt}`;
console.log('[CodexExecutor] System prompt prepended to main prompt');
console.log('[CodexExecutor] System prompt length:', systemPrompt.length);
console.log('[CodexExecutor] Combined prompt length:', combinedPrompt.length);
}
// Build command arguments
// Note: maxTurns and allowedTools are not supported by Codex CLI
console.log('[CodexExecutor] Building command arguments...');
const args = this.buildArgs({
prompt: combinedPrompt,
model
});
console.log('[CodexExecutor] Executing command:', codexPath);
console.log('[CodexExecutor] Number of args:', args.length);
console.log('[CodexExecutor] Args (without prompt):', args.slice(0, -1).join(' '));
console.log('[CodexExecutor] Prompt length in args:', args[args.length - 1]?.length || 0);
console.log('[CodexExecutor] Prompt preview (first 200 chars):', args[args.length - 1]?.substring(0, 200));
console.log('[CodexExecutor] Working directory:', cwd);
// Spawn the process
const processEnv = {
...process.env,
...env,
// Ensure OPENAI_API_KEY is available
OPENAI_API_KEY: env.OPENAI_API_KEY || process.env.OPENAI_API_KEY
};
// Log API key status (without exposing the key)
if (processEnv.OPENAI_API_KEY) {
console.log('[CodexExecutor] OPENAI_API_KEY is set (length:', processEnv.OPENAI_API_KEY.length, ')');
} else {
console.warn('[CodexExecutor] WARNING: OPENAI_API_KEY is not set!');
}
console.log('[CodexExecutor] Spawning process...');
const proc = spawn(codexPath, args, {
cwd,
env: processEnv,
stdio: ['pipe', 'pipe', 'pipe']
});
this.currentProcess = proc;
console.log('[CodexExecutor] Process spawned with PID:', proc.pid);
// Track process events
proc.on('error', (error) => {
console.error('[CodexExecutor] Process error:', error);
});
proc.on('spawn', () => {
console.log('[CodexExecutor] Process spawned successfully');
});
// Collect stderr output as it comes in
let stderr = '';
let hasOutput = false;
let stdoutChunks = [];
let stderrChunks = [];
proc.stderr.on('data', (data) => {
const errorText = data.toString();
stderr += errorText;
stderrChunks.push(errorText);
hasOutput = true;
console.error('[CodexExecutor] stderr chunk received (', data.length, 'bytes):', errorText.substring(0, 200));
});
proc.stderr.on('end', () => {
console.log('[CodexExecutor] stderr stream ended. Total chunks:', stderrChunks.length, 'Total length:', stderr.length);
});
proc.stdout.on('data', (data) => {
const text = data.toString();
stdoutChunks.push(text);
hasOutput = true;
console.log('[CodexExecutor] stdout chunk received (', data.length, 'bytes):', text.substring(0, 200));
});
proc.stdout.on('end', () => {
console.log('[CodexExecutor] stdout stream ended. Total chunks:', stdoutChunks.length);
});
// Create readline interface for parsing JSONL output
console.log('[CodexExecutor] Creating readline interface...');
const rl = readline.createInterface({
input: proc.stdout,
crlfDelay: Infinity
});
// Track accumulated content for converting to Claude format
let accumulatedText = '';
let toolUses = [];
let lastOutputTime = Date.now();
const OUTPUT_TIMEOUT = 30000; // 30 seconds timeout for no output
let lineCount = 0;
let jsonParseErrors = 0;
// Set up timeout check
const checkTimeout = setInterval(() => {
const timeSinceLastOutput = Date.now() - lastOutputTime;
if (timeSinceLastOutput > OUTPUT_TIMEOUT && !hasOutput) {
console.warn('[CodexExecutor] No output received for', timeSinceLastOutput, 'ms. Process still alive:', !proc.killed);
}
}, 5000);
console.log('[CodexExecutor] Starting to read lines from stdout...');
// Process stdout line by line (JSONL format)
try {
for await (const line of rl) {
hasOutput = true;
lastOutputTime = Date.now();
lineCount++;
console.log('[CodexExecutor] Line', lineCount, 'received (length:', line.length, '):', line.substring(0, 100));
if (!line.trim()) {
console.log('[CodexExecutor] Skipping empty line');
continue;
}
try {
const event = JSON.parse(line);
console.log('[CodexExecutor] Successfully parsed JSON event. Type:', event.type, 'Keys:', Object.keys(event));
const convertedMsg = this.convertToClaudeFormat(event);
console.log('[CodexExecutor] Converted message:', convertedMsg ? { type: convertedMsg.type } : 'null');
if (convertedMsg) {
// Accumulate text content
if (convertedMsg.type === 'assistant' && convertedMsg.message?.content) {
for (const block of convertedMsg.message.content) {
if (block.type === 'text') {
accumulatedText += block.text;
console.log('[CodexExecutor] Accumulated text block (total length:', accumulatedText.length, ')');
} else if (block.type === 'tool_use') {
toolUses.push(block);
console.log('[CodexExecutor] Tool use detected:', block.name);
}
}
}
console.log('[CodexExecutor] Yielding message of type:', convertedMsg.type);
yield convertedMsg;
} else {
console.log('[CodexExecutor] Converted message is null, skipping');
}
} catch (parseError) {
jsonParseErrors++;
// Non-JSON output, yield as text
console.log('[CodexExecutor] JSON parse error (', jsonParseErrors, 'total):', parseError.message);
console.log('[CodexExecutor] Non-JSON line content:', line.substring(0, 200));
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: line + '\n' }]
}
};
}
}
console.log('[CodexExecutor] Finished reading all lines. Total lines:', lineCount, 'JSON errors:', jsonParseErrors);
} catch (readError) {
console.error('[CodexExecutor] Error reading from readline:', readError);
throw readError;
} finally {
clearInterval(checkTimeout);
console.log('[CodexExecutor] Cleaned up timeout checker');
}
// Handle process completion
console.log('[CodexExecutor] Waiting for process to close...');
const exitCode = await new Promise((resolve) => {
proc.on('close', (code, signal) => {
console.log('[CodexExecutor] Process closed with code:', code, 'signal:', signal);
resolve(code);
});
});
this.currentProcess = null;
console.log('[CodexExecutor] Process completed. Exit code:', exitCode, 'Has output:', hasOutput, 'Stderr length:', stderr.length);
// Wait a bit for any remaining stderr data to be collected
console.log('[CodexExecutor] Waiting 200ms for any remaining stderr data...');
await new Promise(resolve => setTimeout(resolve, 200));
console.log('[CodexExecutor] Final stderr length:', stderr.length, 'Final stdout chunks:', stdoutChunks.length);
if (exitCode !== 0) {
const errorMessage = stderr.trim()
? `Codex CLI exited with code ${exitCode}.\n\nError output:\n${stderr}`
: `Codex CLI exited with code ${exitCode}. No error output captured.`;
console.error('[CodexExecutor] Process failed with exit code', exitCode);
console.error('[CodexExecutor] Error message:', errorMessage);
console.error('[CodexExecutor] Stderr chunks:', stderrChunks.length, 'Stdout chunks:', stdoutChunks.length);
yield {
type: 'error',
error: errorMessage
};
} else if (!hasOutput && !stderr) {
// Process exited successfully but produced no output - might be API key issue
const warningMessage = 'Codex CLI completed but produced no output. This might indicate:\n' +
'- Missing or invalid OPENAI_API_KEY\n' +
'- Codex CLI configuration issue\n' +
'- The process completed without generating any response\n\n' +
`Debug info: Exit code ${exitCode}, stdout chunks: ${stdoutChunks.length}, stderr chunks: ${stderrChunks.length}, lines read: ${lineCount}`;
console.warn('[CodexExecutor] No output detected:', warningMessage);
console.warn('[CodexExecutor] Stdout chunks:', stdoutChunks);
console.warn('[CodexExecutor] Stderr chunks:', stderrChunks);
yield {
type: 'error',
error: warningMessage
};
} else {
console.log('[CodexExecutor] Process completed successfully. Exit code:', exitCode, 'Lines processed:', lineCount);
}
}
/**
* Build command arguments for Codex CLI
* Only includes supported arguments based on Codex CLI help:
* - --model: Model to use
* - --json: JSON output format
* - --full-auto: Non-interactive automatic execution
*
* Note: Codex CLI does NOT support:
* - --system-prompt (system prompt is prepended to main prompt)
* - --max-turns (not available in CLI)
* - --tools (not available in CLI)
*
* @param {Object} options Options
* @returns {string[]} Command arguments
*/
buildArgs(options) {
const { prompt, model } = options;
console.log('[CodexExecutor] buildArgs called with model:', model, 'prompt length:', prompt?.length || 0);
const args = ['exec'];
// Add model (required for most use cases)
if (model) {
args.push('--model', model);
console.log('[CodexExecutor] Added model argument:', model);
}
// Add JSON output flag for structured parsing
args.push('--json');
console.log('[CodexExecutor] Added --json flag');
// Add full-auto mode (non-interactive)
// This enables automatic execution with workspace-write sandbox
args.push('--full-auto');
console.log('[CodexExecutor] Added --full-auto flag');
// Add the prompt at the end
args.push(prompt);
console.log('[CodexExecutor] Added prompt (length:', prompt?.length || 0, ')');
console.log('[CodexExecutor] Final args count:', args.length);
return args;
}
/**
* Map Claude tool names to Codex tool names
* @param {string[]} tools Array of tool names
* @returns {string[]} Mapped tool names
*/
mapToolsToCodex(tools) {
const toolMap = {
'Read': 'read',
'Write': 'write',
'Edit': 'edit',
'Bash': 'bash',
'Glob': 'glob',
'Grep': 'grep',
'WebSearch': 'web-search',
'WebFetch': 'web-fetch'
};
return tools
.map(tool => toolMap[tool] || tool.toLowerCase())
.filter(tool => tool); // Remove undefined
}
/**
* Convert Codex JSONL event to Claude SDK message format
* @param {Object} event Codex event object
* @returns {Object|null} Claude-format message or null
*/
convertToClaudeFormat(event) {
console.log('[CodexExecutor] Converting event:', JSON.stringify(event).substring(0, 200));
const { type, data, item, thread_id } = event;
switch (type) {
case CODEX_EVENT_TYPES.THREAD_STARTED:
case 'thread.started':
// Session initialization
return {
type: 'session_start',
sessionId: thread_id || data?.thread_id || event.thread_id
};
case CODEX_EVENT_TYPES.ITEM_COMPLETED:
case 'item.completed':
// Codex uses 'item' field, not 'data'
return this.convertItemCompleted(item || data);
case CODEX_EVENT_TYPES.ITEM_STARTED:
case 'item.started':
// Convert item.started events - these indicate tool/command usage
const startedItem = item || data;
if (startedItem?.type === 'command_execution' && startedItem?.command) {
return {
type: 'assistant',
message: {
content: [{
type: 'tool_use',
name: 'bash',
input: { command: startedItem.command }
}]
}
};
}
// For other item.started types, return null (we'll show the completed version)
return null;
case CODEX_EVENT_TYPES.THREAD_COMPLETED:
case 'thread.completed':
return {
type: 'complete',
sessionId: thread_id || data?.thread_id || event.thread_id
};
case CODEX_EVENT_TYPES.ERROR:
case 'error':
return {
type: 'error',
error: data?.message || item?.message || event.message || 'Unknown error from Codex CLI'
};
case 'turn.started':
// Turn started - just a marker, no need to convert
return null;
default:
// Pass through other events
console.log('[CodexExecutor] Unhandled event type:', type);
return null;
}
}
/**
* Convert item.completed event to Claude format
* @param {Object} item Event item data
* @returns {Object|null} Claude-format message
*/
convertItemCompleted(item) {
if (!item) {
console.log('[CodexExecutor] convertItemCompleted: item is null/undefined');
return null;
}
const itemType = item.type || item.item_type;
console.log('[CodexExecutor] convertItemCompleted: itemType =', itemType, 'item keys:', Object.keys(item));
switch (itemType) {
case 'reasoning':
// Thinking/reasoning output - Codex uses 'text' field
const reasoningText = item.text || item.content || '';
console.log('[CodexExecutor] Converting reasoning, text length:', reasoningText.length);
return {
type: 'assistant',
message: {
content: [{
type: 'thinking',
thinking: reasoningText
}]
}
};
case 'agent_message':
case 'message':
// Assistant text message
const messageText = item.content || item.text || '';
console.log('[CodexExecutor] Converting message, text length:', messageText.length);
return {
type: 'assistant',
message: {
content: [{
type: 'text',
text: messageText
}]
}
};
case 'command_execution':
// Command execution - show both the command and its output
const command = item.command || '';
const output = item.aggregated_output || item.output || '';
console.log('[CodexExecutor] Converting command_execution, command:', command.substring(0, 50), 'output length:', output.length);
// Return as text message showing the command and output
return {
type: 'assistant',
message: {
content: [{
type: 'text',
text: `\`\`\`bash\n${command}\n\`\`\`\n\n${output}`
}]
}
};
case 'tool_use':
// Tool use
return {
type: 'assistant',
message: {
content: [{
type: 'tool_use',
name: item.tool || item.command || 'unknown',
input: item.input || item.args || {}
}]
}
};
case 'tool_result':
// Tool result
return {
type: 'tool_result',
tool_use_id: item.tool_use_id,
content: item.output || item.result
};
case 'todo_list':
// Todo list - convert to text format
const todos = item.items || [];
const todoText = todos.map((t, i) => `${i + 1}. ${t.text || t}`).join('\n');
console.log('[CodexExecutor] Converting todo_list, items:', todos.length);
return {
type: 'assistant',
message: {
content: [{
type: 'text',
text: `**Todo List:**\n${todoText}`
}]
}
};
default:
// Generic text output
const text = item.text || item.content || item.aggregated_output;
if (text) {
console.log('[CodexExecutor] Converting default item type, text length:', text.length);
return {
type: 'assistant',
message: {
content: [{
type: 'text',
text: String(text)
}]
}
};
}
console.log('[CodexExecutor] convertItemCompleted: No text content found, returning null');
return null;
}
}
/**
* Abort current execution
*/
abort() {
if (this.currentProcess) {
console.log('[CodexExecutor] Aborting current process');
this.currentProcess.kill('SIGTERM');
this.currentProcess = null;
}
}
/**
* Check if execution is in progress
* @returns {boolean} Whether execution is in progress
*/
isRunning() {
return this.currentProcess !== null;
}
}
// Singleton instance
const codexExecutor = new CodexExecutor();
module.exports = codexExecutor;

View File

@@ -0,0 +1,452 @@
const path = require("path");
const fs = require("fs/promises");
/**
* Context Manager - Handles reading, writing, and deleting context files for features
*/
class ContextManager {
/**
* Write output to feature context file
*/
async writeToContextFile(projectPath, featureId, content) {
if (!projectPath) return;
try {
const featureDir = path.join(
projectPath,
".automaker",
"features",
featureId
);
// Ensure feature directory exists
try {
await fs.access(featureDir);
} catch {
await fs.mkdir(featureDir, { recursive: true });
}
const filePath = path.join(featureDir, "agent-output.md");
// Append to existing file or create new one
try {
const existing = await fs.readFile(filePath, "utf-8");
await fs.writeFile(filePath, existing + content, "utf-8");
} catch {
await fs.writeFile(filePath, content, "utf-8");
}
} catch (error) {
console.error("[ContextManager] Failed to write to context file:", error);
}
}
/**
* Read context file for a feature
*/
async readContextFile(projectPath, featureId) {
try {
const contextPath = path.join(
projectPath,
".automaker",
"features",
featureId,
"agent-output.md"
);
const content = await fs.readFile(contextPath, "utf-8");
return content;
} catch (error) {
console.log(`[ContextManager] No context file found for ${featureId}`);
return null;
}
}
/**
* Delete agent context file for a feature
*/
async deleteContextFile(projectPath, featureId) {
if (!projectPath) return;
try {
const contextPath = path.join(
projectPath,
".automaker",
"features",
featureId,
"agent-output.md"
);
await fs.unlink(contextPath);
console.log(
`[ContextManager] Deleted agent context for feature ${featureId}`
);
} catch (error) {
// File might not exist, which is fine
if (error.code !== "ENOENT") {
console.error("[ContextManager] Failed to delete context file:", error);
}
}
}
/**
* Read the memory.md file containing lessons learned and common issues
* Returns formatted string to inject into prompts
*/
async getMemoryContent(projectPath) {
if (!projectPath) return "";
try {
const memoryPath = path.join(projectPath, ".automaker", "memory.md");
// Check if file exists
try {
await fs.access(memoryPath);
} catch {
// File doesn't exist, return empty string
return "";
}
const content = await fs.readFile(memoryPath, "utf-8");
if (!content.trim()) {
return "";
}
return `
**🧠 Agent Memory - Previous Lessons Learned:**
The following memory file contains lessons learned from previous agent runs, including common issues and their solutions. Review this carefully to avoid repeating past mistakes.
<agent-memory>
${content}
</agent-memory>
**IMPORTANT:** If you encounter a new issue that took significant debugging effort to resolve, add it to the memory file at \`.automaker/memory.md\` in a concise format:
- Issue title
- Problem description (1-2 sentences)
- Solution/fix (with code example if helpful)
This helps future agent runs avoid the same pitfalls.
`;
} catch (error) {
console.error("[ContextManager] Failed to read memory file:", error);
return "";
}
}
/**
* List context files from .automaker/context/ directory and get previews
* Returns a formatted string with file names and first 50 lines of each file
*/
async getContextFilesPreview(projectPath) {
if (!projectPath) return "";
try {
const contextDir = path.join(projectPath, ".automaker", "context");
// Check if directory exists
try {
await fs.access(contextDir);
} catch {
// Directory doesn't exist, return empty string
return "";
}
// Read directory contents
const entries = await fs.readdir(contextDir, { withFileTypes: true });
const files = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort();
if (files.length === 0) {
return "";
}
// Build preview string
const previews = [];
previews.push(`\n**📁 Context Files Available:**\n`);
previews.push(
`The following context files are available in \`.automaker/context/\` directory.`
);
previews.push(
`These files contain additional context that may be relevant to your work.`
);
previews.push(
`You can read them in full using the Read tool if needed.\n`
);
for (const fileName of files) {
try {
const filePath = path.join(contextDir, fileName);
const content = await fs.readFile(filePath, "utf-8");
const lines = content.split("\n");
const previewLines = lines.slice(0, 50);
const preview = previewLines.join("\n");
const hasMore = lines.length > 50;
previews.push(`\n**File: ${fileName}**`);
if (hasMore) {
previews.push(
`(Showing first 50 of ${lines.length} lines - use Read tool to see full content)`
);
}
previews.push(`\`\`\``);
previews.push(preview);
previews.push(`\`\`\`\n`);
} catch (error) {
console.error(
`[ContextManager] Failed to read context file ${fileName}:`,
error
);
previews.push(`\n**File: ${fileName}** (Error reading file)\n`);
}
}
return previews.join("\n");
} catch (error) {
console.error("[ContextManager] Failed to list context files:", error);
return "";
}
}
/**
* Save the initial git state before a feature starts executing
* This captures all files that were already modified before the AI agent started
* @param {string} projectPath - Path to the project
* @param {string} featureId - Feature ID
* @returns {Promise<{modifiedFiles: string[], untrackedFiles: string[]}>}
*/
async saveInitialGitState(projectPath, featureId) {
if (!projectPath) return { modifiedFiles: [], untrackedFiles: [] };
try {
const { execSync } = require("child_process");
const featureDir = path.join(
projectPath,
".automaker",
"features",
featureId
);
// Ensure feature directory exists
try {
await fs.access(featureDir);
} catch {
await fs.mkdir(featureDir, { recursive: true });
}
// Get list of modified files (both staged and unstaged)
let modifiedFiles = [];
try {
const modifiedOutput = execSync("git diff --name-only HEAD", {
cwd: projectPath,
encoding: "utf-8",
}).trim();
if (modifiedOutput) {
modifiedFiles = modifiedOutput.split("\n").filter(Boolean);
}
} catch (error) {
console.log(
"[ContextManager] No modified files or git error:",
error.message
);
}
// Get list of untracked files
let untrackedFiles = [];
try {
const untrackedOutput = execSync(
"git ls-files --others --exclude-standard",
{
cwd: projectPath,
encoding: "utf-8",
}
).trim();
if (untrackedOutput) {
untrackedFiles = untrackedOutput.split("\n").filter(Boolean);
}
} catch (error) {
console.log(
"[ContextManager] Error getting untracked files:",
error.message
);
}
// Save the initial state to a JSON file
const stateFile = path.join(featureDir, "git-state.json");
const state = {
timestamp: new Date().toISOString(),
modifiedFiles,
untrackedFiles,
};
await fs.writeFile(stateFile, JSON.stringify(state, null, 2), "utf-8");
console.log(
`[ContextManager] Saved initial git state for ${featureId}:`,
{
modifiedCount: modifiedFiles.length,
untrackedCount: untrackedFiles.length,
}
);
return state;
} catch (error) {
console.error(
"[ContextManager] Failed to save initial git state:",
error
);
return { modifiedFiles: [], untrackedFiles: [] };
}
}
/**
* Get the initial git state saved before a feature started executing
* @param {string} projectPath - Path to the project
* @param {string} featureId - Feature ID
* @returns {Promise<{modifiedFiles: string[], untrackedFiles: string[], timestamp: string} | null>}
*/
async getInitialGitState(projectPath, featureId) {
if (!projectPath) return null;
try {
const stateFile = path.join(
projectPath,
".automaker",
"features",
featureId,
"git-state.json"
);
const content = await fs.readFile(stateFile, "utf-8");
return JSON.parse(content);
} catch (error) {
console.log(
`[ContextManager] No initial git state found for ${featureId}`
);
return null;
}
}
/**
* Delete the git state file for a feature
* @param {string} projectPath - Path to the project
* @param {string} featureId - Feature ID
*/
async deleteGitStateFile(projectPath, featureId) {
if (!projectPath) return;
try {
const stateFile = path.join(
projectPath,
".automaker",
"features",
featureId,
"git-state.json"
);
await fs.unlink(stateFile);
console.log(`[ContextManager] Deleted git state file for ${featureId}`);
} catch (error) {
// File might not exist, which is fine
if (error.code !== "ENOENT") {
console.error(
"[ContextManager] Failed to delete git state file:",
error
);
}
}
}
/**
* Calculate which files were changed during the AI session
* by comparing current git state with the saved initial state
* @param {string} projectPath - Path to the project
* @param {string} featureId - Feature ID
* @returns {Promise<{newFiles: string[], modifiedFiles: string[]}>}
*/
async getFilesChangedDuringSession(projectPath, featureId) {
if (!projectPath) return { newFiles: [], modifiedFiles: [] };
try {
const { execSync } = require("child_process");
// Get initial state
const initialState = await this.getInitialGitState(
projectPath,
featureId
);
// Get current state
let currentModified = [];
try {
const modifiedOutput = execSync("git diff --name-only HEAD", {
cwd: projectPath,
encoding: "utf-8",
}).trim();
if (modifiedOutput) {
currentModified = modifiedOutput.split("\n").filter(Boolean);
}
} catch (error) {
console.log("[ContextManager] No modified files or git error");
}
let currentUntracked = [];
try {
const untrackedOutput = execSync(
"git ls-files --others --exclude-standard",
{
cwd: projectPath,
encoding: "utf-8",
}
).trim();
if (untrackedOutput) {
currentUntracked = untrackedOutput.split("\n").filter(Boolean);
}
} catch (error) {
console.log("[ContextManager] Error getting untracked files");
}
if (!initialState) {
// No initial state - all current changes are considered from this session
console.log(
"[ContextManager] No initial state found, returning all current changes"
);
return {
newFiles: currentUntracked,
modifiedFiles: currentModified,
};
}
// Calculate files that are new since the session started
const initialModifiedSet = new Set(initialState.modifiedFiles || []);
const initialUntrackedSet = new Set(initialState.untrackedFiles || []);
// New files = current untracked - initial untracked
const newFiles = currentUntracked.filter(
(f) => !initialUntrackedSet.has(f)
);
// Modified files = current modified - initial modified
const modifiedFiles = currentModified.filter(
(f) => !initialModifiedSet.has(f)
);
console.log(
`[ContextManager] Files changed during session for ${featureId}:`,
{
newFilesCount: newFiles.length,
modifiedFilesCount: modifiedFiles.length,
newFiles,
modifiedFiles,
}
);
return { newFiles, modifiedFiles };
} catch (error) {
console.error(
"[ContextManager] Failed to calculate changed files:",
error
);
return { newFiles: [], modifiedFiles: [] };
}
}
}
module.exports = new ContextManager();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,500 @@
const path = require("path");
const fs = require("fs/promises");
/**
* Feature Loader - Handles loading and managing features from individual feature folders
* Each feature is stored in .automaker/features/{featureId}/feature.json
*/
class FeatureLoader {
/**
* Get the features directory path
*/
getFeaturesDir(projectPath) {
return path.join(projectPath, ".automaker", "features");
}
/**
* Get the path to a specific feature folder
*/
getFeatureDir(projectPath, featureId) {
return path.join(this.getFeaturesDir(projectPath), featureId);
}
/**
* Get the path to a feature's feature.json file
*/
getFeatureJsonPath(projectPath, featureId) {
return path.join(
this.getFeatureDir(projectPath, featureId),
"feature.json"
);
}
/**
* Get the path to a feature's agent-output.md file
*/
getAgentOutputPath(projectPath, featureId) {
return path.join(
this.getFeatureDir(projectPath, featureId),
"agent-output.md"
);
}
/**
* Generate a new feature ID
*/
generateFeatureId() {
return `feature-${Date.now()}-${Math.random()
.toString(36)
.substring(2, 11)}`;
}
/**
* Ensure all image paths for a feature are stored within the feature directory
*/
async ensureFeatureImages(projectPath, featureId, feature) {
if (
!feature ||
!Array.isArray(feature.imagePaths) ||
feature.imagePaths.length === 0
) {
return;
}
const featureDir = this.getFeatureDir(projectPath, featureId);
const featureImagesDir = path.join(featureDir, "images");
await fs.mkdir(featureImagesDir, { recursive: true });
const updatedImagePaths = [];
for (const entry of feature.imagePaths) {
const isStringEntry = typeof entry === "string";
const currentPathValue = isStringEntry ? entry : entry.path;
if (!currentPathValue) {
updatedImagePaths.push(entry);
continue;
}
let resolvedCurrentPath = currentPathValue;
if (!path.isAbsolute(resolvedCurrentPath)) {
resolvedCurrentPath = path.join(projectPath, resolvedCurrentPath);
}
resolvedCurrentPath = path.normalize(resolvedCurrentPath);
// Skip if file doesn't exist
try {
await fs.access(resolvedCurrentPath);
} catch {
console.warn(
`[FeatureLoader] Image file missing for ${featureId}: ${resolvedCurrentPath}`
);
updatedImagePaths.push(entry);
continue;
}
const relativeToFeatureImages = path.relative(
featureImagesDir,
resolvedCurrentPath
);
const alreadyInFeatureDir =
relativeToFeatureImages === "" ||
(!relativeToFeatureImages.startsWith("..") &&
!path.isAbsolute(relativeToFeatureImages));
let finalPath = resolvedCurrentPath;
if (!alreadyInFeatureDir) {
const originalName = path.basename(resolvedCurrentPath);
let targetPath = path.join(featureImagesDir, originalName);
// Avoid overwriting files by appending a counter if needed
let counter = 1;
while (true) {
try {
await fs.access(targetPath);
const parsed = path.parse(originalName);
targetPath = path.join(
featureImagesDir,
`${parsed.name}-${counter}${parsed.ext}`
);
counter += 1;
} catch {
break;
}
}
try {
await fs.rename(resolvedCurrentPath, targetPath);
finalPath = targetPath;
} catch (error) {
console.warn(
`[FeatureLoader] Failed to move image ${resolvedCurrentPath}: ${error.message}`
);
updatedImagePaths.push(entry);
continue;
}
}
updatedImagePaths.push(
isStringEntry ? finalPath : { ...entry, path: finalPath }
);
}
feature.imagePaths = updatedImagePaths;
}
/**
* Get all features for a project
*/
async getAll(projectPath) {
try {
const featuresDir = this.getFeaturesDir(projectPath);
// Check if features directory exists
try {
await fs.access(featuresDir);
} catch {
// Directory doesn't exist, return empty array
return [];
}
// Read all feature directories
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
const featureDirs = entries.filter((entry) => entry.isDirectory());
// Load each feature
const features = [];
for (const dir of featureDirs) {
const featureId = dir.name;
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
try {
// Read feature.json directly - handle ENOENT in catch block
// This avoids TOCTOU race condition from checking with fs.access first
const content = await fs.readFile(featureJsonPath, "utf-8");
const feature = JSON.parse(content);
// Validate that the feature has required fields
if (!feature.id) {
console.warn(
`[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping`
);
continue;
}
features.push(feature);
} catch (error) {
// Handle different error types appropriately
if (error.code === "ENOENT") {
// File doesn't exist - this is expected for incomplete feature directories
// Skip silently (feature.json not yet created or was removed)
continue;
} else if (error instanceof SyntaxError) {
// JSON parse error - log as warning since file exists but is malformed
console.warn(
`[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}`
);
} else {
// Other errors - log as error
console.error(
`[FeatureLoader] Failed to load feature ${featureId}:`,
error.message || error
);
}
// Continue loading other features
}
}
// Sort by creation order (feature IDs contain timestamp)
features.sort((a, b) => {
const aTime = a.id ? parseInt(a.id.split("-")[1] || "0") : 0;
const bTime = b.id ? parseInt(b.id.split("-")[1] || "0") : 0;
return aTime - bTime;
});
return features;
} catch (error) {
console.error("[FeatureLoader] Failed to get all features:", error);
return [];
}
}
/**
* Get a single feature by ID
*/
async get(projectPath, featureId) {
try {
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
const content = await fs.readFile(featureJsonPath, "utf-8");
return JSON.parse(content);
} catch (error) {
if (error.code === "ENOENT") {
return null;
}
console.error(
`[FeatureLoader] Failed to get feature ${featureId}:`,
error
);
throw error;
}
}
/**
* Create a new feature
*/
async create(projectPath, featureData) {
const featureId = featureData.id || this.generateFeatureId();
const featureDir = this.getFeatureDir(projectPath, featureId);
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
// Ensure features directory exists
const featuresDir = this.getFeaturesDir(projectPath);
await fs.mkdir(featuresDir, { recursive: true });
// Create feature directory
await fs.mkdir(featureDir, { recursive: true });
// Ensure feature has an ID
const feature = { ...featureData, id: featureId };
// Move any uploaded images into the feature directory
await this.ensureFeatureImages(projectPath, featureId, feature);
// Write feature.json
await fs.writeFile(
featureJsonPath,
JSON.stringify(feature, null, 2),
"utf-8"
);
console.log(`[FeatureLoader] Created feature ${featureId}`);
return feature;
}
/**
* Update a feature (partial updates supported)
*/
async update(projectPath, featureId, updates) {
try {
const feature = await this.get(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
// Merge updates
const updatedFeature = { ...feature, ...updates };
// Move any new images into the feature directory
await this.ensureFeatureImages(projectPath, featureId, updatedFeature);
// Write back to file
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
await fs.writeFile(
featureJsonPath,
JSON.stringify(updatedFeature, null, 2),
"utf-8"
);
console.log(`[FeatureLoader] Updated feature ${featureId}`);
return updatedFeature;
} catch (error) {
console.error(
`[FeatureLoader] Failed to update feature ${featureId}:`,
error
);
throw error;
}
}
/**
* Delete a feature and its entire folder
*/
async delete(projectPath, featureId) {
try {
const featureDir = this.getFeatureDir(projectPath, featureId);
await fs.rm(featureDir, { recursive: true, force: true });
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
} catch (error) {
if (error.code === "ENOENT") {
// Feature doesn't exist, that's fine
return;
}
console.error(
`[FeatureLoader] Failed to delete feature ${featureId}:`,
error
);
throw error;
}
}
/**
* Get agent output for a feature
*/
async getAgentOutput(projectPath, featureId) {
try {
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
const content = await fs.readFile(agentOutputPath, "utf-8");
return content;
} catch (error) {
if (error.code === "ENOENT") {
return null;
}
console.error(
`[FeatureLoader] Failed to get agent output for ${featureId}:`,
error
);
return null;
}
}
// ============================================================================
// Legacy methods for backward compatibility (used by backend services)
// ============================================================================
/**
* Load all features for a project (legacy API)
* Features are stored in .automaker/features/{id}/feature.json
*/
async loadFeatures(projectPath) {
return await this.getAll(projectPath);
}
/**
* Update feature status (legacy API)
* Features are stored in .automaker/features/{id}/feature.json
* Creates the feature if it doesn't exist.
* @param {string} featureId - The ID of the feature to update
* @param {string} status - The new status
* @param {string} projectPath - Path to the project
* @param {Object} options - Options object for optional parameters
* @param {string} [options.summary] - Optional summary of what was done
* @param {string} [options.error] - Optional error message if feature errored
* @param {string} [options.description] - Optional detailed description
* @param {string} [options.category] - Optional category/phase
* @param {string[]} [options.steps] - Optional array of implementation steps
*/
async updateFeatureStatus(featureId, status, projectPath, options = {}) {
const { summary, error, description, category, steps } = options;
// Check if feature exists
const existingFeature = await this.get(projectPath, featureId);
if (!existingFeature) {
// Feature doesn't exist - create it with all required fields
console.log(`[FeatureLoader] Feature ${featureId} not found - creating new feature`);
const newFeature = {
id: featureId,
title: featureId.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '),
description: description || summary || '', // Use provided description, fall back to summary
category: category || "Uncategorized",
steps: steps || [],
status: status,
images: [],
imagePaths: [],
skipTests: false, // Auto-generated features should run tests by default
model: "sonnet",
thinkingLevel: "none",
summary: summary || description || '',
createdAt: new Date().toISOString(),
};
if (error !== undefined) {
newFeature.error = error;
}
await this.create(projectPath, newFeature);
console.log(
`[FeatureLoader] Created feature ${featureId}: status=${status}, category=${category || "Uncategorized"}, steps=${steps?.length || 0}${
summary ? `, summary="${summary}"` : ""
}`
);
return;
}
// Feature exists - update it
const updates = { status };
if (summary !== undefined) {
updates.summary = summary;
// Also update description if it's empty or not set
if (!existingFeature.description) {
updates.description = summary;
}
}
if (description !== undefined) {
updates.description = description;
}
if (category !== undefined) {
updates.category = category;
}
if (steps !== undefined && Array.isArray(steps)) {
updates.steps = steps;
}
if (error !== undefined) {
updates.error = error;
} else {
// Clear error if not provided
if (existingFeature.error) {
updates.error = undefined;
}
}
// Ensure required fields exist (for features created before this fix)
if (!existingFeature.category && !updates.category) updates.category = "Uncategorized";
if (!existingFeature.steps && !updates.steps) updates.steps = [];
if (!existingFeature.images) updates.images = [];
if (!existingFeature.imagePaths) updates.imagePaths = [];
if (existingFeature.skipTests === undefined) updates.skipTests = false;
if (!existingFeature.model) updates.model = "sonnet";
if (!existingFeature.thinkingLevel) updates.thinkingLevel = "none";
await this.update(projectPath, featureId, updates);
console.log(
`[FeatureLoader] Updated feature ${featureId}: status=${status}${
category ? `, category="${category}"` : ""
}${steps ? `, steps=${steps.length}` : ""}${
summary ? `, summary="${summary}"` : ""
}`
);
}
/**
* Select the next feature to implement
* Prioritizes: earlier features in the list that are not verified or waiting_approval
*/
selectNextFeature(features) {
// Find first feature that is in backlog or in_progress status
// Skip verified and waiting_approval (which needs user input)
return features.find(
(f) => f.status !== "verified" && f.status !== "waiting_approval"
);
}
/**
* Update worktree info for a feature (legacy API)
* Features are stored in .automaker/features/{id}/feature.json
* @param {string} featureId - The ID of the feature to update
* @param {string} projectPath - Path to the project
* @param {string|null} worktreePath - Path to the worktree (null to clear)
* @param {string|null} branchName - Name of the feature branch (null to clear)
*/
async updateFeatureWorktree(
featureId,
projectPath,
worktreePath,
branchName
) {
const updates = {};
if (worktreePath) {
updates.worktreePath = worktreePath;
updates.branchName = branchName;
} else {
updates.worktreePath = null;
updates.branchName = null;
}
await this.update(projectPath, featureId, updates);
console.log(
`[FeatureLoader] Updated feature ${featureId}: worktreePath=${worktreePath}, branchName=${branchName}`
);
}
}
module.exports = new FeatureLoader();

View File

@@ -0,0 +1,379 @@
const { query, AbortError } = require("@anthropic-ai/claude-agent-sdk");
const promptBuilder = require("./prompt-builder");
/**
* Feature Suggestions Service - Analyzes project and generates feature suggestions
*/
class FeatureSuggestionsService {
constructor() {
this.runningAnalysis = null;
}
/**
* Generate feature suggestions by analyzing the project
* @param {string} projectPath - Path to the project
* @param {Function} sendToRenderer - Function to send events to renderer
* @param {Object} execution - Execution context with abort controller
* @param {string} suggestionType - Type of suggestions: "features", "refactoring", "security", "performance"
*/
async generateSuggestions(projectPath, sendToRenderer, execution, suggestionType = "features") {
console.log(
`[FeatureSuggestions] Generating ${suggestionType} suggestions for: ${projectPath}`
);
try {
const abortController = new AbortController();
execution.abortController = abortController;
const options = {
model: "claude-sonnet-4-20250514",
systemPrompt: this.getSystemPrompt(suggestionType),
maxTurns: 50,
cwd: projectPath,
allowedTools: ["Read", "Glob", "Grep", "Bash"],
permissionMode: "acceptEdits",
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
abortController: abortController,
};
const prompt = this.buildAnalysisPrompt(suggestionType);
sendToRenderer({
type: "suggestions_progress",
content: "Starting project analysis...\n",
});
const currentQuery = query({ prompt, options });
execution.query = currentQuery;
let fullResponse = "";
for await (const msg of currentQuery) {
if (!execution.isActive()) break;
if (msg.type === "assistant" && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
fullResponse += block.text;
sendToRenderer({
type: "suggestions_progress",
content: block.text,
});
} else if (block.type === "tool_use") {
sendToRenderer({
type: "suggestions_tool",
tool: block.name,
input: block.input,
});
}
}
}
}
execution.query = null;
execution.abortController = null;
// Parse the suggestions from the response
const suggestions = this.parseSuggestions(fullResponse);
sendToRenderer({
type: "suggestions_complete",
suggestions: suggestions,
});
return {
success: true,
suggestions: suggestions,
};
} catch (error) {
if (error instanceof AbortError || error?.name === "AbortError") {
console.log("[FeatureSuggestions] Analysis aborted");
if (execution) {
execution.abortController = null;
execution.query = null;
}
return {
success: false,
message: "Analysis aborted",
suggestions: [],
};
}
console.error(
"[FeatureSuggestions] Error generating suggestions:",
error
);
if (execution) {
execution.abortController = null;
execution.query = null;
}
throw error;
}
}
/**
* Parse suggestions from the LLM response
* Looks for JSON array in the response
*/
parseSuggestions(response) {
try {
// Try to find JSON array in the response
// Look for ```json ... ``` blocks first
const jsonBlockMatch = response.match(/```json\s*([\s\S]*?)```/);
if (jsonBlockMatch) {
const parsed = JSON.parse(jsonBlockMatch[1].trim());
if (Array.isArray(parsed)) {
return this.validateSuggestions(parsed);
}
}
// Try to find a raw JSON array
const jsonArrayMatch = response.match(/\[\s*\{[\s\S]*\}\s*\]/);
if (jsonArrayMatch) {
const parsed = JSON.parse(jsonArrayMatch[0]);
if (Array.isArray(parsed)) {
return this.validateSuggestions(parsed);
}
}
console.warn(
"[FeatureSuggestions] Could not parse suggestions from response"
);
return [];
} catch (error) {
console.error("[FeatureSuggestions] Error parsing suggestions:", error);
return [];
}
}
/**
* Validate and normalize suggestions
*/
validateSuggestions(suggestions) {
return suggestions
.filter((s) => s && typeof s === "object")
.map((s, index) => ({
id: `suggestion-${Date.now()}-${index}`,
category: s.category || "Uncategorized",
description: s.description || s.title || "No description",
steps: Array.isArray(s.steps) ? s.steps : [],
priority: typeof s.priority === "number" ? s.priority : index + 1,
reasoning: s.reasoning || "",
}))
.sort((a, b) => a.priority - b.priority);
}
/**
* Get the system prompt for feature suggestion analysis
* @param {string} suggestionType - Type of suggestions: "features", "refactoring", "security", "performance"
*/
getSystemPrompt(suggestionType = "features") {
const basePrompt = `You are an expert software architect. Your job is to analyze a codebase and provide actionable suggestions.
You have access to file reading and search tools. Use them to understand the codebase.
When analyzing, look at:
- README files and documentation
- Package.json, cargo.toml, or similar config files for tech stack
- Source code structure and organization
- Existing code patterns and implementation styles`;
switch (suggestionType) {
case "refactoring":
return `${basePrompt}
Your specific focus is on **refactoring suggestions**. You should:
1. Identify code smells and areas that need cleanup
2. Find duplicated code that could be consolidated
3. Spot overly complex functions or classes that should be broken down
4. Look for inconsistent naming conventions or coding patterns
5. Find opportunities to improve code organization and modularity
6. Identify violations of SOLID principles or common design patterns
7. Look for dead code or unused dependencies
Prioritize suggestions by:
- Impact on maintainability
- Risk level (lower risk refactorings first)
- Complexity of the refactoring`;
case "security":
return `${basePrompt}
Your specific focus is on **security vulnerabilities and improvements**. You should:
1. Identify potential security vulnerabilities (OWASP Top 10)
2. Look for hardcoded secrets, API keys, or credentials
3. Check for proper input validation and sanitization
4. Identify SQL injection, XSS, or command injection risks
5. Review authentication and authorization patterns
6. Check for secure communication (HTTPS, encryption)
7. Look for insecure dependencies or outdated packages
8. Review error handling that might leak sensitive information
9. Check for proper session management
10. Identify insecure file handling or path traversal risks
Prioritize by severity:
- Critical: Exploitable vulnerabilities with high impact
- High: Security issues that could lead to data exposure
- Medium: Best practice violations that weaken security
- Low: Minor improvements to security posture`;
case "performance":
return `${basePrompt}
Your specific focus is on **performance issues and optimizations**. You should:
1. Identify N+1 query problems or inefficient database access
2. Look for unnecessary re-renders in React/frontend code
3. Find opportunities for caching or memoization
4. Identify large bundle sizes or unoptimized imports
5. Look for blocking operations that could be async
6. Find memory leaks or inefficient memory usage
7. Identify slow algorithms or data structure choices
8. Look for missing indexes in database schemas
9. Find opportunities for lazy loading or code splitting
10. Identify unnecessary network requests or API calls
Prioritize by:
- Impact on user experience
- Frequency of the slow path
- Ease of implementation`;
default: // "features"
return `${basePrompt}
Your specific focus is on **missing features and improvements**. You should:
1. Identify what the application does and what features it currently has
2. Look at the .automaker/app_spec.txt file if it exists
3. Generate a comprehensive list of missing features that would be valuable to users
4. Consider user experience improvements
5. Consider developer experience improvements
6. Look at common patterns in similar applications
Prioritize features by:
- Impact on users
- Alignment with project goals
- Complexity of implementation`;
}
}
/**
* Build the prompt for analyzing the project
* @param {string} suggestionType - Type of suggestions: "features", "refactoring", "security", "performance"
*/
buildAnalysisPrompt(suggestionType = "features") {
const commonIntro = `Analyze this project and generate a list of actionable suggestions.
**Your Task:**
1. First, explore the project structure:
- Read README.md, package.json, or similar config files
- Scan the source code directory structure
- Identify the tech stack and frameworks used
- Look at existing code and how it's implemented
2. Identify what the application does:
- What is the main purpose?
- What patterns and conventions are used?
`;
const commonOutput = `
**CRITICAL: Output your suggestions as a JSON array** at the end of your response, formatted like this:
\`\`\`json
[
{
"category": "Category Name",
"description": "Clear description of the suggestion",
"steps": [
"Step 1 to implement",
"Step 2 to implement",
"Step 3 to implement"
],
"priority": 1,
"reasoning": "Why this is important"
}
]
\`\`\`
**Important Guidelines:**
- Generate at least 10-15 suggestions
- Order them by priority (1 = highest priority)
- Each suggestion should have clear, actionable steps
- Be specific about what files might need to be modified
- Consider the existing tech stack and patterns
Begin by exploring the project structure.`;
switch (suggestionType) {
case "refactoring":
return `${commonIntro}
3. Look for refactoring opportunities:
- Find code duplication across the codebase
- Identify functions or classes that are too long or complex
- Look for inconsistent patterns or naming conventions
- Find tightly coupled code that should be decoupled
- Identify opportunities to extract reusable utilities
- Look for dead code or unused exports
- Check for proper separation of concerns
Categories to use: "Code Smell", "Duplication", "Complexity", "Architecture", "Naming", "Dead Code", "Coupling", "Testing"
${commonOutput}`;
case "security":
return `${commonIntro}
3. Look for security issues:
- Check for hardcoded secrets or API keys
- Look for potential injection vulnerabilities (SQL, XSS, command)
- Review authentication and authorization code
- Check input validation and sanitization
- Look for insecure dependencies
- Review error handling for information leakage
- Check for proper HTTPS/TLS usage
- Look for insecure file operations
Categories to use: "Critical", "High", "Medium", "Low" (based on severity)
${commonOutput}`;
case "performance":
return `${commonIntro}
3. Look for performance issues:
- Find N+1 queries or inefficient database access patterns
- Look for unnecessary re-renders in React components
- Identify missing memoization opportunities
- Check bundle size and import patterns
- Look for synchronous operations that could be async
- Find potential memory leaks
- Identify slow algorithms or data structures
- Look for missing caching opportunities
- Check for unnecessary network requests
Categories to use: "Database", "Rendering", "Memory", "Bundle Size", "Caching", "Algorithm", "Network"
${commonOutput}`;
default: // "features"
return `${commonIntro}
3. Generate feature suggestions:
- Think about what's missing compared to similar applications
- Consider user experience improvements
- Consider developer experience improvements
- Think about performance, security, and reliability
- Consider testing and documentation improvements
Categories to use: "User Experience", "Performance", "Security", "Testing", "Documentation", "Developer Experience", "Accessibility", etc.
${commonOutput}`;
}
}
/**
* Stop the current analysis
*/
stop() {
if (this.runningAnalysis && this.runningAnalysis.abortController) {
this.runningAnalysis.abortController.abort();
}
this.runningAnalysis = null;
}
}
module.exports = new FeatureSuggestionsService();

View File

@@ -0,0 +1,185 @@
const { query, AbortError } = require("@anthropic-ai/claude-agent-sdk");
const promptBuilder = require("./prompt-builder");
const contextManager = require("./context-manager");
const featureLoader = require("./feature-loader");
const mcpServerFactory = require("./mcp-server-factory");
/**
* Feature Verifier - Handles feature verification by running tests
*/
class FeatureVerifier {
/**
* Verify feature tests (runs tests and checks if they pass)
*/
async verifyFeatureTests(feature, projectPath, sendToRenderer, execution) {
console.log(
`[FeatureVerifier] Verifying tests for: ${feature.description}`
);
try {
const verifyMsg = `\n✅ Verifying tests for: ${feature.description}\n`;
await contextManager.writeToContextFile(
projectPath,
feature.id,
verifyMsg
);
sendToRenderer({
type: "auto_mode_phase",
featureId: feature.id,
phase: "verification",
message: `Verifying tests for: ${feature.description}`,
});
const abortController = new AbortController();
execution.abortController = abortController;
// Create custom MCP server with UpdateFeatureStatus tool
const featureToolsServer = mcpServerFactory.createFeatureToolsServer(
featureLoader.updateFeatureStatus.bind(featureLoader),
projectPath
);
const options = {
model: "claude-opus-4-5-20251101",
systemPrompt: await promptBuilder.getVerificationPrompt(projectPath),
maxTurns: 1000,
cwd: projectPath,
mcpServers: {
"automaker-tools": featureToolsServer,
},
allowedTools: [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"mcp__automaker-tools__UpdateFeatureStatus",
],
permissionMode: "acceptEdits",
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
abortController: abortController,
};
const prompt = await promptBuilder.buildVerificationPrompt(
feature,
projectPath
);
const runningTestsMsg =
"Running Playwright tests to verify feature implementation...\n";
await contextManager.writeToContextFile(
projectPath,
feature.id,
runningTestsMsg
);
sendToRenderer({
type: "auto_mode_progress",
featureId: feature.id,
content: runningTestsMsg,
});
const currentQuery = query({ prompt, options });
execution.query = currentQuery;
let responseText = "";
for await (const msg of currentQuery) {
// Check if this specific feature was aborted
if (!execution.isActive()) break;
if (msg.type === "assistant" && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
responseText += block.text;
await contextManager.writeToContextFile(
projectPath,
feature.id,
block.text
);
sendToRenderer({
type: "auto_mode_progress",
featureId: feature.id,
content: block.text,
});
} else if (block.type === "tool_use") {
const toolMsg = `\n🔧 Tool: ${block.name}\n`;
await contextManager.writeToContextFile(
projectPath,
feature.id,
toolMsg
);
sendToRenderer({
type: "auto_mode_tool",
featureId: feature.id,
tool: block.name,
input: block.input,
});
}
}
}
}
execution.query = null;
execution.abortController = null;
// Re-load features to check if it was marked as verified or waiting_approval (for skipTests)
const updatedFeatures = await featureLoader.loadFeatures(projectPath);
const updatedFeature = updatedFeatures.find((f) => f.id === feature.id);
// For skipTests features, waiting_approval is also considered a success
const passes =
updatedFeature?.status === "verified" ||
(updatedFeature?.skipTests &&
updatedFeature?.status === "waiting_approval");
const finalMsg = passes
? "✓ Verification successful: All tests passed\n"
: "✗ Tests failed or not all passing - feature remains in progress\n";
await contextManager.writeToContextFile(
projectPath,
feature.id,
finalMsg
);
sendToRenderer({
type: "auto_mode_progress",
featureId: feature.id,
content: finalMsg,
});
return {
passes,
message: responseText.substring(0, 500),
};
} catch (error) {
if (error instanceof AbortError || error?.name === "AbortError") {
console.log("[FeatureVerifier] Verification aborted");
if (execution) {
execution.abortController = null;
execution.query = null;
}
return {
passes: false,
message: "Verification aborted",
};
}
console.error("[FeatureVerifier] Error verifying feature:", error);
if (execution) {
execution.abortController = null;
execution.query = null;
}
throw error;
}
}
}
module.exports = new FeatureVerifier();

View File

@@ -0,0 +1,98 @@
const { createSdkMcpServer, tool } = require("@anthropic-ai/claude-agent-sdk");
const { z } = require("zod");
const featureLoader = require("./feature-loader");
/**
* MCP Server Factory - Creates custom MCP servers with tools
*/
class McpServerFactory {
/**
* Create a custom MCP server with the UpdateFeatureStatus tool
* This tool allows Claude Code to safely update feature status without
* directly modifying feature files, preventing race conditions
* and accidental state corruption.
*/
createFeatureToolsServer(updateFeatureStatusCallback, projectPath) {
return createSdkMcpServer({
name: "automaker-tools",
version: "1.0.0",
tools: [
tool(
"UpdateFeatureStatus",
"Create or update a feature. Use this tool to create new features with detailed information or update existing feature status. When creating features, provide comprehensive description, category, and implementation steps.",
{
featureId: z.string().describe("The ID of the feature (lowercase, hyphens for spaces). Example: 'user-authentication', 'budget-tracking'"),
status: z.enum(["backlog", "todo", "in_progress", "verified"]).describe("The status for the feature. Use 'backlog' or 'todo' for new features."),
summary: z.string().optional().describe("A brief summary of what was implemented/changed or what the feature does."),
description: z.string().optional().describe("A detailed description of the feature. Be comprehensive - explain what the feature does, its purpose, and key functionality."),
category: z.string().optional().describe("The category/phase for this feature. Example: 'Phase 1: Foundation', 'Phase 2: Core Logic', 'Phase 3: Polish', 'Authentication', 'UI/UX'"),
steps: z.array(z.string()).optional().describe("Array of implementation steps. Each step should be a clear, actionable task. Example: ['Set up database schema', 'Create API endpoints', 'Build UI components', 'Add validation']")
},
async (args) => {
try {
console.log(`[McpServerFactory] UpdateFeatureStatus tool called: featureId=${args.featureId}, status=${args.status}, summary=${args.summary || "(none)"}, category=${args.category || "(none)"}, steps=${args.steps?.length || 0}`);
console.log(`[Feature Creation] Creating/updating feature "${args.featureId}" with status "${args.status}"`);
// Load the feature to check skipTests flag
const features = await featureLoader.loadFeatures(projectPath);
const feature = features.find((f) => f.id === args.featureId);
if (!feature) {
console.log(`[Feature Creation] Feature ${args.featureId} not found - this might be a new feature being created`);
// This might be a new feature - try to proceed anyway
}
// If agent tries to mark as verified but feature has skipTests=true, convert to waiting_approval
let finalStatus = args.status;
// Convert 'todo' to 'backlog' for consistency, but only for new features
if (!feature && finalStatus === "todo") {
finalStatus = "backlog";
}
if (feature && args.status === "verified" && feature.skipTests === true) {
console.log(`[McpServerFactory] Feature ${args.featureId} has skipTests=true, converting verified -> waiting_approval`);
finalStatus = "waiting_approval";
}
// Call the provided callback to update feature status
await updateFeatureStatusCallback(
args.featureId,
finalStatus,
projectPath,
{
summary: args.summary,
description: args.description,
category: args.category,
steps: args.steps,
}
);
const statusMessage = finalStatus !== args.status
? `Successfully created/updated feature ${args.featureId} to status "${finalStatus}" (converted from "${args.status}")${args.summary ? ` - ${args.summary}` : ""}`
: `Successfully created/updated feature ${args.featureId} to status "${finalStatus}"${args.summary ? ` - ${args.summary}` : ""}`;
console.log(`[Feature Creation] ✓ ${statusMessage}`);
return {
content: [{
type: "text",
text: statusMessage
}]
};
} catch (error) {
console.error("[McpServerFactory] UpdateFeatureStatus tool error:", error);
console.error(`[Feature Creation] ✗ Failed to create/update feature ${args.featureId}: ${error.message}`);
return {
content: [{
type: "text",
text: `Failed to update feature status: ${error.message}`
}]
};
}
}
)
]
});
}
}
module.exports = new McpServerFactory();

View File

@@ -0,0 +1,349 @@
#!/usr/bin/env node
/**
* Standalone STDIO MCP Server for Automaker Tools
*
* This script runs as a standalone process and communicates via JSON-RPC 2.0
* over stdin/stdout. It implements the MCP protocol to expose the UpdateFeatureStatus
* tool to Codex CLI.
*
* Environment variables:
* - AUTOMAKER_PROJECT_PATH: Path to the project directory
* - AUTOMAKER_IPC_CHANNEL: IPC channel name for callback communication (optional, uses default)
*/
const readline = require('readline');
const path = require('path');
// Redirect all console.log output to stderr to avoid polluting MCP stdout
const originalConsoleLog = console.log;
console.log = (...args) => {
console.error(...args);
};
// Set up readline interface for line-by-line JSON-RPC input
// IMPORTANT: Use a separate output stream for readline to avoid interfering with JSON-RPC stdout
// We'll write JSON-RPC responses directly to stdout, not through readline
const rl = readline.createInterface({
input: process.stdin,
output: null, // Don't use stdout for readline output
terminal: false
});
let initialized = false;
let projectPath = null;
let ipcChannel = null;
// Get configuration from environment
projectPath = process.env.AUTOMAKER_PROJECT_PATH || process.cwd();
ipcChannel = process.env.AUTOMAKER_IPC_CHANNEL || 'mcp:update-feature-status';
// Load dependencies (these will be available in the Electron app context)
let featureLoader;
let electron;
// Try to load Electron IPC if available (when running from Electron app)
try {
// In Electron, we can use IPC directly
if (typeof require !== 'undefined') {
// Check if we're in Electron context
const electronModule = require('electron');
if (electronModule && electronModule.ipcMain) {
electron = electronModule;
}
}
} catch (e) {
// Not in Electron context, will use alternative method
}
// Load feature loader
// Try multiple paths since this script might be run from different contexts
try {
// First try relative path (when run from electron/services/)
featureLoader = require('./feature-loader');
} catch (e) {
try {
// Try absolute path resolution
const featureLoaderPath = path.resolve(__dirname, 'feature-loader.js');
delete require.cache[require.resolve(featureLoaderPath)];
featureLoader = require(featureLoaderPath);
} catch (e2) {
// If still fails, try from parent directory
try {
featureLoader = require(path.join(__dirname, '..', 'services', 'feature-loader'));
} catch (e3) {
console.error('[McpServerStdio] Error loading feature-loader:', e3.message);
console.error('[McpServerStdio] Tried paths:', [
'./feature-loader',
path.resolve(__dirname, 'feature-loader.js'),
path.join(__dirname, '..', 'services', 'feature-loader')
]);
process.exit(1);
}
}
}
/**
* Send JSON-RPC response
* CRITICAL: Must write directly to stdout, not via console.log
* MCP protocol requires ONLY JSON-RPC messages on stdout
*/
function sendResponse(id, result, error = null) {
const response = {
jsonrpc: '2.0',
id
};
if (error) {
response.error = error;
} else {
response.result = result;
}
// Write directly to stdout with newline (MCP uses line-delimited JSON)
process.stdout.write(JSON.stringify(response) + '\n');
}
/**
* Send JSON-RPC notification
* CRITICAL: Must write directly to stdout, not via console.log
*/
function sendNotification(method, params) {
const notification = {
jsonrpc: '2.0',
method,
params
};
// Write directly to stdout with newline (MCP uses line-delimited JSON)
process.stdout.write(JSON.stringify(notification) + '\n');
}
/**
* Handle MCP initialize request
*/
async function handleInitialize(params, id) {
initialized = true;
sendResponse(id, {
protocolVersion: '2024-11-05',
capabilities: {
tools: {}
},
serverInfo: {
name: 'automaker-tools',
version: '1.0.0'
}
});
}
/**
* Handle tools/list request
*/
async function handleToolsList(params, id) {
sendResponse(id, {
tools: [
{
name: 'UpdateFeatureStatus',
description: 'Update the status of a feature. Use this tool instead of directly modifying feature files to safely update feature status. IMPORTANT: If the feature has skipTests=true, you should NOT mark it as verified - instead it will automatically go to waiting_approval status for manual review. Always include a summary of what was done.',
inputSchema: {
type: 'object',
properties: {
featureId: {
type: 'string',
description: 'The ID of the feature to update'
},
status: {
type: 'string',
enum: ['backlog', 'in_progress', 'verified'],
description: 'The new status for the feature. Note: If skipTests=true, verified will be converted to waiting_approval automatically.'
},
summary: {
type: 'string',
description: 'A brief summary of what was implemented/changed. This will be displayed on the Kanban card. Example: "Added dark mode toggle. Modified: settings.tsx, theme-provider.tsx"'
}
},
required: ['featureId', 'status']
}
}
]
});
}
/**
* Handle tools/call request
*/
async function handleToolsCall(params, id) {
const { name, arguments: args } = params;
if (name !== 'UpdateFeatureStatus') {
sendResponse(id, null, {
code: -32601,
message: `Unknown tool: ${name}`
});
return;
}
try {
const { featureId, status, summary } = args;
if (!featureId || !status) {
sendResponse(id, null, {
code: -32602,
message: 'Missing required parameters: featureId and status are required'
});
return;
}
// Load the feature to check skipTests flag
const features = await featureLoader.loadFeatures(projectPath);
const feature = features.find((f) => f.id === featureId);
if (!feature) {
sendResponse(id, null, {
code: -32602,
message: `Feature ${featureId} not found`
});
return;
}
// If agent tries to mark as verified but feature has skipTests=true, convert to waiting_approval
let finalStatus = status;
if (status === 'verified' && feature.skipTests === true) {
finalStatus = 'waiting_approval';
}
// Call the update callback via IPC or direct call
// Since we're in a separate process, we need to use IPC to communicate back
// For now, we'll call the feature loader directly since it has the update method
await featureLoader.updateFeatureStatus(featureId, finalStatus, projectPath, { summary });
const statusMessage = finalStatus !== status
? `Successfully updated feature ${featureId} to status "${finalStatus}" (converted from "${status}" because skipTests=true)${summary ? ` with summary: "${summary}"` : ''}`
: `Successfully updated feature ${featureId} to status "${finalStatus}"${summary ? ` with summary: "${summary}"` : ''}`;
sendResponse(id, {
content: [
{
type: 'text',
text: statusMessage
}
]
});
} catch (error) {
console.error('[McpServerStdio] UpdateFeatureStatus error:', error);
sendResponse(id, null, {
code: -32603,
message: `Failed to update feature status: ${error.message}`
});
}
}
/**
* Handle JSON-RPC request
*/
async function handleRequest(line) {
let request;
try {
request = JSON.parse(line);
} catch (e) {
sendResponse(null, null, {
code: -32700,
message: 'Parse error'
});
return;
}
// Validate JSON-RPC 2.0 structure
if (request.jsonrpc !== '2.0') {
sendResponse(request.id || null, null, {
code: -32600,
message: 'Invalid Request'
});
return;
}
const { method, params, id } = request;
// Handle notifications (no id)
if (id === undefined) {
// Handle notifications if needed
return;
}
// Handle requests
try {
switch (method) {
case 'initialize':
await handleInitialize(params, id);
break;
case 'tools/list':
if (!initialized) {
sendResponse(id, null, {
code: -32002,
message: 'Server not initialized'
});
return;
}
await handleToolsList(params, id);
break;
case 'tools/call':
if (!initialized) {
sendResponse(id, null, {
code: -32002,
message: 'Server not initialized'
});
return;
}
await handleToolsCall(params, id);
break;
default:
sendResponse(id, null, {
code: -32601,
message: `Method not found: ${method}`
});
}
} catch (error) {
console.error('[McpServerStdio] Error handling request:', error);
sendResponse(id, null, {
code: -32603,
message: `Internal error: ${error.message}`
});
}
}
// Process stdin line by line
rl.on('line', async (line) => {
if (!line.trim()) {
return;
}
await handleRequest(line);
});
// Handle errors
rl.on('error', (error) => {
console.error('[McpServerStdio] Readline error:', error);
process.exit(1);
});
// Handle process termination
process.on('SIGTERM', () => {
rl.close();
process.exit(0);
});
process.on('SIGINT', () => {
rl.close();
process.exit(0);
});
// Log startup
console.error('[McpServerStdio] Starting MCP server for automaker-tools');
console.error(`[McpServerStdio] Project path: ${projectPath}`);
console.error(`[McpServerStdio] IPC channel: ${ipcChannel}`);

View File

@@ -0,0 +1,524 @@
/**
* Model Provider Abstraction Layer
*
* This module provides an abstract interface for model providers (Claude, Codex, etc.)
* allowing the application to use different AI models through a unified API.
*/
/**
* Base class for model providers
* Concrete implementations should extend this class
*/
class ModelProvider {
constructor(config = {}) {
this.config = config;
this.name = 'base';
}
/**
* Get provider name
* @returns {string} Provider name
*/
getName() {
return this.name;
}
/**
* Execute a query with the model provider
* @param {Object} options Query options
* @param {string} options.prompt The prompt to send
* @param {string} options.model The model to use
* @param {string} options.systemPrompt System prompt
* @param {string} options.cwd Working directory
* @param {number} options.maxTurns Maximum turns
* @param {string[]} options.allowedTools Allowed tools
* @param {Object} options.mcpServers MCP servers configuration
* @param {AbortController} options.abortController Abort controller
* @param {Object} options.thinking Thinking configuration
* @returns {AsyncGenerator} Async generator yielding messages
*/
async *executeQuery(options) {
throw new Error('executeQuery must be implemented by subclass');
}
/**
* Detect if this provider's CLI/SDK is installed
* @returns {Promise<Object>} Installation status
*/
async detectInstallation() {
throw new Error('detectInstallation must be implemented by subclass');
}
/**
* Get list of available models for this provider
* @returns {Array<Object>} Array of model definitions
*/
getAvailableModels() {
throw new Error('getAvailableModels must be implemented by subclass');
}
/**
* Validate provider configuration
* @returns {Object} Validation result { valid: boolean, errors: string[] }
*/
validateConfig() {
throw new Error('validateConfig must be implemented by subclass');
}
/**
* Get the full model string for a model key
* @param {string} modelKey Short model key (e.g., 'opus', 'gpt-5.1-codex')
* @returns {string} Full model string
*/
getModelString(modelKey) {
throw new Error('getModelString must be implemented by subclass');
}
/**
* Check if provider supports a specific feature
* @param {string} feature Feature name (e.g., 'thinking', 'tools', 'streaming')
* @returns {boolean} Whether the feature is supported
*/
supportsFeature(feature) {
return false;
}
}
/**
* Claude Provider - Uses Anthropic Claude Agent SDK
*/
class ClaudeProvider extends ModelProvider {
constructor(config = {}) {
super(config);
this.name = 'claude';
this.sdk = null;
}
/**
* Try to load credentials from the app's own credentials.json file.
* This is where we store OAuth tokens and API keys that users enter in the setup wizard.
* Returns { oauthToken, apiKey } or null values if not found.
*/
loadTokenFromAppCredentials() {
try {
const fs = require('fs');
const path = require('path');
const { app } = require('electron');
const credentialsPath = path.join(app.getPath('userData'), 'credentials.json');
if (!fs.existsSync(credentialsPath)) {
console.log('[ClaudeProvider] App credentials file does not exist:', credentialsPath);
return { oauthToken: null, apiKey: null };
}
const raw = fs.readFileSync(credentialsPath, 'utf-8');
const parsed = JSON.parse(raw);
// Check for OAuth token first (from claude setup-token), then API key
const oauthToken = parsed.anthropic_oauth_token || null;
const apiKey = parsed.anthropic || parsed.anthropic_api_key || null;
console.log('[ClaudeProvider] App credentials check - OAuth token:', !!oauthToken, ', API key:', !!apiKey);
return { oauthToken, apiKey };
} catch (err) {
console.warn('[ClaudeProvider] Failed to read app credentials:', err?.message);
return { oauthToken: null, apiKey: null };
}
}
/**
* Try to load a Claude OAuth token from the local CLI config (~/.claude/config.json).
* Returns the token string or null if not found.
* NOTE: Claude's credentials.json is encrypted, so we only try config.json
*/
loadTokenFromCliConfig() {
try {
const fs = require('fs');
const path = require('path');
const configPath = path.join(require('os').homedir(), '.claude', 'config.json');
if (!fs.existsSync(configPath)) {
return null;
}
const raw = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw);
// CLI config stores token as oauth_token (newer) or token (older)
return parsed.oauth_token || parsed.token || null;
} catch (err) {
console.warn('[ClaudeProvider] Failed to read CLI config token:', err?.message);
return null;
}
}
ensureAuthEnv() {
// If API key or token already present in environment, keep as-is.
if (process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN) {
console.log('[ClaudeProvider] Auth already present in environment');
return true;
}
// Priority 1: Try to load from app's own credentials (setup wizard)
const appCredentials = this.loadTokenFromAppCredentials();
if (appCredentials.oauthToken) {
process.env.CLAUDE_CODE_OAUTH_TOKEN = appCredentials.oauthToken;
console.log('[ClaudeProvider] Loaded CLAUDE_CODE_OAUTH_TOKEN from app credentials');
return true;
}
if (appCredentials.apiKey) {
process.env.ANTHROPIC_API_KEY = appCredentials.apiKey;
console.log('[ClaudeProvider] Loaded ANTHROPIC_API_KEY from app credentials');
return true;
}
// Priority 2: Try to hydrate from CLI login config (legacy)
const token = this.loadTokenFromCliConfig();
if (token) {
process.env.CLAUDE_CODE_OAUTH_TOKEN = token;
console.log('[ClaudeProvider] Loaded CLAUDE_CODE_OAUTH_TOKEN from ~/.claude/config.json');
return true;
}
// Check if CLI is installed but not logged in
try {
const claudeCliDetector = require('./claude-cli-detector');
const detection = claudeCliDetector.detectClaudeInstallation();
if (detection.installed && detection.method === 'cli') {
console.error('[ClaudeProvider] Claude CLI is installed but not authenticated. Use the setup wizard or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.');
} else {
console.error('[ClaudeProvider] No Anthropic auth found. Use the setup wizard or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN.');
}
} catch (err) {
console.error('[ClaudeProvider] No Anthropic auth found. Use the setup wizard or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN.');
}
return false;
}
/**
* Lazily load the Claude SDK
*/
loadSdk() {
if (!this.sdk) {
this.sdk = require('@anthropic-ai/claude-agent-sdk');
}
return this.sdk;
}
async *executeQuery(options) {
// Ensure we have auth; fall back to app credentials or CLI login token if available.
if (!this.ensureAuthEnv()) {
// Check if CLI is installed to provide better error message
let msg = 'Missing Anthropic auth. Go to Settings > Setup to configure your Claude authentication.';
try {
const claudeCliDetector = require('./claude-cli-detector');
const detection = claudeCliDetector.detectClaudeInstallation();
if (detection.installed && detection.method === 'cli') {
msg = 'Claude CLI is installed but not authenticated. Go to Settings > Setup to provide your subscription token (from `claude setup-token`) or API key.';
} else {
msg = 'Missing Anthropic auth. Go to Settings > Setup to configure your Claude authentication, or set ANTHROPIC_API_KEY environment variable.';
}
} catch (err) {
// Fallback to default message
}
console.error(`[ClaudeProvider] ${msg}`);
yield { type: 'error', error: msg };
return;
}
const { query } = this.loadSdk();
const sdkOptions = {
model: options.model,
systemPrompt: options.systemPrompt,
maxTurns: options.maxTurns || 1000,
cwd: options.cwd,
mcpServers: options.mcpServers,
allowedTools: options.allowedTools,
permissionMode: options.permissionMode || 'acceptEdits',
sandbox: options.sandbox,
abortController: options.abortController,
};
// Add thinking configuration if enabled
if (options.thinking) {
sdkOptions.thinking = options.thinking;
}
const currentQuery = query({ prompt: options.prompt, options: sdkOptions });
for await (const msg of currentQuery) {
yield msg;
}
}
async detectInstallation() {
const claudeCliDetector = require('./claude-cli-detector');
return claudeCliDetector.getFullStatus();
}
getAvailableModels() {
return [
{
id: 'haiku',
name: 'Claude Haiku',
modelString: 'claude-haiku-4-5',
provider: 'claude',
description: 'Fast and efficient for simple tasks',
tier: 'basic'
},
{
id: 'sonnet',
name: 'Claude Sonnet',
modelString: 'claude-sonnet-4-20250514',
provider: 'claude',
description: 'Balanced performance and capabilities',
tier: 'standard'
},
{
id: 'opus',
name: 'Claude Opus 4.5',
modelString: 'claude-opus-4-5-20251101',
provider: 'claude',
description: 'Most capable model for complex tasks',
tier: 'premium'
}
];
}
validateConfig() {
const errors = [];
// Ensure auth is available (try to auto-load from app credentials or CLI config)
this.ensureAuthEnv();
if (!process.env.CLAUDE_CODE_OAUTH_TOKEN && !process.env.ANTHROPIC_API_KEY) {
errors.push('No Claude authentication found. Go to Settings > Setup to configure your subscription token or API key.');
}
return {
valid: errors.length === 0,
errors
};
}
getModelString(modelKey) {
const modelMap = {
haiku: 'claude-haiku-4-5',
sonnet: 'claude-sonnet-4-20250514',
opus: 'claude-opus-4-5-20251101'
};
return modelMap[modelKey] || modelMap.opus;
}
supportsFeature(feature) {
const supportedFeatures = ['thinking', 'tools', 'streaming', 'mcp'];
return supportedFeatures.includes(feature);
}
}
/**
* Codex Provider - Uses OpenAI Codex CLI
*/
class CodexProvider extends ModelProvider {
constructor(config = {}) {
super(config);
this.name = 'codex';
}
async *executeQuery(options) {
const codexExecutor = require('./codex-executor');
// Validate that we're not receiving a Claude model string
if (options.model && options.model.startsWith('claude-')) {
const errorMsg = `Codex provider cannot use Claude model '${options.model}'. Codex only supports OpenAI models (gpt-5.1-codex-max, gpt-5.1-codex, gpt-5.1-codex-mini, gpt-5.1).`;
console.error(`[CodexProvider] ${errorMsg}`);
yield {
type: 'error',
error: errorMsg
};
return;
}
const executeOptions = {
prompt: options.prompt,
model: options.model,
cwd: options.cwd,
systemPrompt: options.systemPrompt,
maxTurns: options.maxTurns || 20,
allowedTools: options.allowedTools,
mcpServers: options.mcpServers, // Pass MCP servers config to executor
env: {
...process.env,
OPENAI_API_KEY: process.env.OPENAI_API_KEY
}
};
// Execute and yield results
const generator = codexExecutor.execute(executeOptions);
for await (const msg of generator) {
yield msg;
}
}
async detectInstallation() {
const codexCliDetector = require('./codex-cli-detector');
return codexCliDetector.getInstallationInfo();
}
getAvailableModels() {
return [
{
id: 'gpt-5.1-codex-max',
name: 'GPT-5.1 Codex Max',
modelString: 'gpt-5.1-codex-max',
provider: 'codex',
description: 'Latest flagship - deep and fast reasoning for coding',
tier: 'premium',
default: true
},
{
id: 'gpt-5.1-codex',
name: 'GPT-5.1 Codex',
modelString: 'gpt-5.1-codex',
provider: 'codex',
description: 'Optimized for code generation',
tier: 'standard'
},
{
id: 'gpt-5.1-codex-mini',
name: 'GPT-5.1 Codex Mini',
modelString: 'gpt-5.1-codex-mini',
provider: 'codex',
description: 'Faster and cheaper option',
tier: 'basic'
},
{
id: 'gpt-5.1',
name: 'GPT-5.1',
modelString: 'gpt-5.1',
provider: 'codex',
description: 'Broad world knowledge with strong reasoning',
tier: 'standard'
}
];
}
validateConfig() {
const errors = [];
const codexCliDetector = require('./codex-cli-detector');
const installation = codexCliDetector.detectCodexInstallation();
if (!installation.installed && !process.env.OPENAI_API_KEY) {
errors.push('Codex CLI not installed and no OPENAI_API_KEY found.');
}
return {
valid: errors.length === 0,
errors
};
}
getModelString(modelKey) {
// Codex models use the key directly as the model string
const modelMap = {
'gpt-5.1-codex-max': 'gpt-5.1-codex-max',
'gpt-5.1-codex': 'gpt-5.1-codex',
'gpt-5.1-codex-mini': 'gpt-5.1-codex-mini',
'gpt-5.1': 'gpt-5.1'
};
return modelMap[modelKey] || 'gpt-5.1-codex-max';
}
supportsFeature(feature) {
const supportedFeatures = ['tools', 'streaming'];
return supportedFeatures.includes(feature);
}
}
/**
* Model Provider Factory
* Creates the appropriate provider based on model or provider name
*/
class ModelProviderFactory {
static providers = {
claude: ClaudeProvider,
codex: CodexProvider
};
/**
* Get provider for a specific model
* @param {string} modelId Model ID (e.g., 'opus', 'gpt-5.1-codex')
* @returns {ModelProvider} Provider instance
*/
static getProviderForModel(modelId) {
// Check if it's a Claude model
const claudeModels = ['haiku', 'sonnet', 'opus'];
if (claudeModels.includes(modelId)) {
return new ClaudeProvider();
}
// Check if it's a Codex/OpenAI model
const codexModels = [
'gpt-5.1-codex-max', 'gpt-5.1-codex', 'gpt-5.1-codex-mini', 'gpt-5.1'
];
if (codexModels.includes(modelId)) {
return new CodexProvider();
}
// Default to Claude
return new ClaudeProvider();
}
/**
* Get provider by name
* @param {string} providerName Provider name ('claude' or 'codex')
* @returns {ModelProvider} Provider instance
*/
static getProvider(providerName) {
const ProviderClass = this.providers[providerName];
if (!ProviderClass) {
throw new Error(`Unknown provider: ${providerName}`);
}
return new ProviderClass();
}
/**
* Get all available providers
* @returns {string[]} List of provider names
*/
static getAvailableProviders() {
return Object.keys(this.providers);
}
/**
* Get all available models across all providers
* @returns {Array<Object>} All available models
*/
static getAllModels() {
const allModels = [];
for (const providerName of this.getAvailableProviders()) {
const provider = this.getProvider(providerName);
const models = provider.getAvailableModels();
allModels.push(...models);
}
return allModels;
}
/**
* Check installation status for all providers
* @returns {Promise<Object>} Installation status for each provider
*/
static async checkAllProviders() {
const status = {};
for (const providerName of this.getAvailableProviders()) {
const provider = this.getProvider(providerName);
status[providerName] = await provider.detectInstallation();
}
return status;
}
}
module.exports = {
ModelProvider,
ClaudeProvider,
CodexProvider,
ModelProviderFactory
};

View File

@@ -0,0 +1,320 @@
/**
* Model Registry - Centralized model definitions and metadata
*
* This module provides a central registry of all available models
* across different providers (Claude, Codex/OpenAI).
*/
/**
* Model Categories
*/
const MODEL_CATEGORIES = {
CLAUDE: 'claude',
OPENAI: 'openai',
CODEX: 'codex'
};
/**
* Model Tiers (capability levels)
*/
const MODEL_TIERS = {
BASIC: 'basic', // Fast, cheap, simple tasks
STANDARD: 'standard', // Balanced performance
PREMIUM: 'premium' // Most capable, complex tasks
};
const CODEX_MODEL_IDS = [
'gpt-5.1-codex-max',
'gpt-5.1-codex',
'gpt-5.1-codex-mini',
'gpt-5.1'
];
/**
* All available models with full metadata
*/
const MODELS = {
// Claude Models
haiku: {
id: 'haiku',
name: 'Claude Haiku',
modelString: 'claude-haiku-4-5',
provider: 'claude',
category: MODEL_CATEGORIES.CLAUDE,
tier: MODEL_TIERS.BASIC,
description: 'Fast and efficient for simple tasks',
capabilities: ['code', 'text', 'tools'],
maxTokens: 8192,
contextWindow: 200000,
supportsThinking: true,
requiresAuth: 'CLAUDE_CODE_OAUTH_TOKEN'
},
sonnet: {
id: 'sonnet',
name: 'Claude Sonnet',
modelString: 'claude-sonnet-4-20250514',
provider: 'claude',
category: MODEL_CATEGORIES.CLAUDE,
tier: MODEL_TIERS.STANDARD,
description: 'Balanced performance and capabilities',
capabilities: ['code', 'text', 'tools', 'analysis'],
maxTokens: 8192,
contextWindow: 200000,
supportsThinking: true,
requiresAuth: 'CLAUDE_CODE_OAUTH_TOKEN'
},
opus: {
id: 'opus',
name: 'Claude Opus 4.5',
modelString: 'claude-opus-4-5-20251101',
provider: 'claude',
category: MODEL_CATEGORIES.CLAUDE,
tier: MODEL_TIERS.PREMIUM,
description: 'Most capable model for complex tasks',
capabilities: ['code', 'text', 'tools', 'analysis', 'reasoning'],
maxTokens: 8192,
contextWindow: 200000,
supportsThinking: true,
requiresAuth: 'CLAUDE_CODE_OAUTH_TOKEN',
default: true
},
// OpenAI GPT-5.1 Codex Models
'gpt-5.1-codex-max': {
id: 'gpt-5.1-codex-max',
name: 'GPT-5.1 Codex Max',
modelString: 'gpt-5.1-codex-max',
provider: 'codex',
category: MODEL_CATEGORIES.OPENAI,
tier: MODEL_TIERS.PREMIUM,
description: 'Latest flagship - deep and fast reasoning for coding',
capabilities: ['code', 'text', 'tools', 'reasoning'],
maxTokens: 32768,
contextWindow: 128000,
supportsThinking: false,
requiresAuth: 'OPENAI_API_KEY',
codexDefault: true
},
'gpt-5.1-codex': {
id: 'gpt-5.1-codex',
name: 'GPT-5.1 Codex',
modelString: 'gpt-5.1-codex',
provider: 'codex',
category: MODEL_CATEGORIES.OPENAI,
tier: MODEL_TIERS.STANDARD,
description: 'Optimized for code generation',
capabilities: ['code', 'text', 'tools'],
maxTokens: 32768,
contextWindow: 128000,
supportsThinking: false,
requiresAuth: 'OPENAI_API_KEY'
},
'gpt-5.1-codex-mini': {
id: 'gpt-5.1-codex-mini',
name: 'GPT-5.1 Codex Mini',
modelString: 'gpt-5.1-codex-mini',
provider: 'codex',
category: MODEL_CATEGORIES.OPENAI,
tier: MODEL_TIERS.BASIC,
description: 'Faster and cheaper option',
capabilities: ['code', 'text'],
maxTokens: 16384,
contextWindow: 128000,
supportsThinking: false,
requiresAuth: 'OPENAI_API_KEY'
},
'gpt-5.1': {
id: 'gpt-5.1',
name: 'GPT-5.1',
modelString: 'gpt-5.1',
provider: 'codex',
category: MODEL_CATEGORIES.OPENAI,
tier: MODEL_TIERS.STANDARD,
description: 'Broad world knowledge with strong reasoning',
capabilities: ['code', 'text', 'reasoning'],
maxTokens: 32768,
contextWindow: 128000,
supportsThinking: false,
requiresAuth: 'OPENAI_API_KEY'
}
};
/**
* Model Registry class for querying and managing models
*/
class ModelRegistry {
/**
* Get all registered models
* @returns {Object} All models
*/
static getAllModels() {
return MODELS;
}
/**
* Get model by ID
* @param {string} modelId Model ID
* @returns {Object|null} Model definition or null
*/
static getModel(modelId) {
return MODELS[modelId] || null;
}
/**
* Get models by provider
* @param {string} provider Provider name ('claude' or 'codex')
* @returns {Object[]} Array of models for the provider
*/
static getModelsByProvider(provider) {
return Object.values(MODELS).filter(m => m.provider === provider);
}
/**
* Get models by category
* @param {string} category Category name
* @returns {Object[]} Array of models in the category
*/
static getModelsByCategory(category) {
return Object.values(MODELS).filter(m => m.category === category);
}
/**
* Get models by tier
* @param {string} tier Tier name
* @returns {Object[]} Array of models in the tier
*/
static getModelsByTier(tier) {
return Object.values(MODELS).filter(m => m.tier === tier);
}
/**
* Get default model for a provider
* @param {string} provider Provider name
* @returns {Object|null} Default model or null
*/
static getDefaultModel(provider = 'claude') {
const models = this.getModelsByProvider(provider);
if (provider === 'claude') {
return models.find(m => m.default) || models[0];
}
if (provider === 'codex') {
return models.find(m => m.codexDefault) || models[0];
}
return models[0];
}
/**
* Get model string (full model name) for a model ID
* @param {string} modelId Model ID
* @returns {string} Full model string
*/
static getModelString(modelId) {
const model = this.getModel(modelId);
return model ? model.modelString : modelId;
}
/**
* Determine provider for a model ID
* @param {string} modelId Model ID
* @returns {string} Provider name ('claude' or 'codex')
*/
static getProviderForModel(modelId) {
const model = this.getModel(modelId);
if (model) {
return model.provider;
}
// Fallback detection for models not explicitly registered (keeps legacy Codex IDs working)
if (CODEX_MODEL_IDS.includes(modelId)) {
return 'codex';
}
return 'claude';
}
/**
* Check if a model is a Claude model
* @param {string} modelId Model ID
* @returns {boolean} Whether it's a Claude model
*/
static isClaudeModel(modelId) {
return this.getProviderForModel(modelId) === 'claude';
}
/**
* Check if a model is a Codex/OpenAI model
* @param {string} modelId Model ID
* @returns {boolean} Whether it's a Codex model
*/
static isCodexModel(modelId) {
return this.getProviderForModel(modelId) === 'codex';
}
/**
* Get models grouped by provider for UI display
* @returns {Object} Models grouped by provider
*/
static getModelsGroupedByProvider() {
return {
claude: this.getModelsByProvider('claude'),
codex: this.getModelsByProvider('codex')
};
}
/**
* Get all model IDs as an array
* @returns {string[]} Array of model IDs
*/
static getAllModelIds() {
return Object.keys(MODELS);
}
/**
* Check if model supports a specific capability
* @param {string} modelId Model ID
* @param {string} capability Capability name
* @returns {boolean} Whether the model supports the capability
*/
static modelSupportsCapability(modelId, capability) {
const model = this.getModel(modelId);
return model ? model.capabilities.includes(capability) : false;
}
/**
* Check if model supports extended thinking
* @param {string} modelId Model ID
* @returns {boolean} Whether the model supports thinking
*/
static modelSupportsThinking(modelId) {
const model = this.getModel(modelId);
return model ? model.supportsThinking : false;
}
/**
* Get required authentication for a model
* @param {string} modelId Model ID
* @returns {string|null} Required auth env variable name
*/
static getRequiredAuth(modelId) {
const model = this.getModel(modelId);
return model ? model.requiresAuth : null;
}
/**
* Check if authentication is available for a model
* @param {string} modelId Model ID
* @returns {boolean} Whether auth is available
*/
static hasAuthForModel(modelId) {
const authVar = this.getRequiredAuth(modelId);
if (!authVar) return false;
return !!process.env[authVar];
}
}
module.exports = {
MODEL_CATEGORIES,
MODEL_TIERS,
MODELS,
ModelRegistry
};

View File

@@ -0,0 +1,112 @@
const { query, AbortError } = require("@anthropic-ai/claude-agent-sdk");
const promptBuilder = require("./prompt-builder");
/**
* Project Analyzer - Scans codebase and updates app_spec.txt
*/
class ProjectAnalyzer {
/**
* Run the project analysis using Claude Agent SDK
*/
async runProjectAnalysis(projectPath, analysisId, sendToRenderer, execution) {
console.log(`[ProjectAnalyzer] Running project analysis for: ${projectPath}`);
try {
sendToRenderer({
type: "auto_mode_phase",
featureId: analysisId,
phase: "planning",
message: "Scanning project structure...",
});
const abortController = new AbortController();
execution.abortController = abortController;
const options = {
model: "claude-sonnet-4-20250514",
systemPrompt: promptBuilder.getProjectAnalysisSystemPrompt(),
maxTurns: 50,
cwd: projectPath,
allowedTools: ["Read", "Write", "Edit", "Glob", "Grep", "Bash"],
permissionMode: "acceptEdits",
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
abortController: abortController,
};
const prompt = promptBuilder.buildProjectAnalysisPrompt(projectPath);
sendToRenderer({
type: "auto_mode_progress",
featureId: analysisId,
content: "Starting project analysis...\n",
});
const currentQuery = query({ prompt, options });
execution.query = currentQuery;
let responseText = "";
for await (const msg of currentQuery) {
if (!execution.isActive()) break;
if (msg.type === "assistant" && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
responseText += block.text;
sendToRenderer({
type: "auto_mode_progress",
featureId: analysisId,
content: block.text,
});
} else if (block.type === "tool_use") {
sendToRenderer({
type: "auto_mode_tool",
featureId: analysisId,
tool: block.name,
input: block.input,
});
}
}
}
}
execution.query = null;
execution.abortController = null;
sendToRenderer({
type: "auto_mode_phase",
featureId: analysisId,
phase: "verification",
message: "Project analysis complete",
});
return {
success: true,
message: "Project analyzed successfully",
};
} catch (error) {
if (error instanceof AbortError || error?.name === "AbortError") {
console.log("[ProjectAnalyzer] Project analysis aborted");
if (execution) {
execution.abortController = null;
execution.query = null;
}
return {
success: false,
message: "Analysis aborted",
};
}
console.error("[ProjectAnalyzer] Error in project analysis:", error);
if (execution) {
execution.abortController = null;
execution.query = null;
}
throw error;
}
}
}
module.exports = new ProjectAnalyzer();

View File

@@ -0,0 +1,787 @@
const contextManager = require("./context-manager");
/**
* Prompt Builder - Generates prompts for different agent tasks
*/
class PromptBuilder {
/**
* Build the prompt for implementing a specific feature
*/
async buildFeaturePrompt(feature, projectPath) {
const skipTestsNote = feature.skipTests
? `\n**⚠️ IMPORTANT - Manual Testing Mode:**\nThis feature has skipTests=true, which means:\n- DO NOT commit changes automatically\n- DO NOT mark as verified - it will automatically go to "waiting_approval" status\n- The user will manually review and commit the changes\n- Just implement the feature and mark it as verified (it will be converted to waiting_approval)\n`
: "";
let imagesNote = "";
if (feature.imagePaths && feature.imagePaths.length > 0) {
const imagesList = feature.imagePaths
.map(
(img, idx) =>
` ${idx + 1}. ${img.filename} (${img.mimeType})\n Path: ${
img.path
}`
)
.join("\n");
imagesNote = `\n**📎 Context Images Attached:**\nThe user has attached ${feature.imagePaths.length} image(s) for context. These images are provided both visually (in the initial message) and as files you can read:
${imagesList}
You can use the Read tool to view these images at any time during implementation. Review them carefully before implementing.\n`;
}
// Get context files preview
const contextFilesPreview = await contextManager.getContextFilesPreview(
projectPath
);
// Get memory content (lessons learned from previous runs)
const memoryContent = await contextManager.getMemoryContent(projectPath);
// Build mode header for this feature
const modeHeader = feature.skipTests
? `**🔨 MODE: Manual Review (No Automated Tests)**
This feature is set for manual review - focus on clean implementation without automated tests.`
: `**🧪 MODE: Test-Driven Development (TDD)**
This feature requires automated Playwright tests to verify the implementation.`;
return `You are working on a feature implementation task.
${modeHeader}
${memoryContent}
**Current Feature to Implement:**
ID: ${feature.id}
Category: ${feature.category || "Uncategorized"}
Description: ${feature.description || feature.summary || feature.title || "No description provided"}
${skipTestsNote}${imagesNote}${contextFilesPreview}
**Steps to Complete:**
${(feature.steps || []).map((step, i) => `${i + 1}. ${step}`).join("\n") || "No specific steps provided - implement based on description"}
**Your Task:**
1. Read the project files to understand the current codebase structure
2. Implement the feature according to the description and steps
${
feature.skipTests
? "3. Test the implementation manually (no automated tests needed for skipTests features)"
: "3. Write Playwright tests to verify the feature works correctly\n4. Run the tests and ensure they pass\n5. **DELETE the test file(s) you created** - tests are only for immediate verification"
}
${
feature.skipTests ? "4" : "6"
}. **CRITICAL: Use the UpdateFeatureStatus tool to mark this feature as verified**
${
feature.skipTests
? "5. **DO NOT commit changes** - the user will review and commit manually"
: "7. Commit your changes with git"
}
**IMPORTANT - Updating Feature Status:**
When you have completed the feature${
feature.skipTests ? "" : " and all tests pass"
}, you MUST use the \`mcp__automaker-tools__UpdateFeatureStatus\` tool to update the feature status:
- Call the tool with: featureId="${feature.id}" and status="verified"
- **You can also include a summary parameter** to describe what was done: summary="Brief summary of changes"
- **DO NOT manually edit feature files** - this can cause race conditions
- The UpdateFeatureStatus tool safely updates the feature status without risk of corrupting other data
- **If skipTests=true, the tool will automatically convert "verified" to "waiting_approval"** - this is correct behavior
**IMPORTANT - Feature Summary (REQUIRED):**
When calling UpdateFeatureStatus, you MUST include a summary parameter that describes:
- What files were modified/created
- What functionality was added or changed
- Any notable implementation decisions
Example:
\`\`\`
UpdateFeatureStatus(featureId="${
feature.id
}", status="verified", summary="Added dark mode toggle to settings. Modified: settings.tsx, theme-provider.tsx. Created new useTheme hook.")
\`\`\`
The summary will be displayed on the Kanban card so the user can see what was done without checking the code.
**Important Guidelines:**
- Focus ONLY on implementing this specific feature
- Write clean, production-quality code
- Add proper error handling
${
feature.skipTests
? "- Skip automated testing (skipTests=true) - user will manually verify"
: "- Write comprehensive Playwright tests\n- Ensure all existing tests still pass\n- Mark the feature as passing only when all tests are green\n- **CRITICAL: Delete test files after verification** - tests accumulate and become brittle"
}
- **CRITICAL: Use UpdateFeatureStatus tool instead of editing feature files directly**
- **CRITICAL: Always include a summary when marking feature as verified**
${
feature.skipTests
? "- **DO NOT commit changes** - user will review and commit manually"
: "- Make a git commit when complete"
}
**Testing Utilities (CRITICAL):**
1. **Create/maintain tests/utils.ts** - Add helper functions for finding elements and common test operations
2. **Use utilities in tests** - Import and use helper functions instead of repeating selectors
3. **Add utilities as needed** - When you write a test, if you need a new helper, add it to utils.ts
4. **Update utilities when functionality changes** - If you modify components, update corresponding utilities
Example utilities to add:
- getByTestId(page, testId) - Find elements by data-testid
- getButtonByText(page, text) - Find buttons by text
- clickElement(page, testId) - Click an element by test ID
- fillForm(page, formData) - Fill form fields
- waitForElement(page, testId) - Wait for element to appear
This makes future tests easier to write and maintain!
**Test Deletion Policy:**
After tests pass, delete them immediately:
\`\`\`bash
rm tests/[feature-name].spec.ts
\`\`\`
Begin by reading the project structure and then implementing the feature.`;
}
/**
* Build the prompt for verifying a specific feature
*/
async buildVerificationPrompt(feature, projectPath) {
const skipTestsNote = feature.skipTests
? `\n**⚠️ IMPORTANT - Manual Testing Mode:**\nThis feature has skipTests=true, which means:\n- DO NOT commit changes automatically\n- DO NOT mark as verified - it will automatically go to "waiting_approval" status\n- The user will manually review and commit the changes\n- Just implement the feature and mark it as verified (it will be converted to waiting_approval)\n`
: "";
let imagesNote = "";
if (feature.imagePaths && feature.imagePaths.length > 0) {
const imagesList = feature.imagePaths
.map(
(img, idx) =>
` ${idx + 1}. ${img.filename} (${img.mimeType})\n Path: ${
img.path
}`
)
.join("\n");
imagesNote = `\n**📎 Context Images Attached:**\nThe user has attached ${feature.imagePaths.length} image(s) for context. These images are provided both visually (in the initial message) and as files you can read:
${imagesList}
You can use the Read tool to view these images at any time during implementation. Review them carefully before implementing.\n`;
}
// Get context files preview
const contextFilesPreview = await contextManager.getContextFilesPreview(
projectPath
);
// Get memory content (lessons learned from previous runs)
const memoryContent = await contextManager.getMemoryContent(projectPath);
// Build mode header for this feature
const modeHeader = feature.skipTests
? `**🔨 MODE: Manual Review (No Automated Tests)**
This feature is set for manual review - focus on completing implementation without automated tests.`
: `**🧪 MODE: Test-Driven Development (TDD)**
This feature requires automated Playwright tests to verify the implementation.`;
return `You are implementing and verifying a feature until it is complete and working correctly.
${modeHeader}
${memoryContent}
**Feature to Implement/Verify:**
ID: ${feature.id}
Category: ${feature.category || "Uncategorized"}
Description: ${feature.description || feature.summary || feature.title || "No description provided"}
Current Status: ${feature.status}
${skipTestsNote}${imagesNote}${contextFilesPreview}
**Steps that should be implemented:**
${(feature.steps || []).map((step, i) => `${i + 1}. ${step}`).join("\n") || "No specific steps provided - implement based on description"}
**Your Task:**
1. Read the project files to understand the current implementation
2. If the feature is not fully implemented, continue implementing it
${
feature.skipTests
? "3. Test the implementation manually (no automated tests needed for skipTests features)"
: `3. Write or update Playwright tests to verify the feature works correctly
4. Run the Playwright tests: npx playwright test tests/[feature-name].spec.ts
5. Check if all tests pass
6. **If ANY tests fail:**
- Analyze the test failures and error messages
- Fix the implementation code to make the tests pass
- Update test utilities in tests/utils.ts if needed
- Re-run the tests to verify the fixes
- **REPEAT this process until ALL tests pass**
7. **If ALL tests pass:**
- **DELETE the test file(s) for this feature** - tests are only for immediate verification`
}
${
feature.skipTests ? "4" : "8"
}. **CRITICAL: Use the UpdateFeatureStatus tool to mark this feature as verified**
${
feature.skipTests
? "5. **DO NOT commit changes** - the user will review and commit manually"
: "9. Explain what was implemented/fixed and that all tests passed\n10. Commit your changes with git"
}
**IMPORTANT - Updating Feature Status:**
When you have completed the feature${
feature.skipTests ? "" : " and all tests pass"
}, you MUST use the \`mcp__automaker-tools__UpdateFeatureStatus\` tool to update the feature status:
- Call the tool with: featureId="${feature.id}" and status="verified"
- **You can also include a summary parameter** to describe what was done: summary="Brief summary of changes"
- **DO NOT manually edit feature files** - this can cause race conditions
- The UpdateFeatureStatus tool safely updates the feature status without risk of corrupting other data
- **If skipTests=true, the tool will automatically convert "verified" to "waiting_approval"** - this is correct behavior
**IMPORTANT - Feature Summary (REQUIRED):**
When calling UpdateFeatureStatus, you MUST include a summary parameter that describes:
- What files were modified/created
- What functionality was added or changed
- Any notable implementation decisions
Example:
\`\`\`
UpdateFeatureStatus(featureId="${
feature.id
}", status="verified", summary="Added dark mode toggle to settings. Modified: settings.tsx, theme-provider.tsx. Created new useTheme hook.")
\`\`\`
The summary will be displayed on the Kanban card so the user can see what was done without checking the code.
**Testing Utilities:**
- Check if tests/utils.ts exists and is being used
- If utilities are outdated due to functionality changes, update them
- Add new utilities as needed for this feature's tests
- Ensure test utilities stay in sync with code changes
**Test Deletion Policy:**
After tests pass, delete them immediately:
\`\`\`bash
rm tests/[feature-name].spec.ts
\`\`\`
**Important:**
${
feature.skipTests
? "- Skip automated testing (skipTests=true) - user will manually verify\n- **DO NOT commit changes** - user will review and commit manually"
: "- **CONTINUE IMPLEMENTING until all tests pass** - don't stop at the first failure\n- Only mark as verified if Playwright tests pass\n- **CRITICAL: Delete test files after they pass** - tests should not accumulate\n- Update test utilities if functionality changed\n- Make a git commit when the feature is complete\n- Be thorough and persistent in fixing issues"
}
- **CRITICAL: Use UpdateFeatureStatus tool instead of editing feature files directly**
- **CRITICAL: Always include a summary when marking feature as verified**
Begin by reading the project structure and understanding what needs to be implemented or fixed.`;
}
/**
* Build prompt for resuming feature with previous context
*/
async buildResumePrompt(feature, previousContext, projectPath) {
const skipTestsNote = feature.skipTests
? `\n**⚠️ IMPORTANT - Manual Testing Mode:**\nThis feature has skipTests=true, which means:\n- DO NOT commit changes automatically\n- DO NOT mark as verified - it will automatically go to "waiting_approval" status\n- The user will manually review and commit the changes\n- Just implement the feature and mark it as verified (it will be converted to waiting_approval)\n`
: "";
// For resume, check both followUpImages and imagePaths
const imagePaths = feature.followUpImages || feature.imagePaths;
let imagesNote = "";
if (imagePaths && imagePaths.length > 0) {
const imagesList = imagePaths
.map((img, idx) => {
// Handle both FeatureImagePath objects and simple path strings
const path = typeof img === "string" ? img : img.path;
const filename =
typeof img === "string" ? path.split("/").pop() : img.filename;
const mimeType = typeof img === "string" ? "image/*" : img.mimeType;
return ` ${
idx + 1
}. ${filename} (${mimeType})\n Path: ${path}`;
})
.join("\n");
imagesNote = `\n**📎 Context Images Attached:**\nThe user has attached ${imagePaths.length} image(s) for context. These images are provided both visually (in the initial message) and as files you can read:
${imagesList}
You can use the Read tool to view these images at any time. Review them carefully.\n`;
}
// Get context files preview
const contextFilesPreview = await contextManager.getContextFilesPreview(
projectPath
);
// Get memory content (lessons learned from previous runs)
const memoryContent = await contextManager.getMemoryContent(projectPath);
// Build mode header for this feature
const modeHeader = feature.skipTests
? `**🔨 MODE: Manual Review (No Automated Tests)**
This feature is set for manual review - focus on clean implementation without automated tests.`
: `**🧪 MODE: Test-Driven Development (TDD)**
This feature requires automated Playwright tests to verify the implementation.`;
return `You are resuming work on a feature implementation that was previously started.
${modeHeader}
${memoryContent}
**Current Feature:**
ID: ${feature.id}
Category: ${feature.category || "Uncategorized"}
Description: ${feature.description || feature.summary || feature.title || "No description provided"}
${skipTestsNote}${imagesNote}${contextFilesPreview}
**Steps to Complete:**
${(feature.steps || []).map((step, i) => `${i + 1}. ${step}`).join("\n") || "No specific steps provided - implement based on description"}
**Previous Work Context:**
${previousContext || "No previous context available - this is a fresh start."}
**Your Task:**
Continue where you left off and complete the feature implementation:
1. Review the previous work context above to understand what has been done
2. Continue implementing the feature according to the description and steps
${
feature.skipTests
? "3. Test the implementation manually (no automated tests needed for skipTests features)"
: "3. Write Playwright tests to verify the feature works correctly (if not already done)\n4. Run the tests and ensure they pass\n5. **DELETE the test file(s) you created** - tests are only for immediate verification"
}
${
feature.skipTests ? "4" : "6"
}. **CRITICAL: Use the UpdateFeatureStatus tool to mark this feature as verified**
${
feature.skipTests
? "5. **DO NOT commit changes** - the user will review and commit manually"
: "7. Commit your changes with git"
}
**IMPORTANT - Updating Feature Status:**
When you have completed the feature${
feature.skipTests ? "" : " and all tests pass"
}, you MUST use the \`mcp__automaker-tools__UpdateFeatureStatus\` tool to update the feature status:
- Call the tool with: featureId="${feature.id}" and status="verified"
- **You can also include a summary parameter** to describe what was done: summary="Brief summary of changes"
- **DO NOT manually edit feature files** - this can cause race conditions
- The UpdateFeatureStatus tool safely updates the feature status without risk of corrupting other data
- **If skipTests=true, the tool will automatically convert "verified" to "waiting_approval"** - this is correct behavior
**IMPORTANT - Feature Summary (REQUIRED):**
When calling UpdateFeatureStatus, you MUST include a summary parameter that describes:
- What files were modified/created
- What functionality was added or changed
- Any notable implementation decisions
Example:
\`\`\`
UpdateFeatureStatus(featureId="${
feature.id
}", status="verified", summary="Added dark mode toggle to settings. Modified: settings.tsx, theme-provider.tsx. Created new useTheme hook.")
\`\`\`
The summary will be displayed on the Kanban card so the user can see what was done without checking the code.
**Important Guidelines:**
- Review what was already done in the previous context
- Don't redo work that's already complete - continue from where it left off
- Focus on completing any remaining tasks
${
feature.skipTests
? "- Skip automated testing (skipTests=true) - user will manually verify"
: "- Write comprehensive Playwright tests if not already done\n- Ensure all tests pass before marking as verified\n- **CRITICAL: Delete test files after verification**"
}
- **CRITICAL: Use UpdateFeatureStatus tool instead of editing feature files directly**
- **CRITICAL: Always include a summary when marking feature as verified**
${
feature.skipTests
? "- **DO NOT commit changes** - user will review and commit manually"
: "- Make a git commit when complete"
}
Begin by assessing what's been done and what remains to be completed.`;
}
/**
* Build the prompt for project analysis
*/
buildProjectAnalysisPrompt(projectPath) {
return `You are analyzing a new project that was just opened in Automaker, an autonomous AI development studio.
**Your Task:**
Analyze this project's codebase and update the .automaker/app_spec.txt file with accurate information about:
1. **Project Name** - Detect the name from package.json, README, or directory name
2. **Overview** - Brief description of what the project does
3. **Technology Stack** - Languages, frameworks, libraries detected
4. **Core Capabilities** - Main features and functionality
5. **Implemented Features** - What features are already built
6. **Implementation Roadmap** - Break down remaining work into phases with individual features
**Steps to Follow:**
1. First, explore the project structure:
- Look at package.json, cargo.toml, go.mod, requirements.txt, etc. for tech stack
- Check README.md for project description
- List key directories (src, lib, components, etc.)
2. Identify the tech stack:
- Frontend framework (React, Vue, Next.js, etc.)
- Backend framework (Express, FastAPI, etc.)
- Database (if any config files exist)
- Testing framework
- Build tools
3. Update .automaker/app_spec.txt with your findings in this format:
\`\`\`xml
<project_specification>
<project_name>Detected Name</project_name>
<overview>
Clear description of what this project does based on your analysis.
</overview>
<technology_stack>
<frontend>
<framework>Framework Name</framework>
<!-- Add detected technologies -->
</frontend>
<backend>
<!-- If applicable -->
</backend>
<database>
<!-- If applicable -->
</database>
<testing>
<!-- Testing frameworks detected -->
</testing>
</technology_stack>
<core_capabilities>
<!-- List main features/capabilities you found -->
</core_capabilities>
<implemented_features>
<!-- List specific features that appear to be implemented -->
</implemented_features>
<implementation_roadmap>
<phase_1_foundation>
<!-- List foundational features to build first -->
</phase_1_foundation>
<phase_2_core_logic>
<!-- List core logic features -->
</phase_2_core_logic>
<phase_3_polish>
<!-- List polish and enhancement features -->
</phase_3_polish>
</implementation_roadmap>
</project_specification>
\`\`\`
4. Ensure .automaker/context/ directory exists
5. Ensure .automaker/features/ directory exists
**Important:**
- Be concise but accurate
- Only include information you can verify from the codebase
- If unsure about something, note it as "to be determined"
- Don't make up features that don't exist
- Features are stored in .automaker/features/{id}/feature.json - each feature gets its own folder
Begin by exploring the project structure.`;
}
/**
* Get the system prompt for coding agent
* @param {string} projectPath - Path to the project
* @param {boolean} isTDD - Whether this is Test-Driven Development mode (skipTests=false)
*/
async getCodingPrompt(projectPath, isTDD = true) {
// Get context files preview
const contextFilesPreview = projectPath
? await contextManager.getContextFilesPreview(projectPath)
: "";
// Get memory content (lessons learned from previous runs)
const memoryContent = projectPath
? await contextManager.getMemoryContent(projectPath)
: "";
// Build mode-specific instructions
const modeHeader = isTDD
? `**🧪 MODE: Test-Driven Development (TDD)**
You are implementing features using TDD methodology. This means:
- Write Playwright tests BEFORE or alongside implementation
- Run tests frequently to verify your work
- Tests are your validation mechanism
- Delete tests after they pass (they're for immediate verification only)`
: `**🔨 MODE: Manual Review (No Automated Tests)**
You are implementing features for manual user review. This means:
- Focus on clean, working implementation
- NO automated test writing required
- User will manually verify the implementation
- DO NOT commit changes - user will review and commit`;
return `You are an AI coding agent working autonomously to implement features.
${modeHeader}
${memoryContent}
**Feature Storage:**
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
**THE ONLY WAY to update features:**
Use the mcp__automaker-tools__UpdateFeatureStatus tool with featureId, status, and summary parameters.
Do NOT manually edit feature.json files directly.
${contextFilesPreview}
Your role is to:
- Implement features exactly as specified
- Write production-quality code
- Check if feature.skipTests is true - if so, skip automated testing and don't commit
- Create comprehensive Playwright tests using testing utilities (only if skipTests is false)
- Ensure all tests pass before marking features complete (only if skipTests is false)
- **DELETE test files after successful verification** - tests are only for immediate feature verification (only if skipTests is false)
- **Use the UpdateFeatureStatus tool to mark features as verified** - NEVER manually edit feature files
- **Always include a summary parameter when calling UpdateFeatureStatus** - describe what was done
- Commit working code to git (only if skipTests is false - skipTests features require manual review)
- Be thorough and detail-oriented
**IMPORTANT - Manual Testing Mode (skipTests=true):**
If a feature has skipTests=true:
- DO NOT write automated tests
- DO NOT commit changes - the user will review and commit manually
- Still mark the feature as verified using UpdateFeatureStatus - it will automatically convert to "waiting_approval" for manual review
- The user will manually verify and commit the changes
**IMPORTANT - UpdateFeatureStatus Tool:**
You have access to the \`mcp__automaker-tools__UpdateFeatureStatus\` tool. When the feature is complete (and all tests pass if skipTests is false), use this tool to update the feature status:
- Call with featureId, status="verified", and summary="Description of what was done"
- **DO NOT manually edit feature files** - this can cause race conditions and restore old state
- The tool safely updates the status without corrupting other feature data
- **If skipTests=true, the tool will automatically convert "verified" to "waiting_approval"** - this is correct
**IMPORTANT - Feature Summary (REQUIRED):**
When calling UpdateFeatureStatus, you MUST include a summary parameter that describes:
- What files were modified/created
- What functionality was added or changed
- Any notable implementation decisions
Example: summary="Added dark mode toggle. Modified: settings.tsx, theme-provider.tsx. Created useTheme hook."
The summary will be displayed on the Kanban card so the user can quickly see what was done.
**Testing Utilities (CRITICAL):**
- **Create and maintain tests/utils.ts** with helper functions for finding elements and common operations
- **Always use utilities in tests** instead of repeating selectors
- **Add new utilities as you write tests** - if you need a helper, add it to utils.ts
- **Update utilities when functionality changes** - keep helpers in sync with code changes
This makes future tests easier to write and more maintainable!
**Test Deletion Policy:**
Tests should NOT accumulate. After a feature is verified:
1. Run the tests to ensure they pass
2. Delete the test file for that feature
3. Use UpdateFeatureStatus tool to mark the feature as "verified"
This prevents test brittleness as the app changes rapidly.
You have full access to:
- Read and write files
- Run bash commands
- Execute tests
- Delete files (rm command)
- Make git commits
- Search and analyze the codebase
- **UpdateFeatureStatus tool** (mcp__automaker-tools__UpdateFeatureStatus) - Use this to update feature status
**🧠 Learning from Errors - Memory System:**
If you encounter an error or issue that:
- Took multiple attempts to debug
- Was caused by a non-obvious codebase quirk
- Required understanding something specific about this project
- Could trip up future agent runs
**ADD IT TO MEMORY** by appending to \`.automaker/memory.md\`:
\`\`\`markdown
### Issue: [Brief Title]
**Problem:** [1-2 sentence description of the issue]
**Fix:** [Concise explanation of the solution]
\`\`\`
Keep entries concise - focus on the essential information needed to avoid the issue in the future. This helps both you and other agents learn from mistakes.
Focus on one feature at a time and complete it fully before finishing. Always delete tests after they pass and use the UpdateFeatureStatus tool.`;
}
/**
* Get the system prompt for verification agent
* @param {string} projectPath - Path to the project
* @param {boolean} isTDD - Whether this is Test-Driven Development mode (skipTests=false)
*/
async getVerificationPrompt(projectPath, isTDD = true) {
// Get context files preview
const contextFilesPreview = projectPath
? await contextManager.getContextFilesPreview(projectPath)
: "";
// Get memory content (lessons learned from previous runs)
const memoryContent = projectPath
? await contextManager.getMemoryContent(projectPath)
: "";
// Build mode-specific instructions
const modeHeader = isTDD
? `**🧪 MODE: Test-Driven Development (TDD)**
You are verifying/completing features using TDD methodology. This means:
- Run Playwright tests to verify implementation
- Fix failing tests by updating code
- Tests are your validation mechanism
- Delete tests after they pass (they're for immediate verification only)`
: `**🔨 MODE: Manual Review (No Automated Tests)**
You are completing features for manual user review. This means:
- Focus on clean, working implementation
- NO automated test writing required
- User will manually verify the implementation
- DO NOT commit changes - user will review and commit`;
return `You are an AI implementation and verification agent focused on completing features and ensuring they work.
${modeHeader}
${memoryContent}
**Feature Storage:**
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
**THE ONLY WAY to update features:**
Use the mcp__automaker-tools__UpdateFeatureStatus tool with featureId, status, and summary parameters.
Do NOT manually edit feature.json files directly.
${contextFilesPreview}
Your role is to:
- **Continue implementing features until they are complete** - don't stop at the first failure
- Check if feature.skipTests is true - if so, skip automated testing and don't commit
- Write or update code to fix failing tests (only if skipTests is false)
- Run Playwright tests to verify feature implementations (only if skipTests is false)
- If tests fail, analyze errors and fix the implementation (only if skipTests is false)
- If other tests fail, verify if those tests are still accurate or should be updated or deleted (only if skipTests is false)
- Continue rerunning tests and fixing issues until ALL tests pass (only if skipTests is false)
- **DELETE test files after successful verification** - tests are only for immediate feature verification (only if skipTests is false)
- **Use the UpdateFeatureStatus tool to mark features as verified** - NEVER manually edit feature files
- **Always include a summary parameter when calling UpdateFeatureStatus** - describe what was done
- **Update test utilities (tests/utils.ts) if functionality changed** - keep helpers in sync with code (only if skipTests is false)
- Commit working code to git (only if skipTests is false - skipTests features require manual review)
**IMPORTANT - Manual Testing Mode (skipTests=true):**
If a feature has skipTests=true:
- DO NOT write automated tests
- DO NOT commit changes - the user will review and commit manually
- Still mark the feature as verified using UpdateFeatureStatus - it will automatically convert to "waiting_approval" for manual review
- The user will manually verify and commit the changes
**IMPORTANT - UpdateFeatureStatus Tool:**
You have access to the \`mcp__automaker-tools__UpdateFeatureStatus\` tool. When the feature is complete (and all tests pass if skipTests is false), use this tool to update the feature status:
- Call with featureId, status="verified", and summary="Description of what was done"
- **DO NOT manually edit feature files** - this can cause race conditions and restore old state
- The tool safely updates the status without corrupting other feature data
- **If skipTests=true, the tool will automatically convert "verified" to "waiting_approval"** - this is correct
**IMPORTANT - Feature Summary (REQUIRED):**
When calling UpdateFeatureStatus, you MUST include a summary parameter that describes:
- What files were modified/created
- What functionality was added or changed
- Any notable implementation decisions
Example: summary="Fixed login validation. Modified: auth.ts, login-form.tsx. Added password strength check."
The summary will be displayed on the Kanban card so the user can quickly see what was done.
**Testing Utilities:**
- Check if tests/utils.ts needs updates based on code changes
- If a component's selectors or behavior changed, update the corresponding utility functions
- Add new utilities as needed for the feature's tests
- Ensure utilities remain accurate and helpful for future tests
**Test Deletion Policy:**
Tests should NOT accumulate. After a feature is verified:
1. Delete the test file for that feature
2. Use UpdateFeatureStatus tool to mark the feature as "verified"
This prevents test brittleness as the app changes rapidly.
You have access to:
- Read and edit files
- Write new code or modify existing code
- Run bash commands (especially Playwright tests)
- Delete files (rm command)
- Analyze test output
- Make git commits
- **UpdateFeatureStatus tool** (mcp__automaker-tools__UpdateFeatureStatus) - Use this to update feature status
**🧠 Learning from Errors - Memory System:**
If you encounter an error or issue that:
- Took multiple attempts to debug
- Was caused by a non-obvious codebase quirk
- Required understanding something specific about this project
- Could trip up future agent runs
**ADD IT TO MEMORY** by appending to \`.automaker/memory.md\`:
\`\`\`markdown
### Issue: [Brief Title]
**Problem:** [1-2 sentence description of the issue]
**Fix:** [Concise explanation of the solution]
\`\`\`
Keep entries concise - focus on the essential information needed to avoid the issue in the future. This helps both you and other agents learn from mistakes.
**CRITICAL:** Be persistent and thorough - keep iterating on the implementation until all tests pass. Don't give up after the first failure. Always delete tests after they pass, use the UpdateFeatureStatus tool with a summary, and commit your work.`;
}
/**
* Get system prompt for project analysis agent
*/
getProjectAnalysisSystemPrompt() {
return `You are a project analysis agent that examines codebases to understand their structure, tech stack, and implemented features.
Your goal is to:
- Quickly scan and understand project structure
- Identify programming languages, frameworks, and libraries
- Detect existing features and capabilities
- Update the .automaker/app_spec.txt with accurate information
- Ensure all required .automaker files and directories exist
Be efficient - don't read every file, focus on:
- Configuration files (package.json, tsconfig.json, etc.)
- Main entry points
- Directory structure
- README and documentation
**Feature Storage:**
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
Use the UpdateFeatureStatus tool to manage features, not direct file edits.
You have access to Read, Write, Edit, Glob, Grep, and Bash tools. Use them to explore the structure and write the necessary files.`;
}
}
module.exports = new PromptBuilder();

View File

@@ -0,0 +1,84 @@
const os = require("os");
// Prefer prebuilt to avoid native build issues.
const pty = require("@homebridge/node-pty-prebuilt-multiarch");
/**
* Minimal PTY helper to run CLI commands with a pseudo-terminal.
* Useful for CLIs (like Claude) that need raw mode on Windows.
*
* @param {string} command Executable path
* @param {string[]} args Arguments for the executable
* @param {Object} options Additional spawn options
* @param {(chunk: string) => void} [options.onData] Data callback
* @param {string} [options.cwd] Working directory
* @param {Object} [options.env] Extra env vars
* @param {number} [options.cols] Terminal columns
* @param {number} [options.rows] Terminal rows
* @returns {Promise<{ success: boolean, exitCode: number, signal?: number, output: string, errorOutput: string }>}
*/
function runPtyCommand(command, args = [], options = {}) {
const {
onData,
cwd = process.cwd(),
env = {},
cols = 120,
rows = 30,
} = options;
const mergedEnv = {
...process.env,
TERM: process.env.TERM || "xterm-256color",
...env,
};
return new Promise((resolve, reject) => {
let ptyProcess;
try {
ptyProcess = pty.spawn(command, args, {
name: os.platform() === "win32" ? "Windows.Terminal" : "xterm-color",
cols,
rows,
cwd,
env: mergedEnv,
useConpty: true,
});
} catch (error) {
return reject(error);
}
let output = "";
let errorOutput = "";
ptyProcess.onData((data) => {
output += data;
if (typeof onData === "function") {
onData(data);
}
});
// node-pty does not emit 'error' in practice, but guard anyway
if (ptyProcess.on) {
ptyProcess.on("error", (err) => {
errorOutput += err?.message || "";
reject(err);
});
}
ptyProcess.onExit(({ exitCode, signal }) => {
resolve({
success: exitCode === 0,
exitCode,
signal,
output,
errorOutput,
});
});
});
}
module.exports = {
runPtyCommand,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,569 @@
const path = require("path");
const fs = require("fs/promises");
const { exec, spawn } = require("child_process");
const { promisify } = require("util");
const execAsync = promisify(exec);
/**
* Worktree Manager - Handles git worktrees for feature isolation
*
* This service creates isolated git worktrees for each feature, allowing:
* - Features to be worked on in isolation without affecting the main branch
* - Easy rollback/revert by simply deleting the worktree
* - Checkpointing - user can see changes in the worktree before merging
*/
class WorktreeManager {
constructor() {
// Cache for worktree info
this.worktreeCache = new Map();
}
/**
* Get the base worktree directory path
*/
getWorktreeBasePath(projectPath) {
return path.join(projectPath, ".automaker", "worktrees");
}
/**
* Generate a safe branch name from feature description
*/
generateBranchName(feature) {
// Create a slug from the description
const slug = feature.description
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "") // Remove special chars
.replace(/\s+/g, "-") // Replace spaces with hyphens
.substring(0, 40); // Limit length
// Add feature ID for uniqueness
const shortId = feature.id.replace("feature-", "").substring(0, 12);
return `feature/${shortId}-${slug}`;
}
/**
* Check if the project is a git repository
*/
async isGitRepo(projectPath) {
try {
await execAsync("git rev-parse --is-inside-work-tree", { cwd: projectPath });
return true;
} catch {
return false;
}
}
/**
* Get the current branch name
*/
async getCurrentBranch(projectPath) {
try {
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: projectPath });
return stdout.trim();
} catch (error) {
console.error("[WorktreeManager] Failed to get current branch:", error);
return null;
}
}
/**
* Check if a branch exists (local or remote)
*/
async branchExists(projectPath, branchName) {
try {
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
return true;
} catch {
return false;
}
}
/**
* List all existing worktrees
*/
async listWorktrees(projectPath) {
try {
const { stdout } = await execAsync("git worktree list --porcelain", { cwd: projectPath });
const worktrees = [];
const lines = stdout.split("\n");
let currentWorktree = null;
for (const line of lines) {
if (line.startsWith("worktree ")) {
if (currentWorktree) {
worktrees.push(currentWorktree);
}
currentWorktree = { path: line.replace("worktree ", "") };
} else if (line.startsWith("branch ") && currentWorktree) {
currentWorktree.branch = line.replace("branch refs/heads/", "");
} else if (line.startsWith("HEAD ") && currentWorktree) {
currentWorktree.head = line.replace("HEAD ", "");
}
}
if (currentWorktree) {
worktrees.push(currentWorktree);
}
return worktrees;
} catch (error) {
console.error("[WorktreeManager] Failed to list worktrees:", error);
return [];
}
}
/**
* Create a worktree for a feature
* @param {string} projectPath - Path to the main project
* @param {object} feature - Feature object with id and description
* @returns {object} - { success, worktreePath, branchName, error }
*/
async createWorktree(projectPath, feature) {
console.log(`[WorktreeManager] Creating worktree for feature: ${feature.id}`);
// Check if project is a git repo
if (!await this.isGitRepo(projectPath)) {
return { success: false, error: "Project is not a git repository" };
}
const branchName = this.generateBranchName(feature);
const worktreeBasePath = this.getWorktreeBasePath(projectPath);
const worktreePath = path.join(worktreeBasePath, branchName.replace("feature/", ""));
try {
// Ensure worktree directory exists
await fs.mkdir(worktreeBasePath, { recursive: true });
// Check if worktree already exists
const worktrees = await this.listWorktrees(projectPath);
const existingWorktree = worktrees.find(
w => w.path === worktreePath || w.branch === branchName
);
if (existingWorktree) {
console.log(`[WorktreeManager] Worktree already exists for feature: ${feature.id}`);
return {
success: true,
worktreePath: existingWorktree.path,
branchName: existingWorktree.branch,
existed: true,
};
}
// Get current branch to base the new branch on
const baseBranch = await this.getCurrentBranch(projectPath);
if (!baseBranch) {
return { success: false, error: "Could not determine current branch" };
}
// Check if branch already exists
const branchExists = await this.branchExists(projectPath, branchName);
if (branchExists) {
// Use existing branch
console.log(`[WorktreeManager] Using existing branch: ${branchName}`);
await execAsync(`git worktree add "${worktreePath}" ${branchName}`, { cwd: projectPath });
} else {
// Create new worktree with new branch
console.log(`[WorktreeManager] Creating new branch: ${branchName} based on ${baseBranch}`);
await execAsync(`git worktree add -b ${branchName} "${worktreePath}" ${baseBranch}`, { cwd: projectPath });
}
// Copy .automaker directory to worktree (except worktrees directory itself to avoid recursion)
const automakerSrc = path.join(projectPath, ".automaker");
const automakerDst = path.join(worktreePath, ".automaker");
try {
await fs.mkdir(automakerDst, { recursive: true });
// Note: Features are stored in .automaker/features/{id}/feature.json
// These are managed by the main project, not copied to worktrees
// Copy app_spec.txt if it exists
const appSpecSrc = path.join(automakerSrc, "app_spec.txt");
const appSpecDst = path.join(automakerDst, "app_spec.txt");
try {
const content = await fs.readFile(appSpecSrc, "utf-8");
await fs.writeFile(appSpecDst, content, "utf-8");
} catch {
// App spec might not exist yet
}
// Copy categories.json if it exists
const categoriesSrc = path.join(automakerSrc, "categories.json");
const categoriesDst = path.join(automakerDst, "categories.json");
try {
const content = await fs.readFile(categoriesSrc, "utf-8");
await fs.writeFile(categoriesDst, content, "utf-8");
} catch {
// Categories might not exist yet
}
} catch (error) {
console.warn("[WorktreeManager] Failed to copy .automaker directory:", error);
}
// Store worktree info in cache
this.worktreeCache.set(feature.id, {
worktreePath,
branchName,
createdAt: new Date().toISOString(),
baseBranch,
});
console.log(`[WorktreeManager] Worktree created at: ${worktreePath}`);
return {
success: true,
worktreePath,
branchName,
baseBranch,
existed: false,
};
} catch (error) {
console.error("[WorktreeManager] Failed to create worktree:", error);
return { success: false, error: error.message };
}
}
/**
* Get worktree info for a feature
*/
async getWorktreeInfo(projectPath, featureId) {
// Check cache first
if (this.worktreeCache.has(featureId)) {
return { success: true, ...this.worktreeCache.get(featureId) };
}
// Scan worktrees to find matching one
const worktrees = await this.listWorktrees(projectPath);
const worktreeBasePath = this.getWorktreeBasePath(projectPath);
for (const worktree of worktrees) {
// Check if this worktree is in our worktree directory
if (worktree.path.startsWith(worktreeBasePath)) {
// Check if the feature ID is in the branch name
const shortId = featureId.replace("feature-", "").substring(0, 12);
if (worktree.branch && worktree.branch.includes(shortId)) {
const info = {
worktreePath: worktree.path,
branchName: worktree.branch,
head: worktree.head,
};
this.worktreeCache.set(featureId, info);
return { success: true, ...info };
}
}
}
return { success: false, error: "Worktree not found" };
}
/**
* Remove a worktree for a feature
* This effectively reverts all changes made by the agent
*/
async removeWorktree(projectPath, featureId, deleteBranch = false) {
console.log(`[WorktreeManager] Removing worktree for feature: ${featureId}`);
const worktreeInfo = await this.getWorktreeInfo(projectPath, featureId);
if (!worktreeInfo.success) {
console.log(`[WorktreeManager] No worktree found for feature: ${featureId}`);
return { success: true, message: "No worktree to remove" };
}
const { worktreePath, branchName } = worktreeInfo;
try {
// Remove the worktree
await execAsync(`git worktree remove "${worktreePath}" --force`, { cwd: projectPath });
console.log(`[WorktreeManager] Worktree removed: ${worktreePath}`);
// Optionally delete the branch too
if (deleteBranch && branchName) {
try {
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
console.log(`[WorktreeManager] Branch deleted: ${branchName}`);
} catch (error) {
console.warn(`[WorktreeManager] Could not delete branch ${branchName}:`, error.message);
}
}
// Remove from cache
this.worktreeCache.delete(featureId);
return { success: true, removedPath: worktreePath, removedBranch: deleteBranch ? branchName : null };
} catch (error) {
console.error("[WorktreeManager] Failed to remove worktree:", error);
return { success: false, error: error.message };
}
}
/**
* Get status of changes in a worktree
*/
async getWorktreeStatus(worktreePath) {
try {
const { stdout: statusOutput } = await execAsync("git status --porcelain", { cwd: worktreePath });
const { stdout: diffStat } = await execAsync("git diff --stat", { cwd: worktreePath });
const { stdout: commitLog } = await execAsync("git log --oneline -10", { cwd: worktreePath });
const files = statusOutput.trim().split("\n").filter(Boolean);
const commits = commitLog.trim().split("\n").filter(Boolean);
return {
success: true,
modifiedFiles: files.length,
files: files.slice(0, 20), // Limit to 20 files
diffStat: diffStat.trim(),
recentCommits: commits.slice(0, 5), // Last 5 commits
};
} catch (error) {
console.error("[WorktreeManager] Failed to get worktree status:", error);
return { success: false, error: error.message };
}
}
/**
* Get detailed file diff content for a worktree
* Returns unified diff format for all changes
*/
async getFileDiffs(worktreePath) {
try {
// Get both staged and unstaged diffs
const { stdout: unstagedDiff } = await execAsync("git diff --no-color", {
cwd: worktreePath,
maxBuffer: 10 * 1024 * 1024 // 10MB buffer for large diffs
});
const { stdout: stagedDiff } = await execAsync("git diff --cached --no-color", {
cwd: worktreePath,
maxBuffer: 10 * 1024 * 1024
});
// Get list of files with their status
const { stdout: statusOutput } = await execAsync("git status --porcelain", { cwd: worktreePath });
const files = statusOutput.trim().split("\n").filter(Boolean);
// Parse file statuses
const fileStatuses = files.map(line => {
const status = line.substring(0, 2);
const filePath = line.substring(3);
return {
status: status.trim() || 'M',
path: filePath,
statusText: this.getStatusText(status)
};
});
// Combine diffs
const combinedDiff = [stagedDiff, unstagedDiff].filter(Boolean).join("\n");
return {
success: true,
diff: combinedDiff,
files: fileStatuses,
hasChanges: files.length > 0
};
} catch (error) {
console.error("[WorktreeManager] Failed to get file diffs:", error);
return { success: false, error: error.message };
}
}
/**
* Get human-readable status text from git status code
*/
getStatusText(status) {
const statusMap = {
'M': 'Modified',
'A': 'Added',
'D': 'Deleted',
'R': 'Renamed',
'C': 'Copied',
'U': 'Updated',
'?': 'Untracked',
'!': 'Ignored'
};
const firstChar = status.charAt(0);
const secondChar = status.charAt(1);
return statusMap[firstChar] || statusMap[secondChar] || 'Changed';
}
/**
* Get diff for a specific file in a worktree
*/
async getFileDiff(worktreePath, filePath) {
try {
// Try to get unstaged diff first, then staged if no unstaged changes
let diff = '';
try {
const { stdout } = await execAsync(`git diff --no-color -- "${filePath}"`, {
cwd: worktreePath,
maxBuffer: 5 * 1024 * 1024
});
diff = stdout;
} catch {
// File might be staged
}
if (!diff) {
try {
const { stdout } = await execAsync(`git diff --cached --no-color -- "${filePath}"`, {
cwd: worktreePath,
maxBuffer: 5 * 1024 * 1024
});
diff = stdout;
} catch {
// File might be untracked, show the content
}
}
// If still no diff, might be an untracked file - show the content
if (!diff) {
try {
const fullPath = path.join(worktreePath, filePath);
const content = await fs.readFile(fullPath, 'utf-8');
diff = `+++ ${filePath} (new file)\n${content.split('\n').map(l => '+' + l).join('\n')}`;
} catch {
diff = '(Unable to read file content)';
}
}
return {
success: true,
diff,
filePath
};
} catch (error) {
console.error(`[WorktreeManager] Failed to get diff for ${filePath}:`, error);
return { success: false, error: error.message };
}
}
/**
* Merge worktree changes back to the main branch
*/
async mergeWorktree(projectPath, featureId, options = {}) {
console.log(`[WorktreeManager] Merging worktree for feature: ${featureId}`);
const worktreeInfo = await this.getWorktreeInfo(projectPath, featureId);
if (!worktreeInfo.success) {
return { success: false, error: "Worktree not found" };
}
const { branchName, worktreePath } = worktreeInfo;
const baseBranch = await this.getCurrentBranch(projectPath);
try {
// First commit any uncommitted changes in the worktree
const { stdout: status } = await execAsync("git status --porcelain", { cwd: worktreePath });
if (status.trim()) {
// There are uncommitted changes - commit them
await execAsync("git add -A", { cwd: worktreePath });
const commitMsg = options.commitMessage || `feat: complete ${featureId}`;
await execAsync(`git commit -m "${commitMsg}"`, { cwd: worktreePath });
}
// Merge the feature branch into the current branch in the main repo
if (options.squash) {
await execAsync(`git merge --squash ${branchName}`, { cwd: projectPath });
const squashMsg = options.squashMessage || `feat: ${featureId} - squashed merge`;
await execAsync(`git commit -m "${squashMsg}"`, { cwd: projectPath });
} else {
await execAsync(`git merge ${branchName} --no-ff -m "Merge ${branchName}"`, { cwd: projectPath });
}
console.log(`[WorktreeManager] Successfully merged ${branchName} into ${baseBranch}`);
// Optionally cleanup worktree after merge
if (options.cleanup) {
await this.removeWorktree(projectPath, featureId, true);
}
return {
success: true,
mergedBranch: branchName,
intoBranch: baseBranch,
};
} catch (error) {
console.error("[WorktreeManager] Failed to merge worktree:", error);
return { success: false, error: error.message };
}
}
/**
* Sync changes from main branch to worktree (rebase or merge)
*/
async syncWorktree(projectPath, featureId, method = "rebase") {
console.log(`[WorktreeManager] Syncing worktree for feature: ${featureId}`);
const worktreeInfo = await this.getWorktreeInfo(projectPath, featureId);
if (!worktreeInfo.success) {
return { success: false, error: "Worktree not found" };
}
const { worktreePath, baseBranch } = worktreeInfo;
try {
if (method === "rebase") {
await execAsync(`git rebase ${baseBranch}`, { cwd: worktreePath });
} else {
await execAsync(`git merge ${baseBranch}`, { cwd: worktreePath });
}
return { success: true, method };
} catch (error) {
console.error("[WorktreeManager] Failed to sync worktree:", error);
return { success: false, error: error.message };
}
}
/**
* Get list of all feature worktrees
*/
async getAllFeatureWorktrees(projectPath) {
const worktrees = await this.listWorktrees(projectPath);
const worktreeBasePath = this.getWorktreeBasePath(projectPath);
return worktrees.filter(w =>
w.path.startsWith(worktreeBasePath) &&
w.branch &&
w.branch.startsWith("feature/")
);
}
/**
* Cleanup orphaned worktrees (worktrees without matching features)
*/
async cleanupOrphanedWorktrees(projectPath, activeFeatureIds) {
console.log("[WorktreeManager] Cleaning up orphaned worktrees...");
const worktrees = await this.getAllFeatureWorktrees(projectPath);
const cleaned = [];
for (const worktree of worktrees) {
// Extract feature ID from branch name
const branchParts = worktree.branch.replace("feature/", "").split("-");
const shortId = branchParts[0];
// Check if any active feature has this short ID
const hasMatchingFeature = activeFeatureIds.some(id => {
const featureShortId = id.replace("feature-", "").substring(0, 12);
return featureShortId === shortId;
});
if (!hasMatchingFeature) {
console.log(`[WorktreeManager] Removing orphaned worktree: ${worktree.path}`);
try {
await execAsync(`git worktree remove "${worktree.path}" --force`, { cwd: projectPath });
await execAsync(`git branch -D ${worktree.branch}`, { cwd: projectPath });
cleaned.push(worktree.path);
} catch (error) {
console.warn(`[WorktreeManager] Failed to cleanup worktree ${worktree.path}:`, error.message);
}
}
}
return { success: true, cleaned };
}
}
module.exports = new WorktreeManager();

View File

@@ -0,0 +1,20 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
// Electron files use CommonJS
"electron/**",
]),
]);
export default eslintConfig;

9
apps/app/next.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
env: {
CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN || "",
},
};
export default nextConfig;

14605
apps/app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

155
apps/app/package.json Normal file
View File

@@ -0,0 +1,155 @@
{
"name": "@automaker/app",
"version": "0.1.0",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker",
"repository": {
"type": "git",
"url": "https://github.com/AutoMaker-Org/automaker.git"
},
"author": {
"name": "Cody Seibert",
"email": "webdevcody@gmail.com"
},
"private": true,
"license": "Unlicense",
"main": "electron/main.js",
"scripts": {
"dev": "next dev -p 3007",
"dev:web": "next dev -p 3007",
"dev:electron": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron .\"",
"build": "next build",
"build:electron": "next build && electron-builder",
"start": "next start",
"lint": "eslint",
"test": "playwright test",
"test:headed": "playwright test --headed",
"dev:electron:wsl": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron . --no-sandbox --disable-gpu\"",
"dev:electron:wsl:gpu": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA electron . --no-sandbox --disable-gpu-sandbox\""
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.61",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dotenv": "^17.2.3",
"lucide-react": "^0.556.0",
"next": "16.0.7",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-markdown": "^10.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"
},
"devDependencies": {
"@electron/rebuild": "^4.0.2",
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.2.1",
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"eslint": "^9",
"eslint-config-next": "16.0.7",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"wait-on": "^9.0.3"
},
"build": {
"appId": "com.automaker.app",
"productName": "Automaker",
"directories": {
"output": "dist"
},
"files": [
"electron/**/*",
".next/**/*",
"public/**/*",
"!node_modules/**/*",
"node_modules/@anthropic-ai/**/*"
],
"extraResources": [
{
"from": ".env",
"to": ".env",
"filter": [
"**/*"
]
}
],
"mac": {
"category": "public.app-category.developer-tools",
"target": [
{
"target": "dmg",
"arch": [
"x64",
"arm64"
]
},
{
"target": "zip",
"arch": [
"x64",
"arm64"
]
}
],
"icon": "public/logo_larger.png"
},
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"icon": "public/logo_larger.png"
},
"linux": {
"target": [
{
"target": "AppImage",
"arch": [
"x64"
]
},
{
"target": "deb",
"arch": [
"x64"
]
}
],
"category": "Development",
"icon": "public/logo_larger.png",
"maintainer": "webdevcody@gmail.com"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
}
}
}

View File

@@ -0,0 +1,35 @@
import { defineConfig, devices } from "@playwright/test";
const port = process.env.TEST_PORT || 3007;
const reuseServer = process.env.TEST_REUSE_SERVER === "true";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
timeout: 30000,
use: {
baseURL: `http://localhost:${port}`,
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
...(reuseServer
? {}
: {
webServer: {
command: `npx next dev -p ${port}`,
url: `http://localhost:${port}`,
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
}),
});

View File

@@ -0,0 +1,30 @@
import { defineConfig, devices } from "@playwright/test";
const port = process.env.TEST_PORT || 3007;
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
timeout: 10000,
use: {
baseURL: `http://localhost:${port}`,
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: `npx next dev -p ${port}`,
url: `http://localhost:${port}`,
reuseExistingServer: true,
timeout: 60000,
},
});

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
apps/app/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
apps/app/public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

BIN
apps/app/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

1
apps/app/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,172 @@
import {
query,
Options,
SDKAssistantMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { NextRequest, NextResponse } from "next/server";
import path from "path";
const systemPrompt = `You are an AI assistant helping users build software. You are part of the Automaker application,
which is designed to help developers plan, design, and implement software projects autonomously.
Your role is to:
- Help users define their project requirements and specifications
- Ask clarifying questions to better understand their needs
- Suggest technical approaches and architectures
- Guide them through the development process
- Be conversational and helpful
- Write, edit, and modify code files as requested
- Execute commands and tests
- Search and analyze the codebase
When discussing projects, help users think through:
- Core functionality and features
- Technical stack choices
- Data models and architecture
- User experience considerations
- Testing strategies
You have full access to the codebase and can:
- Read files to understand existing code
- Write new files
- Edit existing files
- Run bash commands
- Search for code patterns
- Execute tests and builds`;
export async function POST(request: NextRequest) {
try {
const { messages, workingDirectory } = await request.json();
console.log(
"[API] CLAUDE_CODE_OAUTH_TOKEN present:",
!!process.env.CLAUDE_CODE_OAUTH_TOKEN
);
if (!process.env.CLAUDE_CODE_OAUTH_TOKEN) {
return NextResponse.json(
{ error: "CLAUDE_CODE_OAUTH_TOKEN not configured" },
{ status: 500 }
);
}
// Get the last user message
const lastMessage = messages[messages.length - 1];
// Determine working directory - default to parent of app directory
const cwd = workingDirectory || path.resolve(process.cwd(), "..");
console.log("[API] Working directory:", cwd);
// Create query with options that enable code modification
const options: Options = {
// model: "claude-sonnet-4-20250514",
model: "claude-opus-4-5-20251101",
systemPrompt,
maxTurns: 20,
cwd,
// Enable all core tools for code modification
allowedTools: [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
],
// Auto-accept file edits within the working directory
permissionMode: "acceptEdits",
// Enable sandbox for safer bash execution
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
};
// Convert message history to SDK format to preserve conversation context
// Include both user and assistant messages for full context
const sessionId = `api-session-${Date.now()}`;
const conversationMessages = messages.map(
(msg: { role: string; content: string }) => {
if (msg.role === "user") {
return {
type: "user" as const,
message: {
role: "user" as const,
content: msg.content,
},
parent_tool_use_id: null,
session_id: sessionId,
};
} else {
// Assistant message
return {
type: "assistant" as const,
message: {
role: "assistant" as const,
content: [
{
type: "text" as const,
text: msg.content,
},
],
},
session_id: sessionId,
};
}
}
);
// Execute query with full conversation context
const queryResult = query({
prompt:
conversationMessages.length > 0
? conversationMessages
: lastMessage.content,
options,
});
let responseText = "";
const toolUses: Array<{ name: string; input: unknown }> = [];
// Collect the response from the async generator
for await (const msg of queryResult) {
if (msg.type === "assistant") {
const assistantMsg = msg as SDKAssistantMessage;
if (assistantMsg.message.content) {
for (const block of assistantMsg.message.content) {
if (block.type === "text") {
responseText += block.text;
} else if (block.type === "tool_use") {
// Track tool usage for transparency
toolUses.push({
name: block.name,
input: block.input,
});
}
}
}
} else if (msg.type === "result") {
if (msg.subtype === "success") {
if (msg.result) {
responseText = msg.result;
}
}
}
}
return NextResponse.json({
content: responseText || "Sorry, I couldn't generate a response.",
toolUses: toolUses.length > 0 ? toolUses : undefined,
});
} catch (error: unknown) {
console.error("Claude API error:", error);
const errorMessage =
error instanceof Error
? error.message
: "Failed to get response from Claude";
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}

View File

@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from "next/server";
interface AnthropicResponse {
content?: Array<{ type: string; text?: string }>;
model?: string;
error?: { message?: string };
}
export async function POST(request: NextRequest) {
try {
const { apiKey } = await request.json();
// Use provided API key or fall back to environment variable
const effectiveApiKey = apiKey || process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN;
if (!effectiveApiKey) {
return NextResponse.json(
{ success: false, error: "No API key provided or configured in environment" },
{ status: 400 }
);
}
// Send a simple test prompt to the Anthropic API
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": effectiveApiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 100,
messages: [
{
role: "user",
content: "Respond with exactly: 'Claude API connection successful!' and nothing else.",
},
],
}),
});
if (!response.ok) {
const errorData = (await response.json()) as AnthropicResponse;
const errorMessage = errorData.error?.message || `HTTP ${response.status}`;
if (response.status === 401) {
return NextResponse.json(
{ success: false, error: "Invalid API key. Please check your Anthropic API key." },
{ status: 401 }
);
}
if (response.status === 429) {
return NextResponse.json(
{ success: false, error: "Rate limit exceeded. Please try again later." },
{ status: 429 }
);
}
return NextResponse.json(
{ success: false, error: `API error: ${errorMessage}` },
{ status: response.status }
);
}
const data = (await response.json()) as AnthropicResponse;
// Check if we got a valid response
if (data.content && data.content.length > 0) {
const textContent = data.content.find((block) => block.type === "text");
if (textContent && textContent.type === "text" && textContent.text) {
return NextResponse.json({
success: true,
message: `Connection successful! Response: "${textContent.text}"`,
model: data.model,
});
}
}
return NextResponse.json({
success: true,
message: "Connection successful! Claude responded.",
model: data.model,
});
} catch (error: unknown) {
console.error("Claude API test error:", error);
const errorMessage =
error instanceof Error ? error.message : "Failed to connect to Claude API";
return NextResponse.json(
{ success: false, error: errorMessage },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,191 @@
import { NextRequest, NextResponse } from "next/server";
interface GeminiContent {
parts: Array<{
text?: string;
inlineData?: {
mimeType: string;
data: string;
};
}>;
role?: string;
}
interface GeminiRequest {
contents: GeminiContent[];
generationConfig?: {
maxOutputTokens?: number;
temperature?: number;
};
}
interface GeminiResponse {
candidates?: Array<{
content: {
parts: Array<{
text: string;
}>;
role: string;
};
finishReason: string;
safetyRatings?: Array<{
category: string;
probability: string;
}>;
}>;
promptFeedback?: {
safetyRatings?: Array<{
category: string;
probability: string;
}>;
};
error?: {
code: number;
message: string;
status: string;
};
}
export async function POST(request: NextRequest) {
try {
const { apiKey, imageData, mimeType, prompt } = await request.json();
// Use provided API key or fall back to environment variable
const effectiveApiKey = apiKey || process.env.GOOGLE_API_KEY;
if (!effectiveApiKey) {
return NextResponse.json(
{ success: false, error: "No API key provided or configured in environment" },
{ status: 400 }
);
}
// Build the request body
const requestBody: GeminiRequest = {
contents: [
{
parts: [],
},
],
generationConfig: {
maxOutputTokens: 150,
temperature: 0.4,
},
};
// Add image if provided
if (imageData && mimeType) {
requestBody.contents[0].parts.push({
inlineData: {
mimeType: mimeType,
data: imageData,
},
});
}
// Add text prompt
const textPrompt = prompt || (imageData
? "Describe what you see in this image briefly."
: "Respond with exactly: 'Gemini SDK connection successful!' and nothing else.");
requestBody.contents[0].parts.push({
text: textPrompt,
});
// Call Gemini API - using gemini-1.5-flash as it supports both text and vision
const model = imageData ? "gemini-1.5-flash" : "gemini-1.5-flash";
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${effectiveApiKey}`;
const response = await fetch(geminiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
const data: GeminiResponse = await response.json();
// Check for API errors
if (data.error) {
const errorMessage = data.error.message || "Unknown Gemini API error";
const statusCode = data.error.code || 500;
if (statusCode === 400 && errorMessage.includes("API key")) {
return NextResponse.json(
{ success: false, error: "Invalid API key. Please check your Google API key." },
{ status: 401 }
);
}
if (statusCode === 429) {
return NextResponse.json(
{ success: false, error: "Rate limit exceeded. Please try again later." },
{ status: 429 }
);
}
return NextResponse.json(
{ success: false, error: `API error: ${errorMessage}` },
{ status: statusCode }
);
}
// Check for valid response
if (!response.ok) {
return NextResponse.json(
{ success: false, error: `HTTP error: ${response.status} ${response.statusText}` },
{ status: response.status }
);
}
// Extract response text
if (data.candidates && data.candidates.length > 0 && data.candidates[0].content?.parts?.length > 0) {
const responseText = data.candidates[0].content.parts
.filter((part) => part.text)
.map((part) => part.text)
.join("");
return NextResponse.json({
success: true,
message: `Connection successful! Response: "${responseText.substring(0, 200)}${responseText.length > 200 ? '...' : ''}"`,
model: model,
hasImage: !!imageData,
});
}
// Handle blocked responses
if (data.promptFeedback?.safetyRatings) {
return NextResponse.json({
success: true,
message: "Connection successful! Gemini responded (response may have been filtered).",
model: model,
hasImage: !!imageData,
});
}
return NextResponse.json({
success: true,
message: "Connection successful! Gemini responded.",
model: model,
hasImage: !!imageData,
});
} catch (error: unknown) {
console.error("Gemini API test error:", error);
if (error instanceof TypeError && error.message.includes("fetch")) {
return NextResponse.json(
{ success: false, error: "Network error. Unable to reach Gemini API." },
{ status: 503 }
);
}
const errorMessage =
error instanceof Error ? error.message : "Failed to connect to Gemini API";
return NextResponse.json(
{ success: false, error: errorMessage },
{ status: 500 }
);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

2254
apps/app/src/app/globals.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "sonner";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Automaker - Autonomous AI Development Studio",
description: "Build software autonomously with intelligent orchestration",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<Toaster richColors position="bottom-right" />
</body>
</html>
);
}

226
apps/app/src/app/page.tsx Normal file
View File

@@ -0,0 +1,226 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Sidebar } from "@/components/layout/sidebar";
import { WelcomeView } from "@/components/views/welcome-view";
import { BoardView } from "@/components/views/board-view";
import { SpecView } from "@/components/views/spec-view";
import { AgentView } from "@/components/views/agent-view";
import { SettingsView } from "@/components/views/settings-view";
import { AgentToolsView } from "@/components/views/agent-tools-view";
import { InterviewView } from "@/components/views/interview-view";
import { ContextView } from "@/components/views/context-view";
import { ProfilesView } from "@/components/views/profiles-view";
import { SetupView } from "@/components/views/setup-view";
import { RunningAgentsView } from "@/components/views/running-agents-view";
import { useAppStore } from "@/store/app-store";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI, isElectron } from "@/lib/electron";
export default function Home() {
const { currentView, setCurrentView, setIpcConnected, theme, currentProject } = useAppStore();
const { isFirstRun, setupComplete } = useSetupStore();
const [isMounted, setIsMounted] = useState(false);
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
// Hidden streamer panel - opens with "\" key
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
// Don't trigger when typing in inputs
const activeElement = document.activeElement;
if (activeElement) {
const tagName = activeElement.tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
return;
}
if (activeElement.getAttribute("contenteditable") === "true") {
return;
}
const role = activeElement.getAttribute("role");
if (role === "textbox" || role === "searchbox" || role === "combobox") {
return;
}
}
// Don't trigger with modifier keys
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
}
// Check for "\" key (backslash)
if (event.key === "\\") {
event.preventDefault();
setStreamerPanelOpen((prev) => !prev);
}
}, []);
// Register the "\" shortcut for streamer panel
useEffect(() => {
window.addEventListener("keydown", handleStreamerPanelShortcut);
return () => {
window.removeEventListener("keydown", handleStreamerPanelShortcut);
};
}, [handleStreamerPanelShortcut]);
// Compute the effective theme: project theme takes priority over global theme
// This is reactive because it depends on currentProject and theme from the store
const effectiveTheme = currentProject?.theme || theme;
// Prevent hydration issues
useEffect(() => {
setIsMounted(true);
}, []);
// Check if this is first run and redirect to setup if needed
useEffect(() => {
console.log("[Setup Flow] Checking setup state:", {
isMounted,
isFirstRun,
setupComplete,
currentView,
shouldShowSetup: isMounted && isFirstRun && !setupComplete,
});
if (isMounted && isFirstRun && !setupComplete) {
console.log("[Setup Flow] Redirecting to setup wizard (first run, not complete)");
setCurrentView("setup");
} else if (isMounted && setupComplete) {
console.log("[Setup Flow] Setup already complete, showing normal view");
}
}, [isMounted, isFirstRun, setupComplete, setCurrentView, currentView]);
// Test IPC connection on mount
useEffect(() => {
const testConnection = async () => {
try {
const api = getElectronAPI();
const result = await api.ping();
setIpcConnected(result === "pong" || result === "pong (mock)");
} catch (error) {
console.error("IPC connection failed:", error);
setIpcConnected(false);
}
};
testConnection();
}, [setIpcConnected]);
// Apply theme class to document (uses effective theme - project-specific or global)
useEffect(() => {
const root = document.documentElement;
root.classList.remove(
"dark",
"retro",
"light",
"dracula",
"nord",
"monokai",
"tokyonight",
"solarized",
"gruvbox",
"catppuccin",
"onedark",
"synthwave"
);
if (effectiveTheme === "dark") {
root.classList.add("dark");
} else if (effectiveTheme === "retro") {
root.classList.add("retro");
} else if (effectiveTheme === "dracula") {
root.classList.add("dracula");
} else if (effectiveTheme === "nord") {
root.classList.add("nord");
} else if (effectiveTheme === "monokai") {
root.classList.add("monokai");
} else if (effectiveTheme === "tokyonight") {
root.classList.add("tokyonight");
} else if (effectiveTheme === "solarized") {
root.classList.add("solarized");
} else if (effectiveTheme === "gruvbox") {
root.classList.add("gruvbox");
} else if (effectiveTheme === "catppuccin") {
root.classList.add("catppuccin");
} else if (effectiveTheme === "onedark") {
root.classList.add("onedark");
} else if (effectiveTheme === "synthwave") {
root.classList.add("synthwave");
} else if (effectiveTheme === "light") {
root.classList.add("light");
} else if (effectiveTheme === "system") {
// System theme
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (isDark) {
root.classList.add("dark");
} else {
root.classList.add("light");
}
}
}, [effectiveTheme]);
const renderView = () => {
switch (currentView) {
case "welcome":
return <WelcomeView />;
case "setup":
return <SetupView />;
case "board":
return <BoardView />;
case "spec":
return <SpecView />;
case "agent":
return <AgentView />;
case "settings":
return <SettingsView />;
case "tools":
return <AgentToolsView />;
case "interview":
return <InterviewView />;
case "context":
return <ContextView />;
case "profiles":
return <ProfilesView />;
case "running-agents":
return <RunningAgentsView />;
default:
return <WelcomeView />;
}
};
// Setup view is full-screen without sidebar
if (currentView === "setup") {
return (
<main className="h-screen overflow-hidden" data-testid="app-container">
<SetupView />
{/* Environment indicator */}
{isMounted && !isElectron() && (
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-yellow-500/10 text-yellow-500 text-xs rounded-full border border-yellow-500/20 pointer-events-none">
Web Mode (Mock IPC)
</div>
)}
</main>
);
}
return (
<main className="flex h-screen overflow-hidden" data-testid="app-container">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden transition-all duration-300" style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}>
{renderView()}
</div>
{/* Environment indicator - only show after mount to prevent hydration issues */}
{isMounted && !isElectron() && (
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-yellow-500/10 text-yellow-500 text-xs rounded-full border border-yellow-500/20 pointer-events-none">
Web Mode (Mock IPC)
</div>
)}
{/* Hidden streamer panel - opens with "\" key, pushes content */}
<div
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 ${
streamerPanelOpen ? 'translate-x-0' : 'translate-x-full'
}`}
/>
</main>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,532 @@
"use client";
import { useState, useEffect } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Plus,
MessageSquare,
Archive,
Trash2,
Edit2,
Check,
X,
ArchiveRestore,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { SessionListItem } from "@/types/electron";
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
import { useAppStore } from "@/store/app-store";
// Random session name generator
const adjectives = [
"Swift",
"Bright",
"Clever",
"Dynamic",
"Eager",
"Focused",
"Gentle",
"Happy",
"Inventive",
"Jolly",
"Keen",
"Lively",
"Mighty",
"Noble",
"Optimal",
"Peaceful",
"Quick",
"Radiant",
"Smart",
"Tranquil",
"Unique",
"Vibrant",
"Wise",
"Zealous",
];
const nouns = [
"Agent",
"Builder",
"Coder",
"Developer",
"Explorer",
"Forge",
"Garden",
"Helper",
"Innovator",
"Journey",
"Kernel",
"Lighthouse",
"Mission",
"Navigator",
"Oracle",
"Project",
"Quest",
"Runner",
"Spark",
"Task",
"Unicorn",
"Voyage",
"Workshop",
];
function generateRandomSessionName(): string {
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
const number = Math.floor(Math.random() * 100);
return `${adjective} ${noun} ${number}`;
}
interface SessionManagerProps {
currentSessionId: string | null;
onSelectSession: (sessionId: string | null) => void;
projectPath: string;
isCurrentSessionThinking?: boolean;
onQuickCreateRef?: React.MutableRefObject<(() => Promise<void>) | null>;
}
export function SessionManager({
currentSessionId,
onSelectSession,
projectPath,
isCurrentSessionThinking = false,
onQuickCreateRef,
}: SessionManagerProps) {
const shortcuts = useKeyboardShortcutsConfig();
const [sessions, setSessions] = useState<SessionListItem[]>([]);
const [activeTab, setActiveTab] = useState<"active" | "archived">("active");
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [newSessionName, setNewSessionName] = useState("");
const [runningSessions, setRunningSessions] = useState<Set<string>>(
new Set()
);
// Check running state for all sessions
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
if (!window.electronAPI?.agent) return;
const runningIds = new Set<string>();
// Check each session's running state
for (const session of sessionList) {
try {
const result = await window.electronAPI.agent.getHistory(session.id);
if (result.success && result.isRunning) {
runningIds.add(session.id);
}
} catch (err) {
// Ignore errors for individual session checks
console.warn(
`[SessionManager] Failed to check running state for ${session.id}:`,
err
);
}
}
setRunningSessions(runningIds);
};
// Load sessions
const loadSessions = async () => {
if (!window.electronAPI?.sessions) return;
// Always load all sessions and filter client-side
const result = await window.electronAPI.sessions.list(true);
if (result.success && result.sessions) {
setSessions(result.sessions);
// Check running state for all sessions
await checkRunningSessions(result.sessions);
}
};
useEffect(() => {
loadSessions();
}, []);
// Periodically check running state for sessions (useful for detecting when agents finish)
useEffect(() => {
// Only poll if there are running sessions
if (runningSessions.size === 0 && !isCurrentSessionThinking) return;
const interval = setInterval(async () => {
if (sessions.length > 0) {
await checkRunningSessions(sessions);
}
}, 3000); // Check every 3 seconds
return () => clearInterval(interval);
}, [sessions, runningSessions.size, isCurrentSessionThinking]);
// Create new session with random name
const handleCreateSession = async () => {
if (!window.electronAPI?.sessions) return;
const sessionName = newSessionName.trim() || generateRandomSessionName();
const result = await window.electronAPI.sessions.create(
sessionName,
projectPath,
projectPath
);
if (result.success && result.sessionId) {
setNewSessionName("");
setIsCreating(false);
await loadSessions();
onSelectSession(result.sessionId);
}
};
// Create new session directly with a random name (one-click)
const handleQuickCreateSession = async () => {
if (!window.electronAPI?.sessions) return;
const sessionName = generateRandomSessionName();
const result = await window.electronAPI.sessions.create(
sessionName,
projectPath,
projectPath
);
if (result.success && result.sessionId) {
await loadSessions();
onSelectSession(result.sessionId);
}
};
// Expose the quick create function via ref for keyboard shortcuts
useEffect(() => {
if (onQuickCreateRef) {
onQuickCreateRef.current = handleQuickCreateSession;
}
return () => {
if (onQuickCreateRef) {
onQuickCreateRef.current = null;
}
};
}, [onQuickCreateRef, projectPath]);
// Rename session
const handleRenameSession = async (sessionId: string) => {
if (!editingName.trim() || !window.electronAPI?.sessions) return;
const result = await window.electronAPI.sessions.update(
sessionId,
editingName,
undefined
);
if (result.success) {
setEditingSessionId(null);
setEditingName("");
await loadSessions();
}
};
// Archive session
const handleArchiveSession = async (sessionId: string) => {
if (!window.electronAPI?.sessions) return;
const result = await window.electronAPI.sessions.archive(sessionId);
if (result.success) {
// If the archived session was currently selected, deselect it
if (currentSessionId === sessionId) {
onSelectSession(null);
}
await loadSessions();
}
};
// Unarchive session
const handleUnarchiveSession = async (sessionId: string) => {
if (!window.electronAPI?.sessions) return;
const result = await window.electronAPI.sessions.unarchive(sessionId);
if (result.success) {
await loadSessions();
}
};
// Delete session
const handleDeleteSession = async (sessionId: string) => {
if (!window.electronAPI?.sessions) return;
if (!confirm("Are you sure you want to delete this session?")) return;
const result = await window.electronAPI.sessions.delete(sessionId);
if (result.success) {
await loadSessions();
if (currentSessionId === sessionId) {
// Switch to another session or create a new one
const activeSessionsList = sessions.filter((s) => !s.isArchived);
if (activeSessionsList.length > 0) {
onSelectSession(activeSessionsList[0].id);
}
}
}
};
const activeSessions = sessions.filter((s) => !s.isArchived);
const archivedSessions = sessions.filter((s) => s.isArchived);
const displayedSessions =
activeTab === "active" ? activeSessions : archivedSessions;
return (
<Card className="h-full flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-center justify-between mb-4">
<CardTitle>Agent Sessions</CardTitle>
{activeTab === "active" && (
<HotkeyButton
variant="default"
size="sm"
onClick={handleQuickCreateSession}
hotkey={shortcuts.newSession}
hotkeyActive={false}
data-testid="new-session-button"
title={`New Session (${shortcuts.newSession})`}
>
<Plus className="w-4 h-4 mr-1" />
New
</HotkeyButton>
)}
</div>
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(value as "active" | "archived")
}
className="w-full"
>
<TabsList className="w-full">
<TabsTrigger value="active" className="flex-1">
<MessageSquare className="w-4 h-4 mr-2" />
Active ({activeSessions.length})
</TabsTrigger>
<TabsTrigger value="archived" className="flex-1">
<Archive className="w-4 h-4 mr-2" />
Archived ({archivedSessions.length})
</TabsTrigger>
</TabsList>
</Tabs>
</CardHeader>
<CardContent
className="flex-1 overflow-y-auto space-y-2"
data-testid="session-list"
>
{/* Create new session */}
{isCreating && (
<div className="p-3 border rounded-lg bg-muted/50">
<div className="flex gap-2">
<Input
placeholder="Session name..."
value={newSessionName}
onChange={(e) => setNewSessionName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateSession();
if (e.key === "Escape") {
setIsCreating(false);
setNewSessionName("");
}
}}
autoFocus
/>
<Button size="sm" onClick={handleCreateSession}>
<Check className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setIsCreating(false);
setNewSessionName("");
}}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
)}
{/* Session list */}
{displayedSessions.map((session) => (
<div
key={session.id}
className={cn(
"p-3 border rounded-lg cursor-pointer transition-colors hover:bg-accent/50",
currentSessionId === session.id && "bg-primary/10 border-primary",
session.isArchived && "opacity-60"
)}
onClick={() => !session.isArchived && onSelectSession(session.id)}
data-testid={`session-item-${session.id}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
{editingSessionId === session.id ? (
<div className="flex gap-2 mb-2">
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleRenameSession(session.id);
if (e.key === "Escape") {
setEditingSessionId(null);
setEditingName("");
}
}}
onClick={(e) => e.stopPropagation()}
autoFocus
className="h-7"
/>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleRenameSession(session.id);
}}
className="h-7"
>
<Check className="w-3 h-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
setEditingSessionId(null);
setEditingName("");
}}
className="h-7"
>
<X className="w-3 h-3" />
</Button>
</div>
) : (
<>
<div className="flex items-center gap-2 mb-1">
{/* Show loading indicator if this session is running (either current session thinking or any session in runningSessions) */}
{(currentSessionId === session.id &&
isCurrentSessionThinking) ||
runningSessions.has(session.id) ? (
<Loader2 className="w-4 h-4 text-primary animate-spin shrink-0" />
) : (
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
)}
<h3 className="font-medium truncate">{session.name}</h3>
{((currentSessionId === session.id &&
isCurrentSessionThinking) ||
runningSessions.has(session.id)) && (
<span className="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full">
thinking...
</span>
)}
</div>
{session.preview && (
<p className="text-xs text-muted-foreground truncate">
{session.preview}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-muted-foreground">
{session.messageCount} messages
</span>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">
{new Date(session.updatedAt).toLocaleDateString()}
</span>
</div>
</>
)}
</div>
{/* Actions */}
{!session.isArchived && (
<div
className="flex gap-1"
onClick={(e) => e.stopPropagation()}
>
<Button
size="sm"
variant="ghost"
onClick={() => {
setEditingSessionId(session.id);
setEditingName(session.name);
}}
className="h-7 w-7 p-0"
>
<Edit2 className="w-3 h-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleArchiveSession(session.id)}
className="h-7 w-7 p-0"
data-testid={`archive-session-${session.id}`}
>
<Archive className="w-3 h-3" />
</Button>
</div>
)}
{session.isArchived && (
<div
className="flex gap-1"
onClick={(e) => e.stopPropagation()}
>
<Button
size="sm"
variant="ghost"
onClick={() => handleUnarchiveSession(session.id)}
className="h-7 w-7 p-0"
>
<ArchiveRestore className="w-3 h-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteSession(session.id)}
className="h-7 w-7 p-0 text-destructive"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
)}
</div>
</div>
))}
{displayedSessions.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{activeTab === "active"
? "No active sessions"
: "No archived sessions"}
</p>
<p className="text-xs">
{activeTab === "active"
? "Create your first session to get started"
: "Archive sessions to see them here"}
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,95 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
"animated-outline":
"relative overflow-hidden rounded-xl hover:bg-transparent shadow-none",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Button({
className,
variant,
size,
asChild = false,
children,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
// Special handling for animated-outline variant
if (variant === "animated-outline" && !asChild) {
return (
<button
className={cn(
buttonVariants({ variant, size }),
"p-[1px]", // Force 1px padding for the gradient border
className
)}
data-slot="button"
{...props}
>
{/* Animated rotating gradient border */}
<span className="absolute inset-[-1000%] animate-[spin_2s_linear_infinite] animated-outline-gradient" />
{/* Inner content container */}
<span
className={cn(
"animated-outline-inner inline-flex h-full w-full cursor-pointer items-center justify-center gap-2 rounded-[10px] px-4 py-1 text-sm font-medium backdrop-blur-3xl transition-all",
size === "sm" && "px-3 text-xs gap-1.5",
size === "lg" && "px-8",
size === "icon" && "p-0 gap-0"
)}
>
{children}
</span>
</button>
);
}
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
>
{children}
</Comp>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border border-white/10 backdrop-blur-sm py-6 shadow-sm",
className
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,91 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface CategoryAutocompleteProps {
value: string;
onChange: (value: string) => void;
suggestions: string[];
placeholder?: string;
className?: string;
disabled?: boolean;
"data-testid"?: string;
}
export function CategoryAutocomplete({
value,
onChange,
suggestions,
placeholder = "Select or type a category...",
className,
disabled = false,
"data-testid": testId,
}: CategoryAutocompleteProps) {
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("w-full justify-between", className)}
data-testid={testId}
>
{value
? suggestions.find((s) => s === value) ?? value
: placeholder}
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search category..." className="h-9" />
<CommandList>
<CommandEmpty>No category found.</CommandEmpty>
<CommandGroup>
{suggestions.map((suggestion) => (
<CommandItem
key={suggestion}
value={suggestion}
onSelect={(currentValue) => {
onChange(currentValue === value ? "" : currentValue);
setOpen(false);
}}
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
>
{suggestion}
<Check
className={cn(
"ml-auto",
value === suggestion ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,30 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,63 @@
"use client";
import { useState, useEffect } from "react";
import { Clock } from "lucide-react";
interface CountUpTimerProps {
startedAt: string; // ISO timestamp string
className?: string;
}
/**
* Formats elapsed time in MM:SS format
* @param seconds - Total elapsed seconds
* @returns Formatted string like "00:00", "01:30", "59:59", etc.
*/
function formatElapsedTime(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
const paddedMinutes = minutes.toString().padStart(2, "0");
const paddedSeconds = remainingSeconds.toString().padStart(2, "0");
return `${paddedMinutes}:${paddedSeconds}`;
}
/**
* CountUpTimer component that displays elapsed time since a given start time
* Updates every second to show the current elapsed time in MM:SS format
*/
export function CountUpTimer({ startedAt, className = "" }: CountUpTimerProps) {
const [elapsedSeconds, setElapsedSeconds] = useState(0);
useEffect(() => {
// Calculate initial elapsed time
const startTime = new Date(startedAt).getTime();
const calculateElapsed = () => {
const now = Date.now();
const elapsed = Math.floor((now - startTime) / 1000);
return Math.max(0, elapsed); // Ensure non-negative
};
// Set initial value
setElapsedSeconds(calculateElapsed());
// Update every second
const interval = setInterval(() => {
setElapsedSeconds(calculateElapsed());
}, 1000);
return () => clearInterval(interval);
}, [startedAt]);
return (
<div
className={`flex items-center gap-1 text-xs text-muted-foreground ${className}`}
data-testid="count-up-timer"
>
<Clock className="w-3 h-3" />
<span data-testid="timer-display">{formatElapsedTime(elapsedSeconds)}</span>
</div>
);
}

View File

@@ -0,0 +1,407 @@
"use client";
import React, { useState, useRef, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils";
import { ImageIcon, X, Loader2 } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { getElectronAPI } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
export interface FeatureImagePath {
id: string;
path: string; // Path to the temp file
filename: string;
mimeType: string;
}
// Map to store preview data by image ID (persisted across component re-mounts)
export type ImagePreviewMap = Map<string, string>;
interface DescriptionImageDropZoneProps {
value: string;
onChange: (value: string) => void;
images: FeatureImagePath[];
onImagesChange: (images: FeatureImagePath[]) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
maxFiles?: number;
maxFileSize?: number; // in bytes, default 10MB
// Optional: pass preview map from parent to persist across tab switches
previewMap?: ImagePreviewMap;
onPreviewMapChange?: (map: ImagePreviewMap) => void;
autoFocus?: boolean;
error?: boolean; // Show error state with red border
}
const ACCEPTED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
];
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export function DescriptionImageDropZone({
value,
onChange,
images,
onImagesChange,
placeholder = "Describe the feature...",
className,
disabled = false,
maxFiles = 5,
maxFileSize = DEFAULT_MAX_FILE_SIZE,
previewMap,
onPreviewMapChange,
autoFocus = false,
error = false,
}: DescriptionImageDropZoneProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
// Use parent-provided preview map if available, otherwise use local state
const [localPreviewImages, setLocalPreviewImages] = useState<Map<string, string>>(
() => new Map()
);
// Determine which preview map to use - prefer parent-controlled state
const previewImages = previewMap !== undefined ? previewMap : localPreviewImages;
const setPreviewImages = useCallback((updater: Map<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => {
if (onPreviewMapChange) {
const currentMap = previewMap !== undefined ? previewMap : localPreviewImages;
const newMap = typeof updater === 'function' ? updater(currentMap) : updater;
onPreviewMapChange(newMap);
} else {
setLocalPreviewImages((prev) => {
const newMap = typeof updater === 'function' ? updater(prev) : updater;
return newMap;
});
}
}, [onPreviewMapChange, previewMap, localPreviewImages]);
const fileInputRef = useRef<HTMLInputElement>(null);
const currentProject = useAppStore((state) => state.currentProject);
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Failed to read file as base64"));
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
reader.readAsDataURL(file);
});
};
const saveImageToTemp = async (
base64Data: string,
filename: string,
mimeType: string
): Promise<string | null> => {
try {
const api = getElectronAPI();
// Check if saveImageToTemp method exists
if (!api.saveImageToTemp) {
// Fallback for mock API - return a mock path in .automaker/images
console.log("[DescriptionImageDropZone] Using mock path for image");
return `.automaker/images/${Date.now()}_${filename}`;
}
// Get projectPath from the store if available
const projectPath = currentProject?.path;
const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath);
if (result.success && result.path) {
return result.path;
}
console.error("[DescriptionImageDropZone] Failed to save image:", result.error);
return null;
} catch (error) {
console.error("[DescriptionImageDropZone] Error saving image:", error);
return null;
}
};
const processFiles = useCallback(
async (files: FileList) => {
if (disabled || isProcessing) return;
setIsProcessing(true);
const newImages: FeatureImagePath[] = [];
const newPreviews = new Map(previewImages);
const errors: string[] = [];
for (const file of Array.from(files)) {
// Validate file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
errors.push(
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
);
continue;
}
// Validate file size
if (file.size > maxFileSize) {
const maxSizeMB = maxFileSize / (1024 * 1024);
errors.push(
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
);
continue;
}
// Check if we've reached max files
if (newImages.length + images.length >= maxFiles) {
errors.push(`Maximum ${maxFiles} images allowed.`);
break;
}
try {
const base64 = await fileToBase64(file);
const tempPath = await saveImageToTemp(base64, file.name, file.type);
if (tempPath) {
const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const imagePathRef: FeatureImagePath = {
id: imageId,
path: tempPath,
filename: file.name,
mimeType: file.type,
};
newImages.push(imagePathRef);
// Store preview for display
newPreviews.set(imageId, base64);
} else {
errors.push(`${file.name}: Failed to save image.`);
}
} catch (error) {
errors.push(`${file.name}: Failed to process image.`);
}
}
if (errors.length > 0) {
console.warn("Image upload errors:", errors);
}
if (newImages.length > 0) {
onImagesChange([...images, ...newImages]);
setPreviewImages(newPreviews);
}
setIsProcessing(false);
},
[disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (disabled) return;
const files = e.dataTransfer.files;
if (files.length > 0) {
processFiles(files);
}
},
[disabled, processFiles]
);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
setIsDragOver(true);
}
},
[disabled]
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
processFiles(files);
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
},
[processFiles]
);
const handleBrowseClick = useCallback(() => {
if (!disabled && fileInputRef.current) {
fileInputRef.current.click();
}
}, [disabled]);
const removeImage = useCallback(
(imageId: string) => {
onImagesChange(images.filter((img) => img.id !== imageId));
setPreviewImages((prev) => {
const newMap = new Map(prev);
newMap.delete(imageId);
return newMap;
});
},
[images, onImagesChange]
);
return (
<div className={cn("relative", className)}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_IMAGE_TYPES.join(",")}
onChange={handleFileSelect}
className="hidden"
disabled={disabled}
data-testid="description-image-input"
/>
{/* Drop zone wrapper */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={cn(
"relative rounded-md transition-all duration-200",
{
"ring-2 ring-blue-400 ring-offset-2 ring-offset-background":
isDragOver && !disabled,
}
)}
>
{/* Drag overlay */}
{isDragOver && !disabled && (
<div
className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-blue-500/20 border-2 border-dashed border-blue-400 pointer-events-none"
data-testid="drop-overlay"
>
<div className="flex flex-col items-center gap-2 text-blue-400">
<ImageIcon className="w-8 h-8" />
<span className="text-sm font-medium">Drop images here</span>
</div>
</div>
)}
{/* Textarea */}
<Textarea
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
autoFocus={autoFocus}
aria-invalid={error}
className={cn(
"min-h-[120px]",
isProcessing && "opacity-50 pointer-events-none"
)}
data-testid="feature-description-input"
/>
</div>
{/* Hint text */}
<p className="text-xs text-muted-foreground mt-1">
Drag and drop images here or{" "}
<button
type="button"
onClick={handleBrowseClick}
className="text-primary hover:text-primary/80 underline"
disabled={disabled || isProcessing}
>
browse
</button>{" "}
to attach context images
</p>
{/* Processing indicator */}
{isProcessing && (
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span>Saving images...</span>
</div>
)}
{/* Image previews */}
{images.length > 0 && (
<div className="mt-3 space-y-2" data-testid="description-image-previews">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{images.length} image{images.length > 1 ? "s" : ""} attached
</p>
<button
type="button"
onClick={() => {
onImagesChange([]);
setPreviewImages(new Map());
}}
className="text-xs text-muted-foreground hover:text-foreground"
disabled={disabled}
>
Clear all
</button>
</div>
<div className="flex flex-wrap gap-2">
{images.map((image) => (
<div
key={image.id}
className="relative group rounded-md border border-muted bg-muted/50 overflow-hidden"
data-testid={`description-image-preview-${image.id}`}
>
{/* Image thumbnail or placeholder */}
<div className="w-16 h-16 flex items-center justify-center bg-zinc-800">
{previewImages.has(image.id) ? (
<img
src={previewImages.get(image.id)}
alt={image.filename}
className="max-w-full max-h-full object-contain"
/>
) : (
<ImageIcon className="w-6 h-6 text-muted-foreground" />
)}
</div>
{/* Remove button */}
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeImage(image.id);
}}
className="absolute top-0.5 right-0.5 p-0.5 rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
data-testid={`remove-description-image-${image.id}`}
>
<X className="h-3 w-3" />
</button>
)}
{/* Filename tooltip on hover */}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-[10px] text-white truncate">
{image.filename}
</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,157 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
compact = false,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
compact?: boolean;
}) {
// Check if className contains a custom max-width
const hasCustomMaxWidth =
typeof className === "string" && className.includes("max-w-");
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200 max-h-[calc(100vh-4rem)]",
compact
? "max-w-4xl p-4"
: !hasCustomMaxWidth
? "sm:max-w-2xl p-6"
: "p-6",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className={cn(
"ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute rounded-xs opacity-70 transition-opacity cursor-pointer hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
compact ? "top-2 right-3" : "top-3 right-5"
)}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,198 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent hover:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-brand-400/70", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,310 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
import { ImageIcon, X, Upload } from "lucide-react";
export interface FeatureImage {
id: string;
data: string; // base64 encoded
mimeType: string;
filename: string;
size: number;
}
interface FeatureImageUploadProps {
images: FeatureImage[];
onImagesChange: (images: FeatureImage[]) => void;
maxFiles?: number;
maxFileSize?: number; // in bytes, default 10MB
className?: string;
disabled?: boolean;
}
const ACCEPTED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
];
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export function FeatureImageUpload({
images,
onImagesChange,
maxFiles = 5,
maxFileSize = DEFAULT_MAX_FILE_SIZE,
className,
disabled = false,
}: FeatureImageUploadProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Failed to read file as base64"));
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
reader.readAsDataURL(file);
});
};
const processFiles = useCallback(
async (files: FileList) => {
if (disabled || isProcessing) return;
setIsProcessing(true);
const newImages: FeatureImage[] = [];
const errors: string[] = [];
for (const file of Array.from(files)) {
// Validate file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
errors.push(
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
);
continue;
}
// Validate file size
if (file.size > maxFileSize) {
const maxSizeMB = maxFileSize / (1024 * 1024);
errors.push(
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
);
continue;
}
// Check if we've reached max files
if (newImages.length + images.length >= maxFiles) {
errors.push(`Maximum ${maxFiles} images allowed.`);
break;
}
try {
const base64 = await fileToBase64(file);
const imageAttachment: FeatureImage = {
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
data: base64,
mimeType: file.type,
filename: file.name,
size: file.size,
};
newImages.push(imageAttachment);
} catch (error) {
errors.push(`${file.name}: Failed to process image.`);
}
}
if (errors.length > 0) {
console.warn("Image upload errors:", errors);
}
if (newImages.length > 0) {
onImagesChange([...images, ...newImages]);
}
setIsProcessing(false);
},
[disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (disabled) return;
const files = e.dataTransfer.files;
if (files.length > 0) {
processFiles(files);
}
},
[disabled, processFiles]
);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
setIsDragOver(true);
}
},
[disabled]
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
processFiles(files);
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
},
[processFiles]
);
const handleBrowseClick = useCallback(() => {
if (!disabled && fileInputRef.current) {
fileInputRef.current.click();
}
}, [disabled]);
const removeImage = useCallback(
(imageId: string) => {
onImagesChange(images.filter((img) => img.id !== imageId));
},
[images, onImagesChange]
);
const clearAllImages = useCallback(() => {
onImagesChange([]);
}, [onImagesChange]);
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
};
return (
<div className={cn("relative", className)}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_IMAGE_TYPES.join(",")}
onChange={handleFileSelect}
className="hidden"
disabled={disabled}
data-testid="feature-image-input"
/>
{/* Drop zone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={handleBrowseClick}
className={cn(
"relative rounded-lg border-2 border-dashed transition-all duration-200 cursor-pointer",
{
"border-blue-400 bg-blue-50 dark:bg-blue-950/20":
isDragOver && !disabled,
"border-muted-foreground/25": !isDragOver && !disabled,
"border-muted-foreground/10 opacity-50 cursor-not-allowed": disabled,
"hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10":
!disabled && !isDragOver,
}
)}
data-testid="feature-image-dropzone"
>
<div className="flex flex-col items-center justify-center p-4 text-center">
<div
className={cn(
"rounded-full p-2 mb-2",
isDragOver && !disabled
? "bg-blue-100 dark:bg-blue-900/30"
: "bg-muted"
)}
>
{isProcessing ? (
<Upload className="h-5 w-5 animate-spin text-muted-foreground" />
) : (
<ImageIcon className="h-5 w-5 text-muted-foreground" />
)}
</div>
<p className="text-sm text-muted-foreground">
{isDragOver && !disabled
? "Drop images here"
: "Click or drag images here"}
</p>
<p className="text-xs text-muted-foreground mt-1">
Up to {maxFiles} images, max{" "}
{Math.round(maxFileSize / (1024 * 1024))}MB each
</p>
</div>
</div>
{/* Image previews */}
{images.length > 0 && (
<div className="mt-3 space-y-2" data-testid="feature-image-previews">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{images.length} image{images.length > 1 ? "s" : ""} selected
</p>
<button
type="button"
onClick={clearAllImages}
className="text-xs text-muted-foreground hover:text-foreground"
disabled={disabled}
>
Clear all
</button>
</div>
<div className="flex flex-wrap gap-2">
{images.map((image) => (
<div
key={image.id}
className="relative group rounded-md border border-muted bg-muted/50 overflow-hidden"
data-testid={`feature-image-preview-${image.id}`}
>
{/* Image thumbnail */}
<div className="w-16 h-16 flex items-center justify-center">
<img
src={image.data}
alt={image.filename}
className="max-w-full max-h-full object-contain"
/>
</div>
{/* Remove button */}
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeImage(image.id);
}}
className="absolute top-0.5 right-0.5 p-0.5 rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
data-testid={`remove-image-${image.id}`}
>
<X className="h-3 w-3" />
</button>
)}
{/* Filename tooltip on hover */}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-[10px] text-white truncate">
{image.filename}
</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,630 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { cn } from "@/lib/utils";
import {
File,
FileText,
FilePlus,
FileX,
FilePen,
ChevronDown,
ChevronRight,
Loader2,
RefreshCw,
GitBranch,
AlertCircle,
} from "lucide-react";
import { Button } from "./button";
import type { FileStatus } from "@/types/electron";
interface GitDiffPanelProps {
projectPath: string;
featureId: string;
className?: string;
/** Whether to show the panel in a compact/minimized state initially */
compact?: boolean;
/** Whether worktrees are enabled - if false, shows diffs from main project */
useWorktrees?: boolean;
}
interface ParsedDiffHunk {
header: string;
lines: {
type: "context" | "addition" | "deletion" | "header";
content: string;
lineNumber?: { old?: number; new?: number };
}[];
}
interface ParsedFileDiff {
filePath: string;
hunks: ParsedDiffHunk[];
isNew?: boolean;
isDeleted?: boolean;
isRenamed?: boolean;
}
const getFileIcon = (status: string) => {
switch (status) {
case "A":
case "?":
return <FilePlus className="w-4 h-4 text-green-500" />;
case "D":
return <FileX className="w-4 h-4 text-red-500" />;
case "M":
case "U":
return <FilePen className="w-4 h-4 text-amber-500" />;
case "R":
case "C":
return <File className="w-4 h-4 text-blue-500" />;
default:
return <FileText className="w-4 h-4 text-muted-foreground" />;
}
};
const getStatusBadgeColor = (status: string) => {
switch (status) {
case "A":
case "?":
return "bg-green-500/20 text-green-400 border-green-500/30";
case "D":
return "bg-red-500/20 text-red-400 border-red-500/30";
case "M":
case "U":
return "bg-amber-500/20 text-amber-400 border-amber-500/30";
case "R":
case "C":
return "bg-blue-500/20 text-blue-400 border-blue-500/30";
default:
return "bg-muted text-muted-foreground border-border";
}
};
const getStatusDisplayName = (status: string) => {
switch (status) {
case "A":
return "Added";
case "?":
return "Untracked";
case "D":
return "Deleted";
case "M":
return "Modified";
case "U":
return "Updated";
case "R":
return "Renamed";
case "C":
return "Copied";
default:
return "Changed";
}
};
/**
* Parse unified diff format into structured data
*/
function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split("\n");
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
let newLineNum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// New file diff
if (line.startsWith("diff --git")) {
if (currentFile) {
if (currentHunk) {
currentFile.hunks.push(currentHunk);
}
files.push(currentFile);
}
// Extract file path from diff header
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : "unknown",
hunks: [],
};
currentHunk = null;
continue;
}
// New file indicator
if (line.startsWith("new file mode")) {
if (currentFile) currentFile.isNew = true;
continue;
}
// Deleted file indicator
if (line.startsWith("deleted file mode")) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
// Renamed file indicator
if (line.startsWith("rename from") || line.startsWith("rename to")) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
// Skip index, ---/+++ lines
if (
line.startsWith("index ") ||
line.startsWith("--- ") ||
line.startsWith("+++ ")
) {
continue;
}
// Hunk header
if (line.startsWith("@@")) {
if (currentHunk && currentFile) {
currentFile.hunks.push(currentHunk);
}
// Parse line numbers from @@ -old,count +new,count @@
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: "header", content: line }],
};
continue;
}
// Diff content lines
if (currentHunk) {
if (line.startsWith("+")) {
currentHunk.lines.push({
type: "addition",
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
} else if (line.startsWith("-")) {
currentHunk.lines.push({
type: "deletion",
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
} else if (line.startsWith(" ") || line === "") {
currentHunk.lines.push({
type: "context",
content: line.substring(1) || "",
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
newLineNum++;
}
}
}
// Don't forget the last file and hunk
if (currentFile) {
if (currentHunk) {
currentFile.hunks.push(currentHunk);
}
files.push(currentFile);
}
return files;
}
function DiffLine({
type,
content,
lineNumber,
}: {
type: "context" | "addition" | "deletion" | "header";
content: string;
lineNumber?: { old?: number; new?: number };
}) {
const bgClass = {
context: "bg-transparent",
addition: "bg-green-500/10",
deletion: "bg-red-500/10",
header: "bg-blue-500/10",
};
const textClass = {
context: "text-foreground-secondary",
addition: "text-green-400",
deletion: "text-red-400",
header: "text-blue-400",
};
const prefix = {
context: " ",
addition: "+",
deletion: "-",
header: "",
};
if (type === "header") {
return (
<div className={cn("px-2 py-1 font-mono text-xs", bgClass[type], textClass[type])}>
{content}
</div>
);
}
return (
<div className={cn("flex font-mono text-xs", bgClass[type])}>
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
{lineNumber?.old ?? ""}
</span>
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
{lineNumber?.new ?? ""}
</span>
<span className={cn("w-4 flex-shrink-0 text-center select-none", textClass[type])}>
{prefix[type]}
</span>
<span className={cn("flex-1 px-2 whitespace-pre-wrap break-all", textClass[type])}>
{content || "\u00A0"}
</span>
</div>
);
}
function FileDiffSection({
fileDiff,
isExpanded,
onToggle,
}: {
fileDiff: ParsedFileDiff;
isExpanded: boolean;
onToggle: () => void;
}) {
const additions = fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === "addition").length,
0
);
const deletions = fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === "deletion").length,
0
);
return (
<div className="border border-border rounded-lg overflow-hidden">
<button
onClick={onToggle}
className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card hover:bg-accent/50 transition-colors"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="flex-1 text-sm font-mono truncate text-foreground">
{fileDiff.filePath}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
{fileDiff.isNew && (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
new
</span>
)}
{fileDiff.isDeleted && (
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">
deleted
</span>
)}
{fileDiff.isRenamed && (
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">
renamed
</span>
)}
{additions > 0 && (
<span className="text-xs text-green-400">+{additions}</span>
)}
{deletions > 0 && (
<span className="text-xs text-red-400">-{deletions}</span>
)}
</div>
</button>
{isExpanded && (
<div className="bg-background border-t border-border max-h-[400px] overflow-y-auto scrollbar-visible">
{fileDiff.hunks.map((hunk, hunkIndex) => (
<div key={hunkIndex} className="border-b border-border-glass last:border-b-0">
{hunk.lines.map((line, lineIndex) => (
<DiffLine
key={lineIndex}
type={line.type}
content={line.content}
lineNumber={line.lineNumber}
/>
))}
</div>
))}
</div>
)}
</div>
);
}
export function GitDiffPanel({
projectPath,
featureId,
className,
compact = true,
useWorktrees = false,
}: GitDiffPanelProps) {
const [isExpanded, setIsExpanded] = useState(!compact);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<FileStatus[]>([]);
const [diffContent, setDiffContent] = useState<string>("");
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const loadDiffs = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
// Use worktree API if worktrees are enabled, otherwise use git API for main project
if (useWorktrees) {
if (!api?.worktree?.getDiffs) {
throw new Error("Worktree API not available");
}
const result = await api.worktree.getDiffs(projectPath, featureId);
if (result.success) {
setFiles(result.files || []);
setDiffContent(result.diff || "");
} else {
setError(result.error || "Failed to load diffs");
}
} else {
// Use git API for main project diffs
if (!api?.git?.getDiffs) {
throw new Error("Git API not available");
}
const result = await api.git.getDiffs(projectPath);
if (result.success) {
setFiles(result.files || []);
setDiffContent(result.diff || "");
} else {
setError(result.error || "Failed to load diffs");
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load diffs");
} finally {
setIsLoading(false);
}
}, [projectPath, featureId, useWorktrees]);
// Load diffs when expanded
useEffect(() => {
if (isExpanded) {
loadDiffs();
}
}, [isExpanded, loadDiffs]);
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
const toggleFile = (filePath: string) => {
setExpandedFiles((prev) => {
const next = new Set(prev);
if (next.has(filePath)) {
next.delete(filePath);
} else {
next.add(filePath);
}
return next;
});
};
const expandAllFiles = () => {
setExpandedFiles(new Set(parsedDiffs.map((d) => d.filePath)));
};
const collapseAllFiles = () => {
setExpandedFiles(new Set());
};
// Total stats
const totalAdditions = parsedDiffs.reduce(
(acc, file) =>
acc +
file.hunks.reduce(
(hAcc, hunk) =>
hAcc + hunk.lines.filter((l) => l.type === "addition").length,
0
),
0
);
const totalDeletions = parsedDiffs.reduce(
(acc, file) =>
acc +
file.hunks.reduce(
(hAcc, hunk) =>
hAcc + hunk.lines.filter((l) => l.type === "deletion").length,
0
),
0
);
return (
<div
className={cn(
"rounded-xl border border-border bg-card backdrop-blur-sm overflow-hidden",
className
)}
data-testid="git-diff-panel"
>
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-4 py-3 flex items-center justify-between bg-card hover:bg-accent/50 transition-colors text-left flex-shrink-0"
data-testid="git-diff-panel-toggle"
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
<GitBranch className="w-4 h-4 text-brand-500" />
<span className="font-medium text-sm text-foreground">Git Changes</span>
</div>
<div className="flex items-center gap-3 text-xs">
{!isExpanded && files.length > 0 && (
<>
<span className="text-muted-foreground">
{files.length} {files.length === 1 ? "file" : "files"}
</span>
{totalAdditions > 0 && (
<span className="text-green-400">+{totalAdditions}</span>
)}
{totalDeletions > 0 && (
<span className="text-red-400">-{totalDeletions}</span>
)}
</>
)}
</div>
</button>
{/* Content */}
{isExpanded && (
<div className="border-t border-border">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-sm">Loading changes...</span>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
<AlertCircle className="w-5 h-5 text-amber-500" />
<span className="text-sm">{error}</span>
<Button
variant="ghost"
size="sm"
onClick={loadDiffs}
className="mt-2"
>
<RefreshCw className="w-4 h-4 mr-2" />
Retry
</Button>
</div>
) : files.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<span className="text-sm">No changes detected</span>
</div>
) : (
<div>
{/* Summary bar */}
<div className="p-4 pb-2 border-b border-border-glass">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-wrap">
{(() => {
// Group files by status
const statusGroups = files.reduce((acc, file) => {
const status = file.status;
if (!acc[status]) {
acc[status] = {
count: 0,
statusText: getStatusDisplayName(status),
files: []
};
}
acc[status].count += 1;
acc[status].files.push(file.path);
return acc;
}, {} as Record<string, {count: number, statusText: string, files: string[]}>);
return Object.entries(statusGroups).map(([status, group]) => (
<div
key={status}
className="flex items-center gap-1.5"
title={group.files.join('\n')}
data-testid={`git-status-group-${status.toLowerCase()}`}
>
{getFileIcon(status)}
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded border font-medium",
getStatusBadgeColor(status)
)}
>
{group.count} {group.statusText}
</span>
</div>
));
})()}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={expandAllFiles}
className="text-xs h-7"
>
Expand All
</Button>
<Button
variant="ghost"
size="sm"
onClick={collapseAllFiles}
className="text-xs h-7"
>
Collapse All
</Button>
<Button
variant="ghost"
size="sm"
onClick={loadDiffs}
className="text-xs h-7"
>
<RefreshCw className="w-3 h-3 mr-1" />
Refresh
</Button>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm mt-2">
<span className="text-muted-foreground">
{files.length} {files.length === 1 ? "file" : "files"} changed
</span>
{totalAdditions > 0 && (
<span className="text-green-400">
+{totalAdditions} additions
</span>
)}
{totalDeletions > 0 && (
<span className="text-red-400">
-{totalDeletions} deletions
</span>
)}
</div>
</div>
{/* File diffs */}
<div className="p-4 space-y-3">
{parsedDiffs.map((fileDiff) => (
<FileDiffSection
key={fileDiff.filePath}
fileDiff={fileDiff}
isExpanded={expandedFiles.has(fileDiff.filePath)}
onToggle={() => toggleFile(fileDiff.filePath)}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,305 @@
"use client";
import * as React from "react";
import { useEffect, useCallback, useRef } from "react";
import { Button, buttonVariants } from "./button";
import { cn } from "@/lib/utils";
import type { VariantProps } from "class-variance-authority";
export interface HotkeyConfig {
/** The key to trigger the hotkey (e.g., "Enter", "s", "n") */
key: string;
/** Whether the Cmd/Ctrl modifier is required */
cmdCtrl?: boolean;
/** Whether the Shift modifier is required */
shift?: boolean;
/** Whether the Alt/Option modifier is required */
alt?: boolean;
/** Custom display label for the hotkey (overrides auto-generated label) */
label?: string;
}
export interface HotkeyButtonProps
extends React.ComponentProps<"button">,
VariantProps<typeof buttonVariants> {
/** Hotkey configuration - can be a simple key string or a full config object */
hotkey?: string | HotkeyConfig;
/** Whether to show the hotkey indicator badge */
showHotkeyIndicator?: boolean;
/** Whether the hotkey listener is active (registers keyboard listener). Set to false if hotkey is already handled elsewhere. */
hotkeyActive?: boolean;
/** Optional scope element ref - hotkey will only work when this element is visible */
scopeRef?: React.RefObject<HTMLElement | null>;
/** Callback when hotkey is triggered */
onHotkeyTrigger?: () => void;
/** Whether to use the Slot component for composition */
asChild?: boolean;
}
/**
* Get the modifier key symbol based on platform
*/
function getModifierSymbol(isMac: boolean): string {
return isMac ? "⌘" : "Ctrl";
}
/**
* Parse hotkey config into a normalized format
*/
function parseHotkeyConfig(hotkey: string | HotkeyConfig): HotkeyConfig {
if (typeof hotkey === "string") {
return { key: hotkey };
}
return hotkey;
}
/**
* Generate the display label for the hotkey
*/
function getHotkeyDisplayLabel(
config: HotkeyConfig,
isMac: boolean
): React.ReactNode {
if (config.label) {
return config.label;
}
const parts: React.ReactNode[] = [];
if (config.cmdCtrl) {
parts.push(
<span key="mod" className="leading-none flex items-center justify-center">
{getModifierSymbol(isMac)}
</span>
);
}
if (config.shift) {
parts.push(
<span
key="shift"
className="leading-none flex items-center justify-center"
>
</span>
);
}
if (config.alt) {
parts.push(
<span key="alt" className="leading-none flex items-center justify-center">
{isMac ? "⌥" : "Alt"}
</span>
);
}
// Convert key to display format
let keyDisplay = config.key;
switch (config.key.toLowerCase()) {
case "enter":
keyDisplay = "↵";
break;
case "escape":
case "esc":
keyDisplay = "Esc";
break;
case "arrowup":
keyDisplay = "↑";
break;
case "arrowdown":
keyDisplay = "↓";
break;
case "arrowleft":
keyDisplay = "←";
break;
case "arrowright":
keyDisplay = "→";
break;
case "backspace":
keyDisplay = "⌫";
break;
case "delete":
keyDisplay = "⌦";
break;
case "tab":
keyDisplay = "⇥";
break;
case " ":
keyDisplay = "Space";
break;
default:
// Capitalize single letters
if (config.key.length === 1) {
keyDisplay = config.key.toUpperCase();
}
}
parts.push(
<span key="key" className="leading-none flex items-center justify-center">
{keyDisplay}
</span>
);
return <span className="inline-flex items-center gap-1.5">{parts}</span>;
}
/**
* Check if an element is a form input
*/
function isInputElement(element: Element | null): boolean {
if (!element) return false;
const tagName = element.tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
return true;
}
if (element.getAttribute("contenteditable") === "true") {
return true;
}
const role = element.getAttribute("role");
if (role === "textbox" || role === "searchbox" || role === "combobox") {
return true;
}
return false;
}
/**
* A button component that supports keyboard hotkeys
*
* Features:
* - Automatic hotkey listening when mounted
* - Visual hotkey indicator badge
* - Support for modifier keys (Cmd/Ctrl, Shift, Alt)
* - Respects focus context (doesn't trigger when typing in inputs)
* - Scoped activation via scopeRef
*/
export function HotkeyButton({
hotkey,
showHotkeyIndicator = true,
hotkeyActive = true,
scopeRef,
onHotkeyTrigger,
onClick,
disabled,
children,
className,
variant,
size,
asChild = false,
...props
}: HotkeyButtonProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
const [isMac, setIsMac] = React.useState(true);
// Detect platform on mount
useEffect(() => {
setIsMac(navigator.platform.toLowerCase().includes("mac"));
}, []);
const config = hotkey ? parseHotkeyConfig(hotkey) : null;
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!config || !hotkeyActive || disabled) return;
// Don't trigger when typing in inputs (unless explicitly scoped or using cmdCtrl modifier)
// cmdCtrl shortcuts like Cmd+Enter should work even in inputs as they're intentional submit actions
if (
!scopeRef &&
!config.cmdCtrl &&
isInputElement(document.activeElement)
) {
return;
}
// Check modifier keys
const cmdCtrlPressed = event.metaKey || event.ctrlKey;
const shiftPressed = event.shiftKey;
const altPressed = event.altKey;
// Validate modifier requirements
if (config.cmdCtrl && !cmdCtrlPressed) return;
if (!config.cmdCtrl && cmdCtrlPressed) return;
if (config.shift && !shiftPressed) return;
if (!config.shift && shiftPressed) return;
if (config.alt && !altPressed) return;
if (!config.alt && altPressed) return;
// Check if the key matches
if (event.key.toLowerCase() !== config.key.toLowerCase()) return;
// If scoped, check that the scope element is visible
if (scopeRef && scopeRef.current) {
const scopeEl = scopeRef.current;
const isVisible =
scopeEl.offsetParent !== null ||
getComputedStyle(scopeEl).display !== "none";
if (!isVisible) return;
}
event.preventDefault();
event.stopPropagation();
// Trigger the click handler or custom onHotkeyTrigger
if (onHotkeyTrigger) {
onHotkeyTrigger();
} else if (onClick) {
onClick(event as unknown as React.MouseEvent<HTMLButtonElement>);
} else if (buttonRef.current) {
buttonRef.current.click();
}
},
[config, hotkeyActive, disabled, scopeRef, onHotkeyTrigger, onClick]
);
// Set up global key listener
useEffect(() => {
if (!config || !hotkeyActive) return;
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [config, hotkeyActive, handleKeyDown]);
// Render the hotkey indicator
const hotkeyIndicator =
config && showHotkeyIndicator ? (
<span
className="px-2 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/10 border border-primary-foreground/20 inline-flex items-center gap-1.5"
data-testid="hotkey-indicator"
>
{getHotkeyDisplayLabel(config, isMac)}
</span>
) : null;
return (
<Button
ref={buttonRef}
variant={variant}
size={size}
disabled={disabled}
onClick={onClick}
className={cn(className)}
asChild={asChild}
{...props}
>
{typeof children === "string" ? (
<>
{children}
{hotkeyIndicator}
</>
) : (
<>
{children}
{hotkeyIndicator}
</>
)}
</Button>
);
}
export { getHotkeyDisplayLabel, parseHotkeyConfig };

View File

@@ -0,0 +1,290 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
import { ImageIcon, X, Upload } from "lucide-react";
import type { ImageAttachment } from "@/store/app-store";
interface ImageDropZoneProps {
onImagesSelected: (images: ImageAttachment[]) => void;
maxFiles?: number;
maxFileSize?: number; // in bytes, default 10MB
className?: string;
children?: React.ReactNode;
disabled?: boolean;
images?: ImageAttachment[]; // Optional controlled images prop
}
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export function ImageDropZone({
onImagesSelected,
maxFiles = 5,
maxFileSize = DEFAULT_MAX_FILE_SIZE,
className,
children,
disabled = false,
images,
}: ImageDropZoneProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [internalImages, setInternalImages] = useState<ImageAttachment[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
// Use controlled images if provided, otherwise use internal state
const selectedImages = images ?? internalImages;
// Update images - for controlled mode, just call the callback; for uncontrolled, also update internal state
const updateImages = useCallback((newImages: ImageAttachment[]) => {
if (images === undefined) {
setInternalImages(newImages);
}
onImagesSelected(newImages);
}, [images, onImagesSelected]);
const processFiles = useCallback(async (files: FileList) => {
if (disabled || isProcessing) return;
setIsProcessing(true);
const newImages: ImageAttachment[] = [];
const errors: string[] = [];
for (const file of Array.from(files)) {
// Validate file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
continue;
}
// Validate file size
if (file.size > maxFileSize) {
const maxSizeMB = maxFileSize / (1024 * 1024);
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
continue;
}
// Check if we've reached max files
if (newImages.length + selectedImages.length >= maxFiles) {
errors.push(`Maximum ${maxFiles} images allowed.`);
break;
}
try {
const base64 = await fileToBase64(file);
const imageAttachment: ImageAttachment = {
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
data: base64,
mimeType: file.type,
filename: file.name,
size: file.size,
};
newImages.push(imageAttachment);
} catch (error) {
errors.push(`${file.name}: Failed to process image.`);
}
}
if (errors.length > 0) {
console.warn('Image upload errors:', errors);
// You could show these errors to the user via a toast or notification
}
if (newImages.length > 0) {
const allImages = [...selectedImages, ...newImages];
updateImages(allImages);
}
setIsProcessing(false);
}, [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, updateImages]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (disabled) return;
const files = e.dataTransfer.files;
if (files.length > 0) {
processFiles(files);
}
}, [disabled, processFiles]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
setIsDragOver(true);
}
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
processFiles(files);
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [processFiles]);
const handleBrowseClick = useCallback(() => {
if (!disabled && fileInputRef.current) {
fileInputRef.current.click();
}
}, [disabled]);
const removeImage = useCallback((imageId: string) => {
const updated = selectedImages.filter(img => img.id !== imageId);
updateImages(updated);
}, [selectedImages, updateImages]);
const clearAllImages = useCallback(() => {
updateImages([]);
}, [updateImages]);
return (
<div className={cn("relative", className)}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_IMAGE_TYPES.join(',')}
onChange={handleFileSelect}
className="hidden"
disabled={disabled}
/>
{/* Drop zone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={cn(
"relative rounded-lg border-2 border-dashed transition-all duration-200",
{
"border-blue-400 bg-blue-50 dark:bg-blue-950/20": isDragOver && !disabled,
"border-muted-foreground/25": !isDragOver && !disabled,
"border-muted-foreground/10 opacity-50 cursor-not-allowed": disabled,
"hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10": !disabled && !isDragOver,
}
)}
>
{children || (
<div className="flex flex-col items-center justify-center p-6 text-center">
<div className={cn(
"rounded-full p-3 mb-4",
isDragOver && !disabled ? "bg-blue-100 dark:bg-blue-900/30" : "bg-muted"
)}>
{isProcessing ? (
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<ImageIcon className="h-6 w-6 text-muted-foreground" />
)}
</div>
<p className="text-sm font-medium text-foreground mb-1">
{isDragOver && !disabled ? "Drop your images here" : "Drag images here or click to browse"}
</p>
<p className="text-xs text-muted-foreground">
{maxFiles > 1 ? `Up to ${maxFiles} images` : "1 image"}, max {Math.round(maxFileSize / (1024 * 1024))}MB each
</p>
{!disabled && (
<button
onClick={handleBrowseClick}
className="mt-2 text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
disabled={isProcessing}
>
Browse files
</button>
)}
</div>
)}
</div>
{/* Image previews */}
{selectedImages.length > 0 && (
<div className="mt-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{selectedImages.length} image{selectedImages.length > 1 ? 's' : ''} selected
</p>
<button
onClick={clearAllImages}
className="text-xs text-muted-foreground hover:text-foreground"
disabled={disabled}
>
Clear all
</button>
</div>
<div className="flex flex-wrap gap-2">
{selectedImages.map((image) => (
<div
key={image.id}
className="relative group rounded-md border border-muted bg-muted/50 p-2 flex items-center space-x-2"
>
{/* Image thumbnail */}
<div className="w-8 h-8 rounded overflow-hidden bg-muted flex-shrink-0">
<img
src={image.data}
alt={image.filename}
className="w-full h-full object-cover"
/>
</div>
{/* Image info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate">
{image.filename}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(image.size)}
</p>
</div>
{/* Remove button */}
{!disabled && (
<button
onClick={() => removeImage(image.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive hover:text-destructive-foreground text-muted-foreground"
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('Failed to read file as base64'));
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-input border-input h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,639 @@
"use client";
import * as React from "react";
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, parseShortcut, formatShortcut } from "@/store/app-store";
import type { KeyboardShortcuts } from "@/store/app-store";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CheckCircle2, X, RotateCcw, Edit2 } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
// Detect if running on Mac
const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
// Keyboard layout - US QWERTY
const KEYBOARD_ROWS = [
// Number row
[
{ key: "`", label: "`", width: 1 },
{ key: "1", label: "1", width: 1 },
{ key: "2", label: "2", width: 1 },
{ key: "3", label: "3", width: 1 },
{ key: "4", label: "4", width: 1 },
{ key: "5", label: "5", width: 1 },
{ key: "6", label: "6", width: 1 },
{ key: "7", label: "7", width: 1 },
{ key: "8", label: "8", width: 1 },
{ key: "9", label: "9", width: 1 },
{ key: "0", label: "0", width: 1 },
{ key: "-", label: "-", width: 1 },
{ key: "=", label: "=", width: 1 },
],
// Top letter row
[
{ key: "Q", label: "Q", width: 1 },
{ key: "W", label: "W", width: 1 },
{ key: "E", label: "E", width: 1 },
{ key: "R", label: "R", width: 1 },
{ key: "T", label: "T", width: 1 },
{ key: "Y", label: "Y", width: 1 },
{ key: "U", label: "U", width: 1 },
{ key: "I", label: "I", width: 1 },
{ key: "O", label: "O", width: 1 },
{ key: "P", label: "P", width: 1 },
{ key: "[", label: "[", width: 1 },
{ key: "]", label: "]", width: 1 },
{ key: "\\", label: "\\", width: 1 },
],
// Home row
[
{ key: "A", label: "A", width: 1 },
{ key: "S", label: "S", width: 1 },
{ key: "D", label: "D", width: 1 },
{ key: "F", label: "F", width: 1 },
{ key: "G", label: "G", width: 1 },
{ key: "H", label: "H", width: 1 },
{ key: "J", label: "J", width: 1 },
{ key: "K", label: "K", width: 1 },
{ key: "L", label: "L", width: 1 },
{ key: ";", label: ";", width: 1 },
{ key: "'", label: "'", width: 1 },
],
// Bottom letter row
[
{ key: "Z", label: "Z", width: 1 },
{ key: "X", label: "X", width: 1 },
{ key: "C", label: "C", width: 1 },
{ key: "V", label: "V", width: 1 },
{ key: "B", label: "B", width: 1 },
{ key: "N", label: "N", width: 1 },
{ key: "M", label: "M", width: 1 },
{ key: ",", label: ",", width: 1 },
{ key: ".", label: ".", width: 1 },
{ key: "/", label: "/", width: 1 },
],
];
// Map shortcut names to human-readable labels
const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
board: "Kanban Board",
agent: "Agent Runner",
spec: "Spec Editor",
context: "Context",
tools: "Agent Tools",
settings: "Settings",
profiles: "AI Profiles",
toggleSidebar: "Toggle Sidebar",
addFeature: "Add Feature",
addContextFile: "Add Context File",
startNext: "Start Next",
newSession: "New Session",
openProject: "Open Project",
projectPicker: "Project Picker",
cyclePrevProject: "Prev Project",
cycleNextProject: "Next Project",
addProfile: "Add Profile",
};
// Categorize shortcuts for color coding
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" | "action"> = {
board: "navigation",
agent: "navigation",
spec: "navigation",
context: "navigation",
tools: "navigation",
settings: "navigation",
profiles: "navigation",
toggleSidebar: "ui",
addFeature: "action",
addContextFile: "action",
startNext: "action",
newSession: "action",
openProject: "action",
projectPicker: "action",
cyclePrevProject: "action",
cycleNextProject: "action",
addProfile: "action",
};
// Category colors
const CATEGORY_COLORS = {
navigation: {
bg: "bg-blue-500/20",
border: "border-blue-500/50",
text: "text-blue-400",
label: "Navigation",
},
ui: {
bg: "bg-purple-500/20",
border: "border-purple-500/50",
text: "text-purple-400",
label: "UI Controls",
},
action: {
bg: "bg-green-500/20",
border: "border-green-500/50",
text: "text-green-400",
label: "Actions",
},
};
interface KeyboardMapProps {
onKeySelect?: (key: string) => void;
selectedKey?: string | null;
className?: string;
}
export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMapProps) {
const { keyboardShortcuts } = useAppStore();
// Create a reverse map: base key -> list of shortcut names (including info about modifiers)
const keyToShortcuts = React.useMemo(() => {
const map: Record<string, Array<{ name: keyof KeyboardShortcuts; hasModifiers: boolean }>> = {};
(Object.entries(keyboardShortcuts) as [keyof KeyboardShortcuts, string][]).forEach(
([shortcutName, shortcutStr]) => {
const parsed = parseShortcut(shortcutStr);
const normalizedKey = parsed.key.toUpperCase();
const hasModifiers = !!(parsed.shift || parsed.cmdCtrl || parsed.alt);
if (!map[normalizedKey]) {
map[normalizedKey] = [];
}
map[normalizedKey].push({ name: shortcutName, hasModifiers });
}
);
return map;
}, [keyboardShortcuts]);
const renderKey = (keyDef: { key: string; label: string; width: number }) => {
const normalizedKey = keyDef.key.toUpperCase();
const shortcutInfos = keyToShortcuts[normalizedKey] || [];
const shortcuts = shortcutInfos.map(s => s.name);
const isBound = shortcuts.length > 0;
const isSelected = selectedKey?.toUpperCase() === normalizedKey;
const isModified = shortcuts.some(
(s) => keyboardShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s]
);
// Get category for coloring (use first shortcut's category if multiple)
const category = shortcuts.length > 0 ? SHORTCUT_CATEGORIES[shortcuts[0]] : null;
const colors = category ? CATEGORY_COLORS[category] : null;
const keyElement = (
<button
key={keyDef.key}
onClick={() => onKeySelect?.(keyDef.key)}
className={cn(
"relative flex flex-col items-center justify-center rounded-lg border transition-all",
"h-12 min-w-11 py-1",
keyDef.width > 1 && `w-[${keyDef.width * 2.75}rem]`,
// Base styles
!isBound && "bg-sidebar-accent/10 border-sidebar-border hover:bg-sidebar-accent/20",
// Bound key styles
isBound && colors && `${colors.bg} ${colors.border} hover:brightness-110`,
// Selected state
isSelected && "ring-2 ring-brand-500 ring-offset-2 ring-offset-background",
// Modified indicator
isModified && "ring-1 ring-yellow-500/50"
)}
data-testid={`keyboard-key-${keyDef.key}`}
>
{/* Key label - always at top */}
<span
className={cn(
"text-sm font-mono font-bold leading-none",
isBound && colors ? colors.text : "text-muted-foreground"
)}
>
{keyDef.label}
</span>
{/* Shortcut label - always takes up space to maintain consistent height */}
<span
className={cn(
"text-[9px] leading-tight text-center px-0.5 truncate max-w-full h-3 mt-0.5",
isBound && shortcuts.length > 0
? (colors ? colors.text : "text-muted-foreground")
: "opacity-0"
)}
>
{isBound && shortcuts.length > 0
? (shortcuts.length === 1
? SHORTCUT_LABELS[shortcuts[0]].split(" ")[0]
: `${shortcuts.length}x`)
: "\u00A0" // Non-breaking space to maintain height
}
</span>
{isModified && (
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-yellow-500" />
)}
</button>
);
// Wrap in tooltip if bound
if (isBound) {
return (
<Tooltip key={keyDef.key}>
<TooltipTrigger asChild>{keyElement}</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="space-y-1">
{shortcuts.map((shortcut) => {
const shortcutStr = keyboardShortcuts[shortcut];
const displayShortcut = formatShortcut(shortcutStr, true);
return (
<div key={shortcut} className="flex items-center gap-2">
<span
className={cn(
"w-2 h-2 rounded-full",
CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace("/20", "")
)}
/>
<span className="text-sm">{SHORTCUT_LABELS[shortcut]}</span>
<kbd className="text-xs font-mono bg-sidebar-accent/30 px-1 rounded">
{displayShortcut}
</kbd>
{keyboardShortcuts[shortcut] !== DEFAULT_KEYBOARD_SHORTCUTS[shortcut] && (
<span className="text-xs text-yellow-400">(custom)</span>
)}
</div>
);
})}
</div>
</TooltipContent>
</Tooltip>
);
}
return keyElement;
};
return (
<TooltipProvider>
<div className={cn("space-y-4", className)} data-testid="keyboard-map">
{/* Legend */}
<div className="flex flex-wrap gap-4 justify-center text-xs">
{Object.entries(CATEGORY_COLORS).map(([key, colors]) => (
<div key={key} className="flex items-center gap-2">
<div
className={cn(
"w-4 h-4 rounded border",
colors.bg,
colors.border
)}
/>
<span className={colors.text}>{colors.label}</span>
</div>
))}
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-sidebar-accent/10 border border-sidebar-border" />
<span className="text-muted-foreground">Available</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-yellow-500" />
<span className="text-yellow-400">Modified</span>
</div>
</div>
{/* Keyboard layout */}
<div className="flex flex-col items-center gap-1.5 p-4 rounded-xl bg-sidebar-accent/5 border border-sidebar-border">
{KEYBOARD_ROWS.map((row, rowIndex) => (
<div key={rowIndex} className="flex gap-1.5 justify-center">
{row.map(renderKey)}
</div>
))}
</div>
{/* Stats */}
<div className="flex justify-center gap-6 text-xs text-muted-foreground">
<span>
<strong className="text-foreground">{Object.keys(keyboardShortcuts).length}</strong> shortcuts
configured
</span>
<span>
<strong className="text-foreground">
{Object.keys(keyToShortcuts).length}
</strong>{" "}
keys in use
</span>
<span>
<strong className="text-foreground">
{KEYBOARD_ROWS.flat().length - Object.keys(keyToShortcuts).length}
</strong>{" "}
keys available
</span>
</div>
</div>
</TooltipProvider>
);
}
// Full shortcut reference panel with editing capability
interface ShortcutReferencePanelProps {
editable?: boolean;
}
export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePanelProps) {
const { keyboardShortcuts, setKeyboardShortcut, resetKeyboardShortcuts } = useAppStore();
const [editingShortcut, setEditingShortcut] = React.useState<keyof KeyboardShortcuts | null>(null);
const [keyValue, setKeyValue] = React.useState("");
const [modifiers, setModifiers] = React.useState({ shift: false, cmdCtrl: false, alt: false });
const [shortcutError, setShortcutError] = React.useState<string | null>(null);
const groupedShortcuts = React.useMemo(() => {
const groups: Record<string, Array<{ key: keyof KeyboardShortcuts; label: string; value: string }>> = {
navigation: [],
ui: [],
action: [],
};
(Object.entries(SHORTCUT_CATEGORIES) as [keyof KeyboardShortcuts, string][]).forEach(
([shortcut, category]) => {
groups[category].push({
key: shortcut,
label: SHORTCUT_LABELS[shortcut],
value: keyboardShortcuts[shortcut],
});
}
);
return groups;
}, [keyboardShortcuts]);
// Build the full shortcut string from key + modifiers
const buildShortcutString = React.useCallback((key: string, mods: typeof modifiers) => {
const parts: string[] = [];
if (mods.cmdCtrl) parts.push(isMac ? "Cmd" : "Ctrl");
if (mods.alt) parts.push(isMac ? "Opt" : "Alt");
if (mods.shift) parts.push("Shift");
parts.push(key.toUpperCase());
return parts.join("+");
}, []);
// Check for conflicts with other shortcuts
const checkConflict = React.useCallback((shortcutStr: string, currentKey: keyof KeyboardShortcuts) => {
const conflict = Object.entries(keyboardShortcuts).find(
([k, v]) => k !== currentKey && v.toUpperCase() === shortcutStr.toUpperCase()
);
return conflict ? SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] : null;
}, [keyboardShortcuts]);
const handleStartEdit = (key: keyof KeyboardShortcuts) => {
const currentValue = keyboardShortcuts[key];
const parsed = parseShortcut(currentValue);
setEditingShortcut(key);
setKeyValue(parsed.key);
setModifiers({
shift: parsed.shift || false,
cmdCtrl: parsed.cmdCtrl || false,
alt: parsed.alt || false,
});
setShortcutError(null);
};
const handleSaveShortcut = () => {
if (!editingShortcut || shortcutError || !keyValue) return;
const shortcutStr = buildShortcutString(keyValue, modifiers);
setKeyboardShortcut(editingShortcut, shortcutStr);
setEditingShortcut(null);
setKeyValue("");
setModifiers({ shift: false, cmdCtrl: false, alt: false });
setShortcutError(null);
};
const handleCancelEdit = () => {
setEditingShortcut(null);
setKeyValue("");
setModifiers({ shift: false, cmdCtrl: false, alt: false });
setShortcutError(null);
};
const handleKeyChange = (value: string, currentKey: keyof KeyboardShortcuts) => {
setKeyValue(value);
// Check for conflicts with full shortcut string
if (!value) {
setShortcutError("Key cannot be empty");
} else {
const shortcutStr = buildShortcutString(value, modifiers);
const conflictLabel = checkConflict(shortcutStr, currentKey);
if (conflictLabel) {
setShortcutError(`Already used by "${conflictLabel}"`);
} else {
setShortcutError(null);
}
}
};
const handleModifierChange = (modifier: keyof typeof modifiers, checked: boolean, currentKey: keyof KeyboardShortcuts) => {
// Enforce single modifier: when checking, uncheck all others (radio-button behavior)
const newModifiers = checked
? { shift: false, cmdCtrl: false, alt: false, [modifier]: true }
: { ...modifiers, [modifier]: false };
setModifiers(newModifiers);
// Recheck for conflicts
if (keyValue) {
const shortcutStr = buildShortcutString(keyValue, newModifiers);
const conflictLabel = checkConflict(shortcutStr, currentKey);
if (conflictLabel) {
setShortcutError(`Already used by "${conflictLabel}"`);
} else {
setShortcutError(null);
}
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !shortcutError && keyValue) {
handleSaveShortcut();
} else if (e.key === "Escape") {
handleCancelEdit();
}
};
const handleResetShortcut = (key: keyof KeyboardShortcuts) => {
setKeyboardShortcut(key, DEFAULT_KEYBOARD_SHORTCUTS[key]);
};
return (
<TooltipProvider>
<div className="space-y-4" data-testid="shortcut-reference-panel">
{editable && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => resetKeyboardShortcuts()}
className="gap-2 text-xs"
data-testid="reset-all-shortcuts-button"
>
<RotateCcw className="w-3 h-3" />
Reset All to Defaults
</Button>
</div>
)}
{Object.entries(groupedShortcuts).map(([category, shortcuts]) => {
const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS];
return (
<div key={category} className="space-y-2">
<h4 className={cn("text-sm font-semibold", colors.text)}>
{colors.label}
</h4>
<div className="grid grid-cols-2 gap-2">
{shortcuts.map(({ key, label, value }) => {
const isModified = keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
const isEditing = editingShortcut === key;
return (
<div
key={key}
className={cn(
"flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors",
isEditing ? "border-brand-500" : "border-sidebar-border",
editable && !isEditing && "hover:bg-sidebar-accent/20 cursor-pointer"
)}
onClick={() => editable && !isEditing && handleStartEdit(key)}
data-testid={`shortcut-row-${key}`}
>
<span className="text-sm text-foreground">{label}</span>
<div className="flex items-center gap-2">
{isEditing ? (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
{/* Modifier checkboxes */}
<div className="flex items-center gap-1.5 text-xs">
<div className="flex items-center gap-1">
<Checkbox
id={`mod-cmd-${key}`}
checked={modifiers.cmdCtrl}
onCheckedChange={(checked) => handleModifierChange("cmdCtrl", !!checked, key)}
className="h-3.5 w-3.5"
/>
<Label htmlFor={`mod-cmd-${key}`} className="text-xs text-muted-foreground cursor-pointer">
{isMac ? "⌘" : "Ctrl"}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-alt-${key}`}
checked={modifiers.alt}
onCheckedChange={(checked) => handleModifierChange("alt", !!checked, key)}
className="h-3.5 w-3.5"
/>
<Label htmlFor={`mod-alt-${key}`} className="text-xs text-muted-foreground cursor-pointer">
{isMac ? "⌥" : "Alt"}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-shift-${key}`}
checked={modifiers.shift}
onCheckedChange={(checked) => handleModifierChange("shift", !!checked, key)}
className="h-3.5 w-3.5"
/>
<Label htmlFor={`mod-shift-${key}`} className="text-xs text-muted-foreground cursor-pointer">
</Label>
</div>
</div>
<span className="text-muted-foreground">+</span>
<Input
value={keyValue}
onChange={(e) => handleKeyChange(e.target.value, key)}
onKeyDown={handleKeyDown}
className={cn(
"w-12 h-7 text-center font-mono text-xs uppercase",
shortcutError && "border-red-500 focus-visible:ring-red-500"
)}
placeholder="Key"
maxLength={1}
autoFocus
data-testid={`edit-shortcut-input-${key}`}
/>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-green-500/20 hover:text-green-400"
onClick={(e) => {
e.stopPropagation();
handleSaveShortcut();
}}
disabled={!!shortcutError || !keyValue}
data-testid={`save-shortcut-${key}`}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-red-500/20 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
handleCancelEdit();
}}
data-testid={`cancel-shortcut-${key}`}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<>
<kbd
className={cn(
"px-2 py-1 text-xs font-mono rounded border",
colors.bg,
colors.border,
colors.text
)}
>
{formatShortcut(value, true)}
</kbd>
{isModified && editable && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 hover:bg-yellow-500/20 hover:text-yellow-400"
onClick={(e) => {
e.stopPropagation();
handleResetShortcut(key);
}}
data-testid={`reset-shortcut-${key}`}
>
<RotateCcw className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]})
</TooltipContent>
</Tooltip>
)}
{isModified && !editable && (
<span className="w-2 h-2 rounded-full bg-yellow-500" />
)}
{editable && !isModified && (
<Edit2 className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
)}
</>
)}
</div>
</div>
);
})}
</div>
{editingShortcut && shortcutError && SHORTCUT_CATEGORIES[editingShortcut] === category && (
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
)}
</div>
);
})}
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,284 @@
"use client";
import { useState, useMemo } from "react";
import {
ChevronDown,
ChevronRight,
MessageSquare,
Wrench,
Zap,
AlertCircle,
CheckCircle2,
AlertTriangle,
Bug,
Info,
FileOutput,
Brain,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
parseLogOutput,
getLogTypeColors,
type LogEntry,
type LogEntryType,
} from "@/lib/log-parser";
interface LogViewerProps {
output: string;
className?: string;
}
const getLogIcon = (type: LogEntryType) => {
switch (type) {
case "prompt":
return <MessageSquare className="w-4 h-4" />;
case "tool_call":
return <Wrench className="w-4 h-4" />;
case "tool_result":
return <FileOutput className="w-4 h-4" />;
case "phase":
return <Zap className="w-4 h-4" />;
case "error":
return <AlertCircle className="w-4 h-4" />;
case "success":
return <CheckCircle2 className="w-4 h-4" />;
case "warning":
return <AlertTriangle className="w-4 h-4" />;
case "thinking":
return <Brain className="w-4 h-4" />;
case "debug":
return <Bug className="w-4 h-4" />;
default:
return <Info className="w-4 h-4" />;
}
};
interface LogEntryItemProps {
entry: LogEntry;
isExpanded: boolean;
onToggle: () => void;
}
function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
const colors = getLogTypeColors(entry.type);
const hasContent = entry.content.length > 100;
// Format content - detect and highlight JSON
const formattedContent = useMemo(() => {
const content = entry.content;
// Try to find and format JSON blocks
const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g;
let lastIndex = 0;
const parts: { type: "text" | "json"; content: string }[] = [];
let match;
while ((match = jsonRegex.exec(content)) !== null) {
// Add text before JSON
if (match.index > lastIndex) {
parts.push({
type: "text",
content: content.slice(lastIndex, match.index),
});
}
// Try to parse and format JSON
try {
const parsed = JSON.parse(match[1]);
parts.push({
type: "json",
content: JSON.stringify(parsed, null, 2),
});
} catch {
// Not valid JSON, treat as text
parts.push({ type: "text", content: match[1] });
}
lastIndex = match.index + match[1].length;
}
// Add remaining text
if (lastIndex < content.length) {
parts.push({ type: "text", content: content.slice(lastIndex) });
}
return parts.length > 0 ? parts : [{ type: "text" as const, content }];
}, [entry.content]);
return (
<div
className={cn(
"rounded-lg border-l-4 transition-all duration-200",
colors.bg,
colors.border,
"hover:brightness-110"
)}
data-testid={`log-entry-${entry.type}`}
>
<button
onClick={onToggle}
className="w-full px-3 py-2 flex items-center gap-2 text-left"
data-testid={`log-entry-toggle-${entry.id}`}
>
{hasContent ? (
isExpanded ? (
<ChevronDown className="w-4 h-4 text-zinc-400 flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-zinc-400 flex-shrink-0" />
)
) : (
<span className="w-4 flex-shrink-0" />
)}
<span className={cn("flex-shrink-0", colors.icon)}>
{getLogIcon(entry.type)}
</span>
<span
className={cn(
"text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0",
colors.badge
)}
data-testid="log-entry-badge"
>
{entry.title}
</span>
<span className="text-xs text-zinc-400 truncate flex-1 ml-2">
{!isExpanded &&
entry.content.slice(0, 80) +
(entry.content.length > 80 ? "..." : "")}
</span>
</button>
{(isExpanded || !hasContent) && (
<div
className="px-4 pb-3 pt-1"
data-testid={`log-entry-content-${entry.id}`}
>
<div className="font-mono text-xs space-y-1">
{formattedContent.map((part, index) => (
<div key={index}>
{part.type === "json" ? (
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto text-xs text-primary">
{part.content}
</pre>
) : (
<pre
className={cn(
"whitespace-pre-wrap break-words",
colors.text
)}
>
{part.content}
</pre>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
export function LogViewer({ output, className }: LogViewerProps) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const entries = useMemo(() => parseLogOutput(output), [output]);
const toggleEntry = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const expandAll = () => {
setExpandedIds(new Set(entries.map((e) => e.id)));
};
const collapseAll = () => {
setExpandedIds(new Set());
};
if (entries.length === 0) {
return (
<div className="flex items-center justify-center p-8 text-muted-foreground">
<div className="text-center">
<Info className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No log entries yet. Logs will appear here as the process runs.</p>
{output && output.trim() && (
<div className="mt-4 p-3 bg-zinc-900/50 rounded text-xs font-mono text-left max-h-40 overflow-auto">
<pre className="whitespace-pre-wrap">{output}</pre>
</div>
)}
</div>
</div>
);
}
// Count entries by type
const typeCounts = entries.reduce((acc, entry) => {
acc[entry.type] = (acc[entry.type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
return (
<div className={cn("flex flex-col gap-2", className)}>
{/* Header with controls */}
<div className="flex items-center justify-between px-1" data-testid="log-viewer-header">
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(typeCounts).map(([type, count]) => {
const colors = getLogTypeColors(type as LogEntryType);
return (
<span
key={type}
className={cn(
"text-xs px-2 py-0.5 rounded-full",
colors.badge
)}
data-testid={`log-type-count-${type}`}
>
{type}: {count}
</span>
);
})}
</div>
<div className="flex items-center gap-1">
<button
onClick={expandAll}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
data-testid="log-expand-all"
>
Expand All
</button>
<button
onClick={collapseAll}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
data-testid="log-collapse-all"
>
Collapse All
</button>
</div>
</div>
{/* Log entries */}
<div className="space-y-2" data-testid="log-entries-container">
{entries.map((entry) => (
<LogEntryItem
key={entry.id}
entry={entry}
isExpanded={expandedIds.has(entry.id)}
onToggle={() => toggleEntry(entry.id)}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import ReactMarkdown from "react-markdown";
import { cn } from "@/lib/utils";
interface MarkdownProps {
children: string;
className?: string;
}
/**
* Reusable Markdown component for rendering markdown content
* Theme-aware styling that adapts to all predefined themes
*/
export function Markdown({ children, className }: MarkdownProps) {
return (
<div
className={cn(
"prose prose-sm prose-invert max-w-none",
// Headings
"[&_h1]:text-xl [&_h1]:text-foreground [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2",
"[&_h2]:text-lg [&_h2]:text-foreground [&_h2]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2",
"[&_h3]:text-base [&_h3]:text-foreground [&_h3]:font-semibold [&_h3]:mt-3 [&_h3]:mb-2",
"[&_h4]:text-sm [&_h4]:text-foreground [&_h4]:font-semibold [&_h4]:mt-2 [&_h4]:mb-1",
// Paragraphs
"[&_p]:text-foreground-secondary [&_p]:leading-relaxed [&_p]:my-2",
// Lists
"[&_ul]:my-2 [&_ul]:pl-4 [&_ol]:my-2 [&_ol]:pl-4",
"[&_li]:text-foreground-secondary [&_li]:my-0.5",
// Code
"[&_code]:text-chart-2 [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm",
"[&_pre]:bg-card [&_pre]:border [&_pre]:border-border [&_pre]:rounded-lg [&_pre]:my-2 [&_pre]:p-3 [&_pre]:overflow-x-auto",
"[&_pre_code]:bg-transparent [&_pre_code]:p-0",
// Strong/Bold
"[&_strong]:text-foreground [&_strong]:font-semibold",
// Links
"[&_a]:text-brand-500 [&_a]:no-underline hover:[&_a]:underline",
// Blockquotes
"[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground [&_blockquote]:italic [&_blockquote]:my-2",
// Horizontal rules
"[&_hr]:border-border [&_hr]:my-4",
className
)}
>
<ReactMarkdown>{children}</ReactMarkdown>
</div>
);
}

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,27 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ComponentRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
<SliderPrimitive.Range className="slider-range absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -0,0 +1,71 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px] border border-border",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200 cursor-pointer",
"text-foreground/70 hover:text-foreground hover:bg-accent",
"data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:shadow-md data-[state=active]:border-primary/50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:outline-1",
"disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input min-h-[80px] w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,290 @@
"use client";
import { useRef, useCallback, useMemo } from "react";
import { cn } from "@/lib/utils";
interface XmlSyntaxEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
"data-testid"?: string;
}
// Tokenize XML content into parts for highlighting
interface Token {
type:
| "tag-bracket"
| "tag-name"
| "attribute-name"
| "attribute-equals"
| "attribute-value"
| "text"
| "comment"
| "cdata"
| "doctype";
value: string;
}
function tokenizeXml(text: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < text.length) {
// Comment: <!-- ... -->
if (text.slice(i, i + 4) === "<!--") {
const end = text.indexOf("-->", i + 4);
if (end !== -1) {
tokens.push({ type: "comment", value: text.slice(i, end + 3) });
i = end + 3;
continue;
}
}
// CDATA: <![CDATA[ ... ]]>
if (text.slice(i, i + 9) === "<![CDATA[") {
const end = text.indexOf("]]>", i + 9);
if (end !== -1) {
tokens.push({ type: "cdata", value: text.slice(i, end + 3) });
i = end + 3;
continue;
}
}
// DOCTYPE: <!DOCTYPE ... >
if (text.slice(i, i + 9).toUpperCase() === "<!DOCTYPE") {
const end = text.indexOf(">", i + 9);
if (end !== -1) {
tokens.push({ type: "doctype", value: text.slice(i, end + 1) });
i = end + 1;
continue;
}
}
// Tag: < ... >
if (text[i] === "<") {
// Find the end of the tag
let tagEnd = i + 1;
let inString: string | null = null;
while (tagEnd < text.length) {
const char = text[tagEnd];
if (inString) {
if (char === inString && text[tagEnd - 1] !== "\\") {
inString = null;
}
} else {
if (char === '"' || char === "'") {
inString = char;
} else if (char === ">") {
tagEnd++;
break;
}
}
tagEnd++;
}
const tagContent = text.slice(i, tagEnd);
const tagTokens = tokenizeTag(tagContent);
tokens.push(...tagTokens);
i = tagEnd;
continue;
}
// Text content between tags
const nextTag = text.indexOf("<", i);
if (nextTag === -1) {
tokens.push({ type: "text", value: text.slice(i) });
break;
} else if (nextTag > i) {
tokens.push({ type: "text", value: text.slice(i, nextTag) });
i = nextTag;
}
}
return tokens;
}
function tokenizeTag(tag: string): Token[] {
const tokens: Token[] = [];
let i = 0;
// Opening bracket (< or </ or <?)
if (tag.startsWith("</")) {
tokens.push({ type: "tag-bracket", value: "</" });
i = 2;
} else if (tag.startsWith("<?")) {
tokens.push({ type: "tag-bracket", value: "<?" });
i = 2;
} else {
tokens.push({ type: "tag-bracket", value: "<" });
i = 1;
}
// Skip whitespace
while (i < tag.length && /\s/.test(tag[i])) {
tokens.push({ type: "text", value: tag[i] });
i++;
}
// Tag name
let tagName = "";
while (i < tag.length && /[a-zA-Z0-9_:-]/.test(tag[i])) {
tagName += tag[i];
i++;
}
if (tagName) {
tokens.push({ type: "tag-name", value: tagName });
}
// Attributes and closing
while (i < tag.length) {
// Skip whitespace
if (/\s/.test(tag[i])) {
let ws = "";
while (i < tag.length && /\s/.test(tag[i])) {
ws += tag[i];
i++;
}
tokens.push({ type: "text", value: ws });
continue;
}
// Closing bracket
if (tag[i] === ">" || tag.slice(i, i + 2) === "/>" || tag.slice(i, i + 2) === "?>") {
tokens.push({ type: "tag-bracket", value: tag.slice(i) });
break;
}
// Attribute name
let attrName = "";
while (i < tag.length && /[a-zA-Z0-9_:-]/.test(tag[i])) {
attrName += tag[i];
i++;
}
if (attrName) {
tokens.push({ type: "attribute-name", value: attrName });
}
// Skip whitespace around =
while (i < tag.length && /\s/.test(tag[i])) {
tokens.push({ type: "text", value: tag[i] });
i++;
}
// Equals sign
if (tag[i] === "=") {
tokens.push({ type: "attribute-equals", value: "=" });
i++;
}
// Skip whitespace after =
while (i < tag.length && /\s/.test(tag[i])) {
tokens.push({ type: "text", value: tag[i] });
i++;
}
// Attribute value
if (tag[i] === '"' || tag[i] === "'") {
const quote = tag[i];
let value = quote;
i++;
while (i < tag.length && tag[i] !== quote) {
value += tag[i];
i++;
}
if (i < tag.length) {
value += tag[i];
i++;
}
tokens.push({ type: "attribute-value", value });
}
}
return tokens;
}
export function XmlSyntaxEditor({
value,
onChange,
placeholder,
className,
"data-testid": testId,
}: XmlSyntaxEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
// Sync scroll between textarea and highlight layer
const handleScroll = useCallback(() => {
if (textareaRef.current && highlightRef.current) {
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
}
}, []);
// Handle tab key for indentation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Tab") {
e.preventDefault();
const textarea = e.currentTarget;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newValue =
value.substring(0, start) + " " + value.substring(end);
onChange(newValue);
// Reset cursor position after state update
requestAnimationFrame(() => {
textarea.selectionStart = textarea.selectionEnd = start + 2;
});
}
},
[value, onChange]
);
// Memoize the highlighted content
const highlightedContent = useMemo(() => {
const tokens = tokenizeXml(value);
return tokens.map((token, index) => {
const className = `xml-${token.type}`;
// React handles escaping automatically, just render the raw value
return (
<span key={index} className={className}>
{token.value}
</span>
);
});
}, [value]);
return (
<div className={cn("relative w-full h-full xml-editor", className)}>
{/* Syntax highlighted layer (read-only, behind textarea) */}
<div
ref={highlightRef}
className="absolute inset-0 overflow-auto pointer-events-none font-mono text-sm p-4 whitespace-pre-wrap break-words"
aria-hidden="true"
>
{value ? (
<code className="xml-highlight">{highlightedContent}</code>
) : (
<span className="text-muted-foreground opacity-50">{placeholder}</span>
)}
</div>
{/* Actual textarea (transparent text, handles input) */}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onScroll={handleScroll}
onKeyDown={handleKeyDown}
placeholder=""
spellCheck={false}
className="absolute inset-0 w-full h-full font-mono text-sm p-4 bg-transparent resize-none focus:outline-none text-transparent caret-foreground selection:bg-primary/30"
data-testid={testId}
/>
</div>
);
}

View File

@@ -0,0 +1,364 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2, List, FileText, GitBranch } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { LogViewer } from "@/components/ui/log-viewer";
import { GitDiffPanel } from "@/components/ui/git-diff-panel";
import { useAppStore } from "@/store/app-store";
import type { AutoModeEvent } from "@/types/electron";
interface AgentOutputModalProps {
open: boolean;
onClose: () => void;
featureDescription: string;
featureId: string;
/** The status of the feature - used to determine if spinner should be shown */
featureStatus?: string;
/** Called when a number key (0-9) is pressed while the modal is open */
onNumberKeyPress?: (key: string) => void;
}
type ViewMode = "parsed" | "raw" | "changes";
export function AgentOutputModal({
open,
onClose,
featureDescription,
featureId,
featureStatus,
onNumberKeyPress,
}: AgentOutputModalProps) {
const [output, setOutput] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>("parsed");
const [projectPath, setProjectPath] = useState<string>("");
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const projectPathRef = useRef<string>("");
const useWorktrees = useAppStore((state) => state.useWorktrees);
// Auto-scroll to bottom when output changes
useEffect(() => {
if (autoScrollRef.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [output]);
// Load existing output from file
useEffect(() => {
if (!open) return;
const loadOutput = async () => {
const api = getElectronAPI();
if (!api) return;
setIsLoading(true);
try {
// Get current project path from store (we'll need to pass this)
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) {
setIsLoading(false);
return;
}
projectPathRef.current = currentProject.path;
setProjectPath(currentProject.path);
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(
currentProject.path,
featureId
);
if (result.success) {
setOutput(result.content || "");
} else {
setOutput("");
}
} else {
setOutput("");
}
} catch (error) {
console.error("Failed to load output:", error);
setOutput("");
} finally {
setIsLoading(false);
}
};
loadOutput();
}, [open, featureId]);
// Save output to file
const saveOutput = async (newContent: string) => {
if (!projectPathRef.current) return;
const api = getElectronAPI();
if (!api) return;
try {
// Use features API - agent output is stored in features/{id}/agent-output.md
// We need to write it directly since there's no updateAgentOutput method
// The context-manager handles this on the backend, but for frontend edits we write directly
const outputPath = `${projectPathRef.current}/.automaker/features/${featureId}/agent-output.md`;
await api.writeFile(outputPath, newContent);
} catch (error) {
console.error("Failed to save output:", error);
}
};
// Listen to auto mode events and update output
useEffect(() => {
if (!open) return;
const api = getElectronAPI();
if (!api?.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event) => {
// Filter events for this specific feature only (skip events without featureId)
if ("featureId" in event && event.featureId !== featureId) {
return;
}
let newContent = "";
switch (event.type) {
case "auto_mode_progress":
newContent = event.content || "";
break;
case "auto_mode_tool":
const toolName = event.tool || "Unknown Tool";
const toolInput = event.input
? JSON.stringify(event.input, null, 2)
: "";
newContent = `\n🔧 Tool: ${toolName}\n${
toolInput ? `Input: ${toolInput}` : ""
}`;
break;
case "auto_mode_phase":
const phaseEmoji =
event.phase === "planning"
? "📋"
: event.phase === "action"
? "⚡"
: "✅";
newContent = `\n${phaseEmoji} ${event.message}\n`;
break;
case "auto_mode_error":
newContent = `\n❌ Error: ${event.error}\n`;
break;
case "auto_mode_ultrathink_preparation":
// Format thinking level preparation information
let prepContent = `\n🧠 Ultrathink Preparation\n`;
if (event.warnings && event.warnings.length > 0) {
prepContent += `\n⚠ Warnings:\n`;
event.warnings.forEach((warning: string) => {
prepContent += `${warning}\n`;
});
}
if (event.recommendations && event.recommendations.length > 0) {
prepContent += `\n💡 Recommendations:\n`;
event.recommendations.forEach((rec: string) => {
prepContent += `${rec}\n`;
});
}
if (event.estimatedCost !== undefined) {
prepContent += `\n💰 Estimated Cost: ~$${event.estimatedCost.toFixed(
2
)} per execution\n`;
}
if (event.estimatedTime) {
prepContent += `\n⏱ Estimated Time: ${event.estimatedTime}\n`;
}
newContent = prepContent;
break;
case "auto_mode_feature_complete":
const emoji = event.passes ? "✅" : "⚠️";
newContent = `\n${emoji} Task completed: ${event.message}\n`;
// Close the modal when the feature is verified (passes = true)
if (event.passes) {
// Small delay to show the completion message before closing
setTimeout(() => {
onClose();
}, 1500);
}
break;
}
if (newContent) {
setOutput((prev) => {
const updated = prev + newContent;
saveOutput(updated);
return updated;
});
}
});
return () => {
unsubscribe();
};
}, [open, featureId]);
// Handle scroll to detect if user scrolled up
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
autoScrollRef.current = isAtBottom;
};
// Handle number key presses while modal is open
useEffect(() => {
if (!open || !onNumberKeyPress) return;
const handleKeyDown = (event: KeyboardEvent) => {
// Check if a number key (0-9) was pressed without modifiers
if (
!event.ctrlKey &&
!event.altKey &&
!event.metaKey &&
/^[0-9]$/.test(event.key)
) {
event.preventDefault();
onNumberKeyPress(event.key);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [open, onNumberKeyPress]);
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
data-testid="agent-output-modal"
>
<DialogHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
{featureStatus !== "verified" &&
featureStatus !== "waiting_approval" && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
Agent Output
</DialogTitle>
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<button
onClick={() => setViewMode("parsed")}
className={`flex items-center gap-1.5 px-3 py-1.5 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.5 h-3.5" />
Logs
</button>
<button
onClick={() => setViewMode("changes")}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === "changes"
? "bg-primary/20 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
data-testid="view-mode-changes"
>
<GitBranch className="w-3.5 h-3.5" />
Changes
</button>
<button
onClick={() => setViewMode("raw")}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
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.5 h-3.5" />
Raw
</button>
</div>
</div>
<DialogDescription
className="mt-1 max-h-24 overflow-y-auto break-words"
data-testid="agent-output-description"
>
{featureDescription}
</DialogDescription>
</DialogHeader>
{viewMode === "changes" ? (
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? (
<GitDiffPanel
projectPath={projectPath}
featureId={featureId}
compact={false}
useWorktrees={useWorktrees}
className="border-0 rounded-lg"
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Loading...
</div>
)}
</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-[400px] max-h-[60vh] scrollbar-visible"
>
{isLoading && !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Loading output...
</div>
) : !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
No output yet. The agent will stream output here as it works.
</div>
) : viewMode === "parsed" ? (
<LogViewer output={output} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">
{output}
</div>
)}
</div>
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
{autoScrollRef.current
? "Auto-scrolling enabled"
: "Scroll to bottom to enable auto-scroll"}
</div>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,488 @@
"use client";
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";
import {
FileText,
FolderOpen,
Terminal,
CheckCircle,
XCircle,
Loader2,
Play,
File,
Pencil,
Wrench,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron";
interface ToolResult {
success: boolean;
output?: string;
error?: string;
timestamp: Date;
}
interface ToolExecution {
tool: string;
input: string;
result: ToolResult | null;
isRunning: boolean;
}
export function AgentToolsView() {
const { currentProject } = useAppStore();
const api = getElectronAPI();
// Read File Tool State
const [readFilePath, setReadFilePath] = useState("");
const [readFileResult, setReadFileResult] = useState<ToolResult | null>(null);
const [isReadingFile, setIsReadingFile] = useState(false);
// Write File Tool State
const [writeFilePath, setWriteFilePath] = useState("");
const [writeFileContent, setWriteFileContent] = useState("");
const [writeFileResult, setWriteFileResult] = useState<ToolResult | null>(
null
);
const [isWritingFile, setIsWritingFile] = useState(false);
// Terminal Tool State
const [terminalCommand, setTerminalCommand] = useState("ls");
const [terminalResult, setTerminalResult] = useState<ToolResult | null>(null);
const [isRunningCommand, setIsRunningCommand] = useState(false);
// Execute Read File
const handleReadFile = useCallback(async () => {
if (!readFilePath.trim()) return;
setIsReadingFile(true);
setReadFileResult(null);
try {
// Simulate agent requesting file read
console.log(`[Agent Tool] Requesting to read file: ${readFilePath}`);
const result = await api.readFile(readFilePath);
if (result.success) {
setReadFileResult({
success: true,
output: result.content,
timestamp: new Date(),
});
console.log(`[Agent Tool] File read successful: ${readFilePath}`);
} else {
setReadFileResult({
success: false,
error: result.error || "Failed to read file",
timestamp: new Date(),
});
console.log(`[Agent Tool] File read failed: ${result.error}`);
}
} catch (error) {
setReadFileResult({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
timestamp: new Date(),
});
} finally {
setIsReadingFile(false);
}
}, [readFilePath, api]);
// Execute Write File
const handleWriteFile = useCallback(async () => {
if (!writeFilePath.trim() || !writeFileContent.trim()) return;
setIsWritingFile(true);
setWriteFileResult(null);
try {
// Simulate agent requesting file write
console.log(`[Agent Tool] Requesting to write file: ${writeFilePath}`);
const result = await api.writeFile(writeFilePath, writeFileContent);
if (result.success) {
setWriteFileResult({
success: true,
output: `File written successfully: ${writeFilePath}`,
timestamp: new Date(),
});
console.log(`[Agent Tool] File write successful: ${writeFilePath}`);
} else {
setWriteFileResult({
success: false,
error: result.error || "Failed to write file",
timestamp: new Date(),
});
console.log(`[Agent Tool] File write failed: ${result.error}`);
}
} catch (error) {
setWriteFileResult({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
timestamp: new Date(),
});
} finally {
setIsWritingFile(false);
}
}, [writeFilePath, writeFileContent, api]);
// Execute Terminal Command
const handleRunCommand = useCallback(async () => {
if (!terminalCommand.trim()) return;
setIsRunningCommand(true);
setTerminalResult(null);
try {
// Simulate agent requesting terminal command execution
console.log(`[Agent Tool] Requesting to run command: ${terminalCommand}`);
// In mock mode, simulate terminal output
// In real Electron mode, this would use child_process
const mockOutputs: Record<string, string> = {
ls: "app_spec.txt\nfeatures\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json",
pwd: currentProject?.path || "/Users/demo/project",
"echo hello": "hello",
whoami: "automaker-agent",
date: new Date().toString(),
"cat package.json":
'{\n "name": "demo-project",\n "version": "1.0.0"\n}',
};
// Simulate command execution delay
await new Promise((resolve) => setTimeout(resolve, 500));
const output =
mockOutputs[terminalCommand.toLowerCase()] ||
`Command executed: ${terminalCommand}\n(Mock output - real execution requires Electron mode)`;
setTerminalResult({
success: true,
output: output,
timestamp: new Date(),
});
console.log(
`[Agent Tool] Command executed successfully: ${terminalCommand}`
);
} catch (error) {
setTerminalResult({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
timestamp: new Date(),
});
} finally {
setIsRunningCommand(false);
}
}, [terminalCommand, currentProject]);
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="agent-tools-no-project"
>
<div className="text-center">
<Wrench className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project Selected</h2>
<p className="text-muted-foreground">
Open or create a project to test agent tools.
</p>
</div>
</div>
);
}
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>
<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>
<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>
</div>
</div>
);
}

View File

@@ -0,0 +1,804 @@
"use client";
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useAppStore } from "@/store/app-store";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ImageDropZone } from "@/components/ui/image-drop-zone";
import {
Bot,
Send,
User,
Loader2,
Sparkles,
Wrench,
Trash2,
PanelLeftClose,
PanelLeft,
Paperclip,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useElectronAgent } from "@/hooks/use-electron-agent";
import { SessionManager } from "@/components/session-manager";
import { Markdown } from "@/components/ui/markdown";
import type { ImageAttachment } from "@/store/app-store";
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
export function AgentView() {
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [input, setInput] = useState("");
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [showImageDropZone, setShowImageDropZone] = useState(false);
const [currentTool, setCurrentTool] = useState<string | null>(null);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [showSessionManager, setShowSessionManager] = useState(true);
const [isDragOver, setIsDragOver] = useState(false);
// Track if initial session has been loaded
const initialSessionLoadedRef = useRef(false);
// Scroll management for auto-scroll
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
// Input ref for auto-focus
const inputRef = useRef<HTMLInputElement>(null);
// Ref for quick create session function from SessionManager
const quickCreateSessionRef = useRef<(() => Promise<void>) | null>(null);
// Use the Electron agent hook (only if we have a session)
const {
messages,
isProcessing,
isConnected,
sendMessage,
clearHistory,
error: agentError,
} = useElectronAgent({
sessionId: currentSessionId || "",
workingDirectory: currentProject?.path,
onToolUse: (toolName) => {
setCurrentTool(toolName);
setTimeout(() => setCurrentTool(null), 2000);
},
});
// Handle session selection with persistence
const handleSelectSession = useCallback((sessionId: string | null) => {
setCurrentSessionId(sessionId);
// Persist the selection for this project
if (currentProject?.path) {
setLastSelectedSession(currentProject.path, sessionId);
}
}, [currentProject?.path, setLastSelectedSession]);
// Restore last selected session when switching to Agent view or when project changes
useEffect(() => {
if (!currentProject?.path) {
// No project, reset
setCurrentSessionId(null);
initialSessionLoadedRef.current = false;
return;
}
// Only restore once per project
if (initialSessionLoadedRef.current) return;
initialSessionLoadedRef.current = true;
const lastSessionId = getLastSelectedSession(currentProject.path);
if (lastSessionId) {
console.log("[AgentView] Restoring last selected session:", lastSessionId);
setCurrentSessionId(lastSessionId);
}
}, [currentProject?.path, getLastSelectedSession]);
// Reset initialSessionLoadedRef when project changes
useEffect(() => {
initialSessionLoadedRef.current = false;
}, [currentProject?.path]);
const handleSend = useCallback(async () => {
if ((!input.trim() && selectedImages.length === 0) || isProcessing) return;
const messageContent = input;
const messageImages = selectedImages;
setInput("");
setSelectedImages([]);
setShowImageDropZone(false);
await sendMessage(messageContent, messageImages);
}, [input, selectedImages, isProcessing, sendMessage]);
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
setSelectedImages(images);
}, []);
const toggleImageDropZone = useCallback(() => {
setShowImageDropZone(!showImageDropZone);
}, [showImageDropZone]);
// Helper function to convert file to base64
const fileToBase64 = useCallback((file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Failed to read file as base64"));
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
reader.readAsDataURL(file);
});
}, []);
// Process dropped files
const processDroppedFiles = useCallback(
async (files: FileList) => {
if (isProcessing) return;
const ACCEPTED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_FILES = 5;
const newImages: ImageAttachment[] = [];
const errors: string[] = [];
for (const file of Array.from(files)) {
// Validate file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
errors.push(
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
);
continue;
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
const maxSizeMB = MAX_FILE_SIZE / (1024 * 1024);
errors.push(
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
);
continue;
}
// Check if we've reached max files
if (newImages.length + selectedImages.length >= MAX_FILES) {
errors.push(`Maximum ${MAX_FILES} images allowed.`);
break;
}
try {
const base64 = await fileToBase64(file);
const imageAttachment: ImageAttachment = {
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
data: base64,
mimeType: file.type,
filename: file.name,
size: file.size,
};
newImages.push(imageAttachment);
} catch (error) {
errors.push(`${file.name}: Failed to process image.`);
}
}
if (errors.length > 0) {
console.warn("Image upload errors:", errors);
}
if (newImages.length > 0) {
setSelectedImages((prev) => [...prev, ...newImages]);
}
},
[isProcessing, selectedImages, fileToBase64]
);
// Remove individual image
const removeImage = useCallback((imageId: string) => {
setSelectedImages((prev) => prev.filter((img) => img.id !== imageId));
}, []);
// Drag and drop handlers for the input area
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (isProcessing || !isConnected) return;
console.log(
"[agent-view] Drag enter types:",
Array.from(e.dataTransfer.types)
);
// Check if dragged items contain files
if (e.dataTransfer.types.includes("Files")) {
setIsDragOver(true);
}
},
[isProcessing, isConnected]
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set dragOver to false if we're leaving the input container
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
setIsDragOver(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (isProcessing || !isConnected) return;
console.log("[agent-view] Drop event:", {
filesCount: e.dataTransfer.files.length,
itemsCount: e.dataTransfer.items.length,
types: Array.from(e.dataTransfer.types),
});
// Check if we have files
const files = e.dataTransfer.files;
if (files && files.length > 0) {
console.log("[agent-view] Processing files from dataTransfer.files");
processDroppedFiles(files);
return;
}
// Handle file paths (from screenshots or other sources)
// This is common on macOS when dragging screenshots
const items = e.dataTransfer.items;
if (items && items.length > 0) {
console.log("[agent-view] Processing items");
for (let i = 0; i < items.length; i++) {
const item = items[i];
console.log(`[agent-view] Item ${i}:`, {
kind: item.kind,
type: item.type,
});
if (item.kind === "file") {
const file = item.getAsFile();
if (file) {
console.log("[agent-view] Got file from item:", {
name: file.name,
type: file.type,
size: file.size,
});
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
processDroppedFiles(dataTransfer.files);
}
}
}
}
},
[isProcessing, isConnected, processDroppedFiles]
);
const handlePaste = useCallback(
async (e: React.ClipboardEvent) => {
// Check if clipboard contains files
const items = e.clipboardData?.items;
if (items) {
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
console.log("[agent-view] Paste item:", {
kind: item.kind,
type: item.type,
});
if (item.kind === "file") {
const file = item.getAsFile();
if (file && file.type.startsWith("image/")) {
e.preventDefault(); // Prevent default paste of file path
files.push(file);
}
}
}
if (files.length > 0) {
console.log(
"[agent-view] Processing pasted image files:",
files.length
);
const dataTransfer = new DataTransfer();
files.forEach((file) => dataTransfer.items.add(file));
await processDroppedFiles(dataTransfer.files);
}
}
},
[processDroppedFiles]
);
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleClearChat = async () => {
if (!confirm("Are you sure you want to clear this conversation?")) return;
await clearHistory();
};
// Scroll position detection
const checkIfUserIsAtBottom = useCallback(() => {
const container = messagesContainerRef.current;
if (!container) return;
const threshold = 50; // 50px threshold for "near bottom"
const isAtBottom =
container.scrollHeight - container.scrollTop - container.clientHeight <=
threshold;
setIsUserAtBottom(isAtBottom);
}, []);
// Scroll to bottom function
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const container = messagesContainerRef.current;
if (!container) return;
container.scrollTo({
top: container.scrollHeight,
behavior: behavior,
});
}, []);
// Handle scroll events
const handleScroll = useCallback(() => {
checkIfUserIsAtBottom();
}, [checkIfUserIsAtBottom]);
// Auto-scroll effect when messages change
useEffect(() => {
// Only auto-scroll if user was already at bottom
if (isUserAtBottom && messages.length > 0) {
// Use a small delay to ensure DOM is updated
setTimeout(() => {
scrollToBottom("smooth");
}, 100);
}
}, [messages, isUserAtBottom, scrollToBottom]);
// Initial scroll to bottom when session changes
useEffect(() => {
if (currentSessionId && messages.length > 0) {
// Scroll immediately without animation when switching sessions
setTimeout(() => {
scrollToBottom("auto");
setIsUserAtBottom(true);
}, 100);
}
}, [currentSessionId, scrollToBottom]);
// Auto-focus input when session is selected/changed
useEffect(() => {
if (currentSessionId && inputRef.current) {
// Small delay to ensure UI has updated
setTimeout(() => {
inputRef.current?.focus();
}, 200);
}
}, [currentSessionId]);
// Keyboard shortcuts for agent view
const agentShortcuts: KeyboardShortcut[] = useMemo(() => {
const shortcutsList: KeyboardShortcut[] = [];
// New session shortcut - only when in agent view with a project
if (currentProject) {
shortcutsList.push({
key: shortcuts.newSession,
action: () => {
if (quickCreateSessionRef.current) {
quickCreateSessionRef.current();
}
},
description: "Create new session",
});
}
return shortcutsList;
}, [currentProject, shortcuts]);
// Register keyboard shortcuts
useKeyboardShortcuts(agentShortcuts);
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="agent-view-no-project"
>
<div className="text-center">
<Sparkles className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project Selected</h2>
<p className="text-muted-foreground">
Open or create a project to start working with the AI agent.
</p>
</div>
</div>
);
}
// Show welcome message if no messages yet
const displayMessages =
messages.length === 0
? [
{
id: "welcome",
role: "assistant" as const,
content:
"Hello! I'm the Automaker Agent. I can help you build software autonomously. I can read and modify files in this project, run commands, and execute tests. What would you like to create today?",
timestamp: new Date().toISOString(),
},
]
: messages;
return (
<div
className="flex-1 flex overflow-hidden content-bg"
data-testid="agent-view"
>
{/* Session Manager Sidebar */}
{showSessionManager && currentProject && (
<div className="w-80 border-r flex-shrink-0">
<SessionManager
currentSessionId={currentSessionId}
onSelectSession={handleSelectSession}
projectPath={currentProject.path}
isCurrentSessionThinking={isProcessing}
onQuickCreateRef={quickCreateSessionRef}
/>
</div>
)}
{/* Chat Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => setShowSessionManager(!showSessionManager)}
className="h-8 w-8 p-0"
>
{showSessionManager ? (
<PanelLeftClose className="w-4 h-4" />
) : (
<PanelLeft className="w-4 h-4" />
)}
</Button>
<Bot className="w-5 h-5 text-primary" />
<div>
<h1 className="text-xl font-bold">AI Agent</h1>
<p className="text-sm text-muted-foreground">
{currentProject.name}
{currentSessionId && !isConnected && " · Connecting..."}
</p>
</div>
</div>
{/* Status indicators & actions */}
<div className="flex items-center gap-2">
{currentTool && (
<div className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
<Wrench className="w-3 h-3" />
<span>{currentTool}</span>
</div>
)}
{agentError && (
<span className="text-xs text-destructive">{agentError}</span>
)}
{currentSessionId && messages.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleClearChat}
disabled={isProcessing}
>
<Trash2 className="w-4 h-4 mr-1" />
Clear
</Button>
)}
</div>
</div>
{/* Messages */}
{!currentSessionId ? (
<div
className="flex-1 flex items-center justify-center"
data-testid="no-session-placeholder"
>
<div className="text-center">
<Bot className="w-12 h-12 text-muted-foreground mx-auto mb-4 opacity-50" />
<h2 className="text-lg font-semibold mb-2">
No Session Selected
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create or select a session to start chatting
</p>
<Button
onClick={() => setShowSessionManager(true)}
variant="outline"
>
<PanelLeft className="w-4 h-4 mr-2" />
{showSessionManager ? "View" : "Show"} Sessions
</Button>
</div>
</div>
) : (
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto p-4 space-y-4"
data-testid="message-list"
onScroll={handleScroll}
>
{displayMessages.map((message) => (
<div
key={message.id}
className={cn(
"flex gap-3",
message.role === "user" && "flex-row-reverse"
)}
>
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
message.role === "assistant" ? "bg-primary/10" : "bg-muted"
)}
>
{message.role === "assistant" ? (
<Bot className="w-4 h-4 text-primary" />
) : (
<User className="w-4 h-4" />
)}
</div>
<Card
className={cn(
"max-w-[80%] py-0",
message.role === "user"
? "bg-transparent border border-primary text-foreground"
: "border-l-4 border-primary bg-card"
)}
>
<CardContent className="px-3 py-2">
{message.role === "assistant" ? (
<Markdown className="text-sm text-primary prose-headings:text-primary prose-strong:text-primary prose-code:text-primary">
{message.content}
</Markdown>
) : (
<p className="text-sm whitespace-pre-wrap">
{message.content}
</p>
)}
<p
className={cn(
"text-xs mt-1",
message.role === "user"
? "text-muted-foreground"
: "text-primary/70"
)}
>
{new Date(message.timestamp).toLocaleTimeString()}
</p>
</CardContent>
</Card>
</div>
))}
{isProcessing && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<Bot className="w-4 h-4 text-primary" />
</div>
<Card className="border-l-4 border-primary bg-card py-0">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="text-sm text-primary">
Thinking...
</span>
</div>
</CardContent>
</Card>
</div>
)}
</div>
)}
{/* Input */}
{currentSessionId && (
<div className="border-t border-border p-4 space-y-3 bg-background">
{/* Image Drop Zone (when visible) */}
{showImageDropZone && (
<ImageDropZone
onImagesSelected={handleImagesSelected}
images={selectedImages}
maxFiles={5}
className="mb-3"
disabled={isProcessing || !isConnected}
/>
)}
{/* Text Input and Controls - with drag and drop support */}
<div
className={cn(
"flex gap-2 transition-all duration-200 rounded-lg",
isDragOver &&
"bg-primary/10 ring-2 ring-primary ring-offset-2 ring-offset-background"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className="flex-1 relative">
<Input
ref={inputRef}
placeholder={
isDragOver
? "Drop your images here..."
: "Describe what you want to build..."
}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress}
onPaste={handlePaste}
disabled={isProcessing || !isConnected}
data-testid="agent-input"
className={cn(
"bg-input border-border",
selectedImages.length > 0 &&
"border-primary/50 bg-primary/5",
isDragOver &&
"border-primary bg-primary/10"
)}
/>
{selectedImages.length > 0 && !isDragOver && (
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 text-xs text-primary-foreground bg-primary px-2 py-1 rounded">
{selectedImages.length} image
{selectedImages.length > 1 ? "s" : ""}
</div>
)}
{isDragOver && (
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 text-xs text-primary-foreground bg-primary px-2 py-1 rounded flex items-center gap-1">
<Paperclip className="w-3 h-3" />
Drop here
</div>
)}
</div>
{/* Image Attachment Button */}
<Button
variant="outline"
size="default"
onClick={toggleImageDropZone}
disabled={isProcessing || !isConnected}
className={cn(
showImageDropZone &&
"bg-primary/20 text-primary border-primary",
selectedImages.length > 0 && "border-primary"
)}
title="Attach images"
>
<Paperclip className="w-4 h-4" />
</Button>
{/* Send Button */}
<Button
onClick={handleSend}
disabled={
(!input.trim() && selectedImages.length === 0) ||
isProcessing ||
!isConnected
}
data-testid="send-message"
>
<Send className="w-4 h-4" />
</Button>
</div>
{/* Selected Images Preview */}
{selectedImages.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{selectedImages.length} image
{selectedImages.length > 1 ? "s" : ""} attached
</p>
<button
onClick={() => setSelectedImages([])}
className="text-xs text-muted-foreground hover:text-foreground"
disabled={isProcessing}
>
Clear all
</button>
</div>
<div className="flex flex-wrap gap-2">
{selectedImages.map((image) => (
<div
key={image.id}
className="relative group rounded-md border border-muted bg-muted/50 p-2 flex items-center space-x-2"
>
{/* Image thumbnail */}
<div className="w-8 h-8 rounded overflow-hidden bg-muted flex-shrink-0">
<img
src={image.data}
alt={image.filename}
className="w-full h-full object-cover"
/>
</div>
{/* Image info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate">
{image.filename}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(image.size)}
</p>
</div>
{/* Remove button */}
<button
onClick={() => removeImage(image.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive hover:text-destructive-foreground text-muted-foreground"
disabled={isProcessing}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}
// Helper function to format file size
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,251 @@
"use client";
import { useState } from "react";
import { useAppStore } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Plus,
MessageSquare,
Archive,
Trash2,
MoreVertical,
Search,
ChevronLeft,
ArchiveRestore,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
export function ChatHistory() {
const {
chatSessions,
currentProject,
currentChatSession,
chatHistoryOpen,
createChatSession,
setCurrentChatSession,
archiveChatSession,
unarchiveChatSession,
deleteChatSession,
setChatHistoryOpen,
} = useAppStore();
const [searchQuery, setSearchQuery] = useState("");
const [showArchived, setShowArchived] = useState(false);
if (!currentProject) {
return null;
}
// Filter sessions for current project
const projectSessions = chatSessions.filter(
(session) => session.projectId === currentProject.id
);
// Filter by search query and archived status
const filteredSessions = projectSessions.filter((session) => {
const matchesSearch = session.title
.toLowerCase()
.includes(searchQuery.toLowerCase());
const matchesArchivedStatus = showArchived
? session.archived
: !session.archived;
return matchesSearch && matchesArchivedStatus;
});
// Sort by most recently updated
const sortedSessions = filteredSessions.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
const handleCreateNewChat = () => {
createChatSession();
};
const handleSelectSession = (session: any) => {
setCurrentChatSession(session);
};
const handleArchiveSession = (sessionId: string, e: React.MouseEvent) => {
e.stopPropagation();
archiveChatSession(sessionId);
};
const handleUnarchiveSession = (sessionId: string, e: React.MouseEvent) => {
e.stopPropagation();
unarchiveChatSession(sessionId);
};
const handleDeleteSession = (sessionId: string, e: React.MouseEvent) => {
e.stopPropagation();
if (confirm("Are you sure you want to delete this chat session?")) {
deleteChatSession(sessionId);
}
};
return (
<div
className={cn(
"flex flex-col h-full bg-zinc-950/50 backdrop-blur-md border-r border-white/10 transition-all duration-200",
chatHistoryOpen ? "w-80" : "w-0 overflow-hidden"
)}
>
{chatHistoryOpen && (
<>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<div className="flex items-center gap-2">
<MessageSquare className="w-5 h-5" />
<h2 className="font-semibold">Chat History</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setChatHistoryOpen(false)}
>
<ChevronLeft className="w-4 h-4" />
</Button>
</div>
{/* New Chat Button */}
<div className="p-4 border-b">
<Button
onClick={handleCreateNewChat}
className="w-full justify-start gap-2"
variant="outline"
>
<Plus className="w-4 h-4" />
New Chat
</Button>
</div>
{/* Search */}
<div className="p-4 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search chats..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</div>
{/* Archive Toggle */}
<div className="px-4 py-2 border-b">
<Button
variant="ghost"
size="sm"
onClick={() => setShowArchived(!showArchived)}
className="w-full justify-start gap-2"
>
{showArchived ? (
<ArchiveRestore className="w-4 h-4" />
) : (
<Archive className="w-4 h-4" />
)}
{showArchived ? "Show Active" : "Show Archived"}
{showArchived && (
<Badge variant="outline" className="ml-auto">
{projectSessions.filter((s) => s.archived).length}
</Badge>
)}
</Button>
</div>
{/* Chat Sessions List */}
<div className="flex-1 overflow-y-auto">
{sortedSessions.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
{searchQuery ? (
<>No chats match your search</>
) : showArchived ? (
<>No archived chats</>
) : (
<>No active chats. Create your first chat to get started!</>
)}
</div>
) : (
<div className="p-2">
{sortedSessions.map((session) => (
<div
key={session.id}
className={cn(
"flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-accent transition-colors group",
currentChatSession?.id === session.id && "bg-accent"
)}
onClick={() => handleSelectSession(session)}
>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm truncate">
{session.title}
</h3>
<p className="text-xs text-muted-foreground truncate">
{session.messages.length} messages
</p>
<p className="text-xs text-muted-foreground">
{new Date(session.updatedAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
>
<MoreVertical className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{session.archived ? (
<DropdownMenuItem
onClick={(e) =>
handleUnarchiveSession(session.id, e)
}
>
<ArchiveRestore className="w-4 h-4 mr-2" />
Unarchive
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={(e) =>
handleArchiveSession(session.id, e)
}
>
<Archive className="w-4 h-4 mr-2" />
Archive
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleDeleteSession(session.id, e)}
className="text-destructive"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
)}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,301 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
File,
Folder,
FolderOpen,
ChevronRight,
ChevronDown,
RefreshCw,
Code,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface FileTreeNode {
name: string;
path: string;
isDirectory: boolean;
children?: FileTreeNode[];
isExpanded?: boolean;
}
const IGNORE_PATTERNS = [
"node_modules",
".git",
".next",
"dist",
"build",
".DS_Store",
"*.log",
];
const shouldIgnore = (name: string) => {
return IGNORE_PATTERNS.some((pattern) => {
if (pattern.startsWith("*")) {
return name.endsWith(pattern.slice(1));
}
return name === pattern;
});
};
export function CodeView() {
const { currentProject } = useAppStore();
const [fileTree, setFileTree] = useState<FileTreeNode[]>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set()
);
// Load directory tree
const loadTree = useCallback(async () => {
if (!currentProject) return;
setIsLoading(true);
try {
const api = getElectronAPI();
const result = await api.readdir(currentProject.path);
if (result.success && result.entries) {
const entries = result.entries
.filter((e) => !shouldIgnore(e.name))
.sort((a, b) => {
// Directories first
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
})
.map((e) => ({
name: e.name,
path: `${currentProject.path}/${e.name}`,
isDirectory: e.isDirectory,
}));
setFileTree(entries);
}
} catch (error) {
console.error("Failed to load file tree:", error);
} finally {
setIsLoading(false);
}
}, [currentProject]);
useEffect(() => {
loadTree();
}, [loadTree]);
// Load subdirectory
const loadSubdirectory = async (path: string): Promise<FileTreeNode[]> => {
try {
const api = getElectronAPI();
const result = await api.readdir(path);
if (result.success && result.entries) {
return result.entries
.filter((e) => !shouldIgnore(e.name))
.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
})
.map((e) => ({
name: e.name,
path: `${path}/${e.name}`,
isDirectory: e.isDirectory,
}));
}
} catch (error) {
console.error("Failed to load subdirectory:", error);
}
return [];
};
// Load file content
const loadFileContent = async (path: string) => {
try {
const api = getElectronAPI();
const result = await api.readFile(path);
if (result.success && result.content) {
setFileContent(result.content);
setSelectedFile(path);
}
} catch (error) {
console.error("Failed to load file:", error);
}
};
// Toggle folder expansion
const toggleFolder = async (node: FileTreeNode) => {
const newExpanded = new Set(expandedFolders);
if (expandedFolders.has(node.path)) {
newExpanded.delete(node.path);
} else {
newExpanded.add(node.path);
// Load children if not already loaded
if (!node.children) {
const children = await loadSubdirectory(node.path);
// Update the tree with children
const updateTree = (nodes: FileTreeNode[]): FileTreeNode[] => {
return nodes.map((n) => {
if (n.path === node.path) {
return { ...n, children };
}
if (n.children) {
return { ...n, children: updateTree(n.children) };
}
return n;
});
};
setFileTree(updateTree(fileTree));
}
}
setExpandedFolders(newExpanded);
};
// Render file tree node
const renderNode = (node: FileTreeNode, depth: number = 0) => {
const isExpanded = expandedFolders.has(node.path);
const isSelected = selectedFile === node.path;
return (
<div key={node.path}>
<div
className={cn(
"flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50",
isSelected && "bg-muted"
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => {
if (node.isDirectory) {
toggleFolder(node);
} else {
loadFileContent(node.path);
}
}}
data-testid={`file-tree-item-${node.name}`}
>
{node.isDirectory ? (
<>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
)}
{isExpanded ? (
<FolderOpen className="w-4 h-4 text-primary shrink-0" />
) : (
<Folder className="w-4 h-4 text-primary shrink-0" />
)}
</>
) : (
<>
<span className="w-4" />
<File className="w-4 h-4 text-muted-foreground shrink-0" />
</>
)}
<span className="text-sm truncate">{node.name}</span>
</div>
{node.isDirectory && isExpanded && node.children && (
<div>
{node.children.map((child) => renderNode(child, depth + 1))}
</div>
)}
</div>
);
};
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="code-view-no-project"
>
<p className="text-muted-foreground">No project selected</p>
</div>
);
}
if (isLoading) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="code-view-loading"
>
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="code-view"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-zinc-950/50 backdrop-blur-md">
<div className="flex items-center gap-3">
<Code className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">Code Explorer</h1>
<p className="text-sm text-muted-foreground">
{currentProject.name}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={loadTree}
data-testid="refresh-tree"
>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
{/* Split View */}
<div className="flex-1 flex overflow-hidden">
{/* File Tree */}
<div className="w-64 border-r overflow-y-auto" data-testid="file-tree">
<div className="p-2">{fileTree.map((node) => renderNode(node))}</div>
</div>
{/* Code Preview */}
<div className="flex-1 overflow-hidden">
{selectedFile ? (
<div className="h-full flex flex-col">
<div className="px-4 py-2 border-b bg-muted/30">
<p className="text-sm font-mono text-muted-foreground truncate">
{selectedFile.replace(currentProject.path, "")}
</p>
</div>
<Card className="flex-1 m-4 overflow-hidden">
<CardContent className="p-0 h-full">
<pre className="p-4 h-full overflow-auto text-sm font-mono whitespace-pre-wrap">
<code data-testid="code-content">{fileContent}</code>
</pre>
</CardContent>
</Card>
</div>
) : (
<div className="flex-1 flex items-center justify-center">
<p className="text-muted-foreground">
Select a file to view its contents
</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,699 @@
"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Card } from "@/components/ui/card";
import {
Plus,
RefreshCw,
FileText,
Image as ImageIcon,
Trash2,
Save,
Upload,
File,
X,
BookOpen,
} from "lucide-react";
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
interface ContextFile {
name: string;
type: "text" | "image";
content?: string;
path: string;
}
export function ContextView() {
const { currentProject } = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [contextFiles, setContextFiles] = useState<ContextFile[]>([]);
const [selectedFile, setSelectedFile] = useState<ContextFile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [editedContent, setEditedContent] = useState("");
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [newFileName, setNewFileName] = useState("");
const [newFileType, setNewFileType] = useState<"text" | "image">("text");
const [uploadedImageData, setUploadedImageData] = useState<string | null>(
null
);
const [newFileContent, setNewFileContent] = useState("");
const [isDropHovering, setIsDropHovering] = useState(false);
// Keyboard shortcuts for this view
const contextShortcuts: KeyboardShortcut[] = useMemo(
() => [
{
key: shortcuts.addContextFile,
action: () => setIsAddDialogOpen(true),
description: "Add new context file",
},
],
[shortcuts]
);
useKeyboardShortcuts(contextShortcuts);
// Get context directory path for user-added context files
const getContextPath = useCallback(() => {
if (!currentProject) return null;
return `${currentProject.path}/.automaker/context`;
}, [currentProject]);
// Determine if a file is an image based on extension
const isImageFile = (filename: string): boolean => {
const imageExtensions = [
".png",
".jpg",
".jpeg",
".gif",
".webp",
".svg",
".bmp",
];
const ext = filename.toLowerCase().substring(filename.lastIndexOf("."));
return imageExtensions.includes(ext);
};
// Load context files
const loadContextFiles = useCallback(async () => {
const contextPath = getContextPath();
if (!contextPath) return;
setIsLoading(true);
try {
const api = getElectronAPI();
// Ensure context directory exists
await api.mkdir(contextPath);
// Read directory contents
const result = await api.readdir(contextPath);
if (result.success && result.entries) {
const files: ContextFile[] = result.entries
.filter((entry) => entry.isFile)
.map((entry) => ({
name: entry.name,
type: isImageFile(entry.name) ? "image" : "text",
path: `${contextPath}/${entry.name}`,
}));
setContextFiles(files);
}
} catch (error) {
console.error("Failed to load context files:", error);
} finally {
setIsLoading(false);
}
}, [getContextPath]);
useEffect(() => {
loadContextFiles();
}, [loadContextFiles]);
// Load selected file content
const loadFileContent = useCallback(async (file: ContextFile) => {
try {
const api = getElectronAPI();
const result = await api.readFile(file.path);
if (result.success && result.content !== undefined) {
setEditedContent(result.content);
setSelectedFile({ ...file, content: result.content });
setHasChanges(false);
}
} catch (error) {
console.error("Failed to load file content:", error);
}
}, []);
// Select a file
const handleSelectFile = (file: ContextFile) => {
if (hasChanges) {
// Could add a confirmation dialog here
}
loadFileContent(file);
};
// Save current file
const saveFile = async () => {
if (!selectedFile) return;
setIsSaving(true);
try {
const api = getElectronAPI();
await api.writeFile(selectedFile.path, editedContent);
setSelectedFile({ ...selectedFile, content: editedContent });
setHasChanges(false);
} catch (error) {
console.error("Failed to save file:", error);
} finally {
setIsSaving(false);
}
};
// Handle content change
const handleContentChange = (value: string) => {
setEditedContent(value);
setHasChanges(true);
};
// Add new context file
const handleAddFile = async () => {
const contextPath = getContextPath();
if (!contextPath || !newFileName.trim()) return;
try {
const api = getElectronAPI();
let filename = newFileName.trim();
// Add default extension if not provided
if (newFileType === "text" && !filename.includes(".")) {
filename += ".md";
}
const filePath = `${contextPath}/${filename}`;
if (newFileType === "image" && uploadedImageData) {
// Write image data
await api.writeFile(filePath, uploadedImageData);
} else {
// Write text file with content (or empty if no content)
await api.writeFile(filePath, newFileContent);
}
setIsAddDialogOpen(false);
setNewFileName("");
setNewFileType("text");
setUploadedImageData(null);
setNewFileContent("");
setIsDropHovering(false);
await loadContextFiles();
} catch (error) {
console.error("Failed to add file:", error);
}
};
// Delete selected file
const handleDeleteFile = async () => {
if (!selectedFile) return;
try {
const api = getElectronAPI();
await api.deleteFile(selectedFile.path);
setIsDeleteDialogOpen(false);
setSelectedFile(null);
setEditedContent("");
setHasChanges(false);
await loadContextFiles();
} catch (error) {
console.error("Failed to delete file:", error);
}
};
// Handle image upload
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const base64 = event.target?.result as string;
setUploadedImageData(base64);
if (!newFileName) {
setNewFileName(file.name);
}
};
reader.readAsDataURL(file);
};
// Handle drag and drop for file upload
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
const contextPath = getContextPath();
if (!contextPath) return;
const api = getElectronAPI();
for (const file of files) {
const reader = new FileReader();
reader.onload = async (event) => {
const content = event.target?.result as string;
const filePath = `${contextPath}/${file.name}`;
await api.writeFile(filePath, content);
await loadContextFiles();
};
if (isImageFile(file.name)) {
reader.readAsDataURL(file);
} else {
reader.readAsText(file);
}
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
};
// Handle drag and drop for .txt and .md files in the add context dialog textarea
const handleTextAreaDrop = async (
e: React.DragEvent<HTMLTextAreaElement>
) => {
e.preventDefault();
e.stopPropagation();
setIsDropHovering(false);
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
const file = files[0]; // Only handle the first file
const fileName = file.name.toLowerCase();
// Only accept .txt and .md files
if (!fileName.endsWith(".txt") && !fileName.endsWith(".md")) {
console.warn("Only .txt and .md files are supported for drag and drop");
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
setNewFileContent(content);
// Auto-fill filename if empty
if (!newFileName) {
setNewFileName(file.name);
}
};
reader.readAsText(file);
};
const handleTextAreaDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDropHovering(true);
};
const handleTextAreaDragLeave = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDropHovering(false);
};
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="context-view-no-project"
>
<p className="text-muted-foreground">No project selected</p>
</div>
);
}
if (isLoading) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="context-view-loading"
>
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="context-view"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
<BookOpen className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">Context Files</h1>
<p className="text-sm text-muted-foreground">
Add context files to include in AI prompts
</p>
</div>
</div>
<div className="flex gap-2">
<HotkeyButton
size="sm"
onClick={() => setIsAddDialogOpen(true)}
hotkey={shortcuts.addContextFile}
hotkeyActive={false}
data-testid="add-context-file"
>
<Plus className="w-4 h-4 mr-2" />
Add File
</HotkeyButton>
</div>
</div>
{/* Main content area with file list and editor */}
<div
className="flex-1 flex overflow-hidden"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* Left Panel - File List */}
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
<div className="p-3 border-b border-border">
<h2 className="text-sm font-semibold text-muted-foreground">
Context Files ({contextFiles.length})
</h2>
</div>
<div
className="flex-1 overflow-y-auto p-2"
data-testid="context-file-list"
>
{contextFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-4">
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
No context files yet.
<br />
Drop files here or click Add File.
</p>
</div>
) : (
<div className="space-y-1">
{contextFiles.map((file) => (
<button
key={file.path}
onClick={() => handleSelectFile(file)}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors",
selectedFile?.path === file.path
? "bg-primary/20 text-foreground border border-primary/30"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
data-testid={`context-file-${file.name}`}
>
{file.type === "image" ? (
<ImageIcon className="w-4 h-4 flex-shrink-0" />
) : (
<FileText className="w-4 h-4 flex-shrink-0" />
)}
<span className="truncate text-sm">{file.name}</span>
</button>
))}
</div>
)}
</div>
</div>
{/* Right Panel - Editor/Preview */}
<div className="flex-1 flex flex-col overflow-hidden">
{selectedFile ? (
<>
{/* File toolbar */}
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
<div className="flex items-center gap-2">
{selectedFile.type === "image" ? (
<ImageIcon className="w-4 h-4 text-muted-foreground" />
) : (
<FileText className="w-4 h-4 text-muted-foreground" />
)}
<span className="text-sm font-medium">
{selectedFile.name}
</span>
</div>
<div className="flex gap-2">
{selectedFile.type === "text" && (
<Button
size="sm"
onClick={saveFile}
disabled={!hasChanges || isSaving}
data-testid="save-context-file"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? "Saving..." : hasChanges ? "Save" : "Saved"}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => setIsDeleteDialogOpen(true)}
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
data-testid="delete-context-file"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{/* Content area */}
<div className="flex-1 overflow-hidden p-4">
{selectedFile.type === "image" ? (
<div
className="h-full flex items-center justify-center bg-card rounded-lg"
data-testid="image-preview"
>
<img
src={editedContent}
alt={selectedFile.name}
className="max-w-full max-h-full object-contain"
/>
</div>
) : (
<Card className="h-full overflow-hidden">
<textarea
className="w-full h-full p-4 font-mono text-sm bg-transparent resize-none focus:outline-none"
value={editedContent}
onChange={(e) => handleContentChange(e.target.value)}
placeholder="Enter context content here..."
spellCheck={false}
data-testid="context-editor"
/>
</Card>
)}
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<File className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
<p className="text-foreground-secondary">
Select a file to view or edit
</p>
<p className="text-muted-foreground text-sm mt-1">
Or drop files here to add them
</p>
</div>
</div>
)}
</div>
</div>
{/* Add File Dialog */}
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogContent
data-testid="add-context-dialog"
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
>
<DialogHeader>
<DialogTitle>Add Context File</DialogTitle>
<DialogDescription>
Add a new text or image file to the context.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex gap-2">
<Button
variant={newFileType === "text" ? "default" : "outline"}
size="sm"
onClick={() => setNewFileType("text")}
data-testid="add-text-type"
>
<FileText className="w-4 h-4 mr-2" />
Text
</Button>
<Button
variant={newFileType === "image" ? "default" : "outline"}
size="sm"
onClick={() => setNewFileType("image")}
data-testid="add-image-type"
>
<ImageIcon className="w-4 h-4 mr-2" />
Image
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="filename">File Name</Label>
<Input
id="filename"
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
placeholder={
newFileType === "text" ? "context.md" : "image.png"
}
data-testid="new-file-name"
/>
</div>
{newFileType === "text" && (
<div className="space-y-2">
<Label htmlFor="context-content">Context Content</Label>
<div
className={cn(
"relative rounded-lg transition-colors",
isDropHovering && "ring-2 ring-primary"
)}
>
<textarea
id="context-content"
value={newFileContent}
onChange={(e) => setNewFileContent(e.target.value)}
onDrop={handleTextAreaDrop}
onDragOver={handleTextAreaDragOver}
onDragLeave={handleTextAreaDragLeave}
placeholder="Enter context content here or drag & drop a .txt or .md file..."
className={cn(
"w-full h-40 p-3 font-mono text-sm bg-background border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent",
isDropHovering && "border-primary bg-primary/10"
)}
spellCheck={false}
data-testid="new-file-content"
/>
{isDropHovering && (
<div className="absolute inset-0 flex items-center justify-center bg-primary/20 rounded-lg pointer-events-none">
<div className="flex flex-col items-center text-primary">
<Upload className="w-8 h-8 mb-2" />
<span className="text-sm font-medium">
Drop .txt or .md file here
</span>
</div>
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
Drag & drop .txt or .md files to import their content
</p>
</div>
)}
{newFileType === "image" && (
<div className="space-y-2">
<Label>Upload Image</Label>
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center">
<input
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
id="image-upload"
data-testid="image-upload-input"
/>
<label
htmlFor="image-upload"
className="cursor-pointer flex flex-col items-center"
>
{uploadedImageData ? (
<img
src={uploadedImageData}
alt="Preview"
className="max-w-32 max-h-32 object-contain mb-2"
/>
) : (
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
)}
<span className="text-sm text-muted-foreground">
{uploadedImageData
? "Click to change"
: "Click to upload"}
</span>
</label>
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsAddDialogOpen(false);
setNewFileName("");
setUploadedImageData(null);
setNewFileContent("");
setIsDropHovering(false);
}}
>
Cancel
</Button>
<HotkeyButton
onClick={handleAddFile}
disabled={
!newFileName.trim() ||
(newFileType === "image" && !uploadedImageData)
}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={isAddDialogOpen}
data-testid="confirm-add-file"
>
Add File
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent data-testid="delete-context-dialog">
<DialogHeader>
<DialogTitle>Delete Context File</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{selectedFile?.name}"? This
action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteFile}
className="bg-red-600 hover:bg-red-700"
data-testid="confirm-delete-file"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,539 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "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";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Loader2,
Lightbulb,
Download,
StopCircle,
ChevronDown,
ChevronRight,
RefreshCw,
Shield,
Zap,
} from "lucide-react";
import { getElectronAPI, FeatureSuggestion, SuggestionsEvent, SuggestionType } from "@/lib/electron";
import { useAppStore, Feature } from "@/store/app-store";
import { toast } from "sonner";
interface FeatureSuggestionsDialogProps {
open: boolean;
onClose: () => void;
projectPath: string;
// Props to persist state across dialog open/close
suggestions: FeatureSuggestion[];
setSuggestions: (suggestions: FeatureSuggestion[]) => void;
isGenerating: boolean;
setIsGenerating: (generating: boolean) => void;
}
// Configuration for each suggestion type
const suggestionTypeConfig: Record<SuggestionType, {
label: string;
icon: React.ComponentType<{ className?: string }>;
description: string;
color: string;
}> = {
features: {
label: "Feature Suggestions",
icon: Lightbulb,
description: "Discover missing features and improvements",
color: "text-yellow-500",
},
refactoring: {
label: "Refactoring Suggestions",
icon: RefreshCw,
description: "Find code smells and refactoring opportunities",
color: "text-blue-500",
},
security: {
label: "Security Suggestions",
icon: Shield,
description: "Identify security vulnerabilities and issues",
color: "text-red-500",
},
performance: {
label: "Performance Suggestions",
icon: Zap,
description: "Discover performance bottlenecks and optimizations",
color: "text-green-500",
},
};
export function FeatureSuggestionsDialog({
open,
onClose,
projectPath,
suggestions,
setSuggestions,
isGenerating,
setIsGenerating,
}: FeatureSuggestionsDialogProps) {
const [progress, setProgress] = useState<string[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [isImporting, setIsImporting] = useState(false);
const [currentSuggestionType, setCurrentSuggestionType] = useState<SuggestionType | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const { features, setFeatures } = useAppStore();
// Initialize selectedIds when suggestions change
useEffect(() => {
if (suggestions.length > 0 && selectedIds.size === 0) {
setSelectedIds(new Set(suggestions.map((s) => s.id)));
}
}, [suggestions, selectedIds.size]);
// Auto-scroll progress when new content arrives
useEffect(() => {
if (autoScrollRef.current && scrollRef.current && isGenerating) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [progress, isGenerating]);
// Listen for suggestion events when dialog is open
useEffect(() => {
if (!open) return;
const api = getElectronAPI();
if (!api?.suggestions) return;
const unsubscribe = api.suggestions.onEvent((event: SuggestionsEvent) => {
if (event.type === "suggestions_progress") {
setProgress((prev) => [...prev, event.content || ""]);
} else if (event.type === "suggestions_tool") {
const toolName = event.tool || "Unknown Tool";
setProgress((prev) => [...prev, `Using tool: ${toolName}\n`]);
} else if (event.type === "suggestions_complete") {
setIsGenerating(false);
if (event.suggestions && event.suggestions.length > 0) {
setSuggestions(event.suggestions);
// Select all by default
setSelectedIds(new Set(event.suggestions.map((s) => s.id)));
const typeLabel = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType].label.toLowerCase() : "suggestions";
toast.success(`Generated ${event.suggestions.length} ${typeLabel}!`);
} else {
toast.info("No suggestions generated. Try again.");
}
} else if (event.type === "suggestions_error") {
setIsGenerating(false);
toast.error(`Error: ${event.error}`);
}
});
return () => {
unsubscribe();
};
}, [open, setSuggestions, setIsGenerating, currentSuggestionType]);
// Start generating suggestions for a specific type
const handleGenerate = useCallback(async (suggestionType: SuggestionType) => {
const api = getElectronAPI();
if (!api?.suggestions) {
toast.error("Suggestions API not available");
return;
}
setIsGenerating(true);
setProgress([]);
setSuggestions([]);
setSelectedIds(new Set());
setCurrentSuggestionType(suggestionType);
try {
const result = await api.suggestions.generate(projectPath, suggestionType);
if (!result.success) {
toast.error(result.error || "Failed to start generation");
setIsGenerating(false);
}
} catch (error) {
console.error("Failed to generate suggestions:", error);
toast.error("Failed to start generation");
setIsGenerating(false);
}
}, [projectPath, setIsGenerating, setSuggestions]);
// Stop generating
const handleStop = useCallback(async () => {
const api = getElectronAPI();
if (!api?.suggestions) return;
try {
await api.suggestions.stop();
setIsGenerating(false);
toast.info("Generation stopped");
} catch (error) {
console.error("Failed to stop generation:", error);
}
}, [setIsGenerating]);
// Toggle suggestion selection
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// Toggle expand/collapse for a suggestion
const toggleExpanded = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// Select/deselect all
const toggleSelectAll = useCallback(() => {
if (selectedIds.size === suggestions.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(suggestions.map((s) => s.id)));
}
}, [selectedIds.size, suggestions]);
// Import selected suggestions as features
const handleImport = useCallback(async () => {
if (selectedIds.size === 0) {
toast.warning("No suggestions selected");
return;
}
setIsImporting(true);
try {
const api = getElectronAPI();
const selectedSuggestions = suggestions.filter((s) =>
selectedIds.has(s.id)
);
// Create new features from selected suggestions
const newFeatures: Feature[] = selectedSuggestions.map((s) => ({
id: `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
category: s.category,
description: s.description,
steps: s.steps,
status: "backlog" as const,
skipTests: true, // As specified, testing mode true
}));
// Create each new feature using the features API
if (api.features) {
for (const feature of newFeatures) {
await api.features.create(projectPath, feature);
}
}
// Merge with existing features for store update
const updatedFeatures = [...features, ...newFeatures];
// Update store
setFeatures(updatedFeatures);
toast.success(`Imported ${newFeatures.length} features to backlog!`);
// Clear suggestions after importing
setSuggestions([]);
setSelectedIds(new Set());
setProgress([]);
setCurrentSuggestionType(null);
onClose();
} catch (error) {
console.error("Failed to import features:", error);
toast.error("Failed to import features");
} finally {
setIsImporting(false);
}
}, [selectedIds, suggestions, features, setFeatures, setSuggestions, projectPath, onClose]);
// Handle scroll to detect if user scrolled up
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
autoScrollRef.current = isAtBottom;
};
// Go back to type selection
const handleBackToSelection = useCallback(() => {
setSuggestions([]);
setSelectedIds(new Set());
setProgress([]);
setCurrentSuggestionType(null);
}, [setSuggestions]);
const hasStarted = progress.length > 0 || suggestions.length > 0;
const hasSuggestions = suggestions.length > 0;
const currentConfig = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType] : null;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
className="w-[70vw] max-w-[70vw] max-h-[85vh] flex flex-col"
data-testid="feature-suggestions-dialog"
>
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
{currentConfig ? (
<>
<currentConfig.icon className={`w-5 h-5 ${currentConfig.color}`} />
{currentConfig.label}
</>
) : (
<>
<Lightbulb className="w-5 h-5 text-yellow-500" />
AI Suggestions
</>
)}
</DialogTitle>
<DialogDescription>
{currentConfig
? currentConfig.description
: "Analyze your project to discover improvements. Choose a suggestion type below."}
</DialogDescription>
</DialogHeader>
{!hasStarted ? (
// Initial state - show suggestion type buttons
<div className="flex-1 flex flex-col items-center justify-center py-8">
<p className="text-muted-foreground text-center max-w-lg mb-8">
Our AI will analyze your project and generate actionable suggestions.
Choose what type of analysis you want to perform:
</p>
<div className="grid grid-cols-2 gap-4 w-full max-w-2xl">
{(Object.entries(suggestionTypeConfig) as [SuggestionType, typeof suggestionTypeConfig[SuggestionType]][]).map(
([type, config]) => {
const Icon = config.icon;
return (
<Button
key={type}
variant="outline"
className="h-auto py-6 px-6 flex flex-col items-center gap-3 hover:border-primary/50 transition-colors"
onClick={() => handleGenerate(type)}
data-testid={`generate-${type}-btn`}
>
<Icon className={`w-8 h-8 ${config.color}`} />
<div className="text-center">
<div className="font-semibold">{config.label.replace(" Suggestions", "")}</div>
<div className="text-xs text-muted-foreground mt-1">
{config.description}
</div>
</div>
</Button>
);
}
)}
</div>
</div>
) : isGenerating ? (
// Generating state - show progress
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
Analyzing project...
</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]"
>
<div className="whitespace-pre-wrap break-words text-zinc-300">
{progress.join("")}
</div>
</div>
</div>
) : hasSuggestions ? (
// Results state - show suggestions list
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
{suggestions.length} suggestions generated
</span>
<Button variant="ghost" size="sm" onClick={toggleSelectAll}>
{selectedIds.size === suggestions.length
? "Deselect All"
: "Select All"}
</Button>
</div>
<span className="text-sm font-medium">
{selectedIds.size} selected
</span>
</div>
<div
ref={scrollRef}
className="flex-1 overflow-y-auto space-y-2 min-h-[200px] max-h-[400px] pr-2"
>
{suggestions.map((suggestion) => {
const isSelected = selectedIds.has(suggestion.id);
const isExpanded = expandedIds.has(suggestion.id);
return (
<div
key={suggestion.id}
className={`border rounded-lg p-3 transition-colors ${
isSelected
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
data-testid={`suggestion-${suggestion.id}`}
>
<div className="flex items-start gap-3">
<Checkbox
id={suggestion.id}
checked={isSelected}
onCheckedChange={() => toggleSelection(suggestion.id)}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<button
onClick={() => toggleExpanded(suggestion.id)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/20 text-primary font-medium">
#{suggestion.priority}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary text-secondary-foreground">
{suggestion.category}
</span>
</div>
<Label
htmlFor={suggestion.id}
className="text-sm font-medium cursor-pointer"
>
{suggestion.description}
</Label>
{isExpanded && (
<div className="mt-3 space-y-2 text-sm">
{suggestion.reasoning && (
<p className="text-muted-foreground italic">
{suggestion.reasoning}
</p>
)}
{suggestion.steps.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
Implementation Steps:
</p>
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-0.5">
{suggestion.steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
) : (
// No results state
<div className="flex-1 flex flex-col items-center justify-center py-8 text-center">
<p className="text-muted-foreground mb-4">
No suggestions were generated. Try running the analysis again.
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={handleBackToSelection}>
Back to Selection
</Button>
{currentSuggestionType && (
<Button onClick={() => handleGenerate(currentSuggestionType)}>
<Lightbulb className="w-4 h-4 mr-2" />
Try Again
</Button>
)}
</div>
</div>
)}
<DialogFooter className="flex-shrink-0">
{hasSuggestions && (
<div className="flex gap-2 w-full justify-between">
<div className="flex gap-2">
<Button variant="outline" onClick={handleBackToSelection}>
Back
</Button>
{currentSuggestionType && (
<Button variant="outline" onClick={() => handleGenerate(currentSuggestionType)}>
{currentConfig && <currentConfig.icon className="w-4 h-4 mr-2" />}
Regenerate
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<HotkeyButton
onClick={handleImport}
disabled={selectedIds.size === 0 || isImporting}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open && hasSuggestions}
>
{isImporting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Download className="w-4 h-4 mr-2" />
)}
Import {selectedIds.size} Feature
{selectedIds.size !== 1 ? "s" : ""}
</HotkeyButton>
</div>
</div>
)}
{!hasSuggestions && !isGenerating && hasStarted && (
<Button variant="ghost" onClick={onClose}>
Close
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,616 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { useAppStore, Feature } from "@/store/app-store";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Bot,
Send,
User,
Loader2,
Sparkles,
FileText,
ArrowLeft,
CheckCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron";
import { Markdown } from "@/components/ui/markdown";
interface InterviewMessage {
id: string;
role: "user" | "assistant";
content: string;
timestamp: Date;
}
interface InterviewState {
projectName: string;
projectDescription: string;
techStack: string[];
features: string[];
additionalNotes: string;
}
// Interview questions flow
const INTERVIEW_QUESTIONS = [
{
id: "project-description",
question: "What do you want to build?",
hint: "Describe your project idea in a few sentences",
field: "projectDescription" as const,
},
{
id: "tech-stack",
question: "What tech stack would you like to use?",
hint: "e.g., React, Next.js, Node.js, Python, etc.",
field: "techStack" as const,
},
{
id: "core-features",
question: "What are the core features you want to include?",
hint: "List the main functionalities your app should have",
field: "features" as const,
},
{
id: "additional",
question: "Any additional requirements or preferences?",
hint: "Design preferences, integrations, deployment needs, etc.",
field: "additionalNotes" as const,
},
];
export function InterviewView() {
const { setCurrentView, addProject, setCurrentProject, setAppSpec } =
useAppStore();
const [input, setInput] = useState("");
const [messages, setMessages] = useState<InterviewMessage[]>([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [interviewData, setInterviewData] = useState<InterviewState>({
projectName: "",
projectDescription: "",
techStack: [],
features: [],
additionalNotes: "",
});
const [isGenerating, setIsGenerating] = useState(false);
const [isComplete, setIsComplete] = useState(false);
const [generatedSpec, setGeneratedSpec] = useState<string | null>(null);
const [projectPath, setProjectPath] = useState("");
const [projectName, setProjectName] = useState("");
const [showProjectSetup, setShowProjectSetup] = useState(false);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Initialize with first question
useEffect(() => {
if (messages.length === 0) {
const welcomeMessage: InterviewMessage = {
id: "welcome",
role: "assistant",
content: `Hello! I'm here to help you plan your new project. Let's go through a few questions to understand what you want to build.\n\n**${INTERVIEW_QUESTIONS[0].question}**\n\n_${INTERVIEW_QUESTIONS[0].hint}_`,
timestamp: new Date(),
};
setMessages([welcomeMessage]);
}
}, [messages.length]);
// Auto-scroll to bottom when messages change
useEffect(() => {
let timeoutId: NodeJS.Timeout | undefined;
if (messagesContainerRef.current) {
// Use a small delay to ensure DOM is updated
timeoutId = setTimeout(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTo({
top: messagesContainerRef.current.scrollHeight,
behavior: "smooth",
});
}
}, 100);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [messages]);
// Auto-focus input
useEffect(() => {
if (inputRef.current && !isComplete) {
inputRef.current.focus();
}
}, [currentQuestionIndex, isComplete]);
const handleSend = useCallback(() => {
if (!input.trim() || isGenerating || isComplete) return;
const userMessage: InterviewMessage = {
id: `user-${Date.now()}`,
role: "user",
content: input,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
// Update interview data based on current question
const currentQuestion = INTERVIEW_QUESTIONS[currentQuestionIndex];
if (currentQuestion) {
setInterviewData((prev) => {
const newData = { ...prev };
if (
currentQuestion.field === "techStack" ||
currentQuestion.field === "features"
) {
// Parse comma-separated values into array
newData[currentQuestion.field] = input
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} else {
(newData as Record<string, string | string[]>)[
currentQuestion.field
] = input;
}
return newData;
});
}
setInput("");
// Move to next question or complete
const nextIndex = currentQuestionIndex + 1;
setTimeout(() => {
if (nextIndex < INTERVIEW_QUESTIONS.length) {
const nextQuestion = INTERVIEW_QUESTIONS[nextIndex];
const assistantMessage: InterviewMessage = {
id: `assistant-${Date.now()}`,
role: "assistant",
content: `Great! **${nextQuestion.question}**\n\n_${nextQuestion.hint}_`,
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
setCurrentQuestionIndex(nextIndex);
} else {
// All questions answered - generate spec
const summaryMessage: InterviewMessage = {
id: `assistant-summary-${Date.now()}`,
role: "assistant",
content:
"Perfect! I have all the information I need. Now let me generate your project specification...",
timestamp: new Date(),
};
setMessages((prev) => [...prev, summaryMessage]);
generateSpec({
...interviewData,
projectDescription:
currentQuestionIndex === 0
? input
: interviewData.projectDescription,
techStack:
currentQuestionIndex === 1
? input
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: interviewData.techStack,
features:
currentQuestionIndex === 2
? input
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: interviewData.features,
additionalNotes:
currentQuestionIndex === 3 ? input : interviewData.additionalNotes,
});
}
}, 500);
}, [input, isGenerating, isComplete, currentQuestionIndex, interviewData]);
const generateSpec = useCallback(async (data: InterviewState) => {
setIsGenerating(true);
// Generate a draft app_spec.txt based on the interview responses
const spec = generateAppSpec(data);
// Simulate some processing time for better UX
await new Promise((resolve) => setTimeout(resolve, 1500));
setGeneratedSpec(spec);
setIsGenerating(false);
setIsComplete(true);
setShowProjectSetup(true);
const completionMessage: InterviewMessage = {
id: `assistant-complete-${Date.now()}`,
role: "assistant",
content: `I've generated a draft project specification based on our conversation!\n\nPlease provide a project name and choose where to save your project, then click "Create Project" to get started.`,
timestamp: new Date(),
};
setMessages((prev) => [...prev, completionMessage]);
}, []);
const generateAppSpec = (data: InterviewState): string => {
const projectName = data.projectDescription
.split(" ")
.slice(0, 3)
.join("-")
.toLowerCase()
.replace(/[^a-z0-9-]/g, "");
return `<project_specification>
<project_name>${projectName || "my-project"}</project_name>
<overview>
${data.projectDescription}
</overview>
<technology_stack>
${
data.techStack.length > 0
? data.techStack
.map((tech) => `<technology>${tech}</technology>`)
.join("\n ")
: "<!-- Define your tech stack -->"
}
</technology_stack>
<core_capabilities>
${
data.features.length > 0
? data.features
.map((feature) => `<capability>${feature}</capability>`)
.join("\n ")
: "<!-- List core features -->"
}
</core_capabilities>
<additional_requirements>
${data.additionalNotes || "None specified"}
</additional_requirements>
<development_guidelines>
<guideline>Write clean, production-quality code</guideline>
<guideline>Include proper error handling</guideline>
<guideline>Write comprehensive Playwright tests</guideline>
<guideline>Ensure all tests pass before marking features complete</guideline>
</development_guidelines>
</project_specification>`;
};
const handleSelectDirectory = async () => {
const api = getElectronAPI();
const result = await api.openDirectory();
if (!result.canceled && result.filePaths[0]) {
setProjectPath(result.filePaths[0]);
}
};
const handleCreateProject = async () => {
if (!projectName || !projectPath || !generatedSpec) return;
setIsGenerating(true);
try {
const api = getElectronAPI();
const fullProjectPath = `${projectPath}/${projectName}`;
// Create project directory
await api.mkdir(fullProjectPath);
// Write app_spec.txt with generated content
await api.writeFile(
`${fullProjectPath}/.automaker/app_spec.txt`,
generatedSpec
);
// Create initial feature in the features folder
const initialFeature: Feature = {
id: crypto.randomUUID(),
category: "Core",
description: "Initial project setup",
status: "backlog" as const,
steps: [
"Step 1: Review app_spec.txt",
"Step 2: Set up development environment",
"Step 3: Start implementing features",
],
skipTests: true,
};
if (!api.features) {
throw new Error("Features API not available");
}
await api.features.create(fullProjectPath, initialFeature);
const project = {
id: `project-${Date.now()}`,
name: projectName,
path: fullProjectPath,
lastOpened: new Date().toISOString(),
};
// Update app spec in store
setAppSpec(generatedSpec);
// Add and select the project
addProject(project);
setCurrentProject(project);
} catch (error) {
console.error("Failed to create project:", error);
setIsGenerating(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleGoBack = () => {
setCurrentView("welcome");
};
return (
<div
className="flex-1 flex flex-col content-bg min-h-0"
data-testid="interview-view"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={handleGoBack}
className="h-8 w-8 p-0"
data-testid="interview-back-button"
>
<ArrowLeft className="w-4 h-4" />
</Button>
<Sparkles className="w-5 h-5 text-primary" />
<div>
<h1 className="text-xl font-bold">New Project Interview</h1>
<p className="text-sm text-muted-foreground">
{isComplete
? "Specification generated!"
: `Question ${currentQuestionIndex + 1} of ${
INTERVIEW_QUESTIONS.length
}`}
</p>
</div>
</div>
{/* Progress indicator */}
<div className="flex items-center gap-2">
{INTERVIEW_QUESTIONS.map((_, index) => (
<div
key={index}
className={cn(
"w-2 h-2 rounded-full transition-colors",
index < currentQuestionIndex
? "bg-green-500"
: index === currentQuestionIndex
? "bg-primary"
: "bg-zinc-700"
)}
/>
))}
{isComplete && (
<CheckCircle className="w-4 h-4 text-green-500 ml-2" />
)}
</div>
</div>
{/* Messages */}
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto p-4 space-y-4"
data-testid="interview-messages"
>
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex gap-3",
message.role === "user" && "flex-row-reverse"
)}
>
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
message.role === "assistant" ? "bg-primary/10" : "bg-muted"
)}
>
{message.role === "assistant" ? (
<Bot className="w-4 h-4 text-primary" />
) : (
<User className="w-4 h-4" />
)}
</div>
<Card
className={cn(
"max-w-[80%]",
message.role === "user"
? "bg-transparent border border-primary text-foreground"
: "border-l-4 border-primary bg-card"
)}
>
<CardContent className="px-3 py-2">
{message.role === "assistant" ? (
<Markdown className="text-sm text-primary prose-headings:text-primary prose-strong:text-primary prose-code:text-primary">
{message.content}
</Markdown>
) : (
<p className="text-sm whitespace-pre-wrap">
{message.content}
</p>
)}
<p
className={cn(
"text-xs mt-1",
message.role === "user"
? "text-muted-foreground"
: "text-primary/70"
)}
>
{message.timestamp.toLocaleTimeString()}
</p>
</CardContent>
</Card>
</div>
))}
{isGenerating && !showProjectSetup && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<Bot className="w-4 h-4 text-primary" />
</div>
<Card className="border-l-4 border-primary bg-card">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="text-sm text-primary">
Generating specification...
</span>
</div>
</CardContent>
</Card>
</div>
)}
{/* Project Setup Form */}
{showProjectSetup && (
<div className="mt-6">
<Card
className="bg-zinc-900/50 border-white/10"
data-testid="project-setup-form"
>
<CardContent className="p-6 space-y-4">
<div className="flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-primary" />
<h3 className="text-lg font-semibold">Create Your Project</h3>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label
htmlFor="project-name"
className="text-sm font-medium text-zinc-300"
>
Project Name
</label>
<Input
id="project-name"
placeholder="my-awesome-project"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
className="bg-zinc-950/50 border-white/10 text-white placeholder:text-zinc-500"
data-testid="interview-project-name-input"
/>
</div>
<div className="space-y-2">
<label
htmlFor="project-path"
className="text-sm font-medium text-zinc-300"
>
Parent Directory
</label>
<div className="flex gap-2">
<Input
id="project-path"
placeholder="/path/to/projects"
value={projectPath}
onChange={(e) => setProjectPath(e.target.value)}
className="flex-1 bg-zinc-950/50 border-white/10 text-white placeholder:text-zinc-500"
data-testid="interview-project-path-input"
/>
<Button
variant="secondary"
onClick={handleSelectDirectory}
className="bg-white/5 hover:bg-white/10 text-white border border-white/10"
data-testid="interview-browse-directory"
>
Browse
</Button>
</div>
</div>
{/* Preview of generated spec */}
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-300">
Generated Specification Preview
</label>
<div
className="bg-zinc-950/50 border border-white/10 rounded-md p-3 max-h-48 overflow-y-auto"
data-testid="spec-preview"
>
<pre className="text-xs text-zinc-400 whitespace-pre-wrap font-mono">
{generatedSpec}
</pre>
</div>
</div>
<Button
onClick={handleCreateProject}
disabled={!projectName || !projectPath || isGenerating}
className="w-full bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-primary-foreground border-0"
data-testid="interview-create-project"
>
{isGenerating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<CheckCircle className="w-4 h-4 mr-2" />
Create Project
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
{/* Input */}
{!isComplete && (
<div className="border-t p-4">
<div className="flex gap-2">
<Input
ref={inputRef}
placeholder="Type your answer..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress}
disabled={isGenerating}
data-testid="interview-input"
className="flex-1"
/>
<Button
onClick={handleSend}
disabled={!input.trim() || isGenerating}
data-testid="interview-send"
>
<Send className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,920 @@
"use client";
import { useState, useEffect, memo } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Feature, useAppStore } from "@/store/app-store";
import {
GripVertical,
Edit,
CheckCircle2,
Circle,
Loader2,
Trash2,
Eye,
PlayCircle,
RotateCcw,
StopCircle,
Hand,
MessageSquare,
GitCommit,
Cpu,
Wrench,
ListTodo,
Sparkles,
Expand,
FileText,
MoreVertical,
AlertCircle,
GitBranch,
Undo2,
GitMerge,
ChevronDown,
ChevronUp,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { getElectronAPI } from "@/lib/electron";
import {
parseAgentContext,
AgentTaskInfo,
formatModelName,
DEFAULT_MODEL,
} from "@/lib/agent-context-parser";
import { Markdown } from "@/components/ui/markdown";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface KanbanCardProps {
feature: Feature;
onEdit: () => void;
onDelete: () => void;
onViewOutput?: () => void;
onVerify?: () => void;
onResume?: () => void;
onForceStop?: () => void;
onManualVerify?: () => void;
onMoveBackToInProgress?: () => void;
onFollowUp?: () => void;
onCommit?: () => void;
onRevert?: () => void;
onMerge?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
shortcutKey?: string;
/** Context content for extracting progress info */
contextContent?: string;
/** Feature summary from agent completion */
summary?: string;
}
export const KanbanCard = memo(function KanbanCard({
feature,
onEdit,
onDelete,
onViewOutput,
onVerify,
onResume,
onForceStop,
onManualVerify,
onMoveBackToInProgress,
onFollowUp,
onCommit,
onRevert,
onMerge,
hasContext,
isCurrentAutoTask,
shortcutKey,
contextContent,
summary,
}: KanbanCardProps) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const { kanbanCardDetailLevel } = useAppStore();
// Check if feature has worktree
const hasWorktree = !!feature.branchName;
// Helper functions to check what should be shown based on detail level
const showSteps =
kanbanCardDetailLevel === "standard" ||
kanbanCardDetailLevel === "detailed";
const showAgentInfo = kanbanCardDetailLevel === "detailed";
// Load context file for in_progress, waiting_approval, and verified features
useEffect(() => {
const loadContext = async () => {
// Use provided context or load from file
if (contextContent) {
const info = parseAgentContext(contextContent);
setAgentInfo(info);
return;
}
// Only load for non-backlog features
if (feature.status === "backlog") {
setAgentInfo(null);
return;
}
try {
const api = getElectronAPI();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) return;
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(
currentProject.path,
feature.id
);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
}
} else {
// Fallback to direct file read for backward compatibility
const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`;
const result = await api.readFile(contextPath);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
}
}
} catch {
// Context file might not exist
console.debug("[KanbanCard] No context file for feature:", feature.id);
}
};
loadContext();
// Reload context periodically while feature is running
if (isCurrentAutoTask) {
const interval = setInterval(loadContext, 3000);
return () => clearInterval(interval);
}
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsDeleteDialogOpen(true);
};
const handleConfirmDelete = () => {
setIsDeleteDialogOpen(false);
onDelete();
};
const handleCancelDelete = () => {
setIsDeleteDialogOpen(false);
};
// Dragging logic:
// - Backlog items can always be dragged
// - skipTests items can be dragged even when in_progress or verified (unless currently running)
// - waiting_approval items can always be dragged (to allow manual verification via drag)
// - Non-skipTests (TDD) items in progress or verified cannot be dragged
const isDraggable =
feature.status === "backlog" ||
feature.status === "waiting_approval" ||
(feature.skipTests && !isCurrentAutoTask);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: feature.id,
disabled: !isDraggable,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<Card
ref={setNodeRef}
style={style}
className={cn(
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative kanban-card-content select-none",
isDragging && "opacity-50 scale-105 shadow-lg",
isCurrentAutoTask &&
"border-running-indicator border-2 shadow-running-indicator/50 shadow-lg animate-pulse",
feature.error &&
!isCurrentAutoTask &&
"border-red-500 border-2 shadow-red-500/30 shadow-lg",
!isDraggable && "cursor-default"
)}
data-testid={`kanban-card-${feature.id}`}
onDoubleClick={onEdit}
{...attributes}
{...(isDraggable ? listeners : {})}
>
{/* Skip Tests indicator badge */}
{feature.skipTests && !feature.error && (
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
"top-2 left-2",
"bg-orange-500/20 border border-orange-500/50 text-orange-400"
)}
data-testid={`skip-tests-badge-${feature.id}`}
title="Manual verification required"
>
<Hand className="w-3 h-3" />
<span>Manual</span>
</div>
)}
{/* Error indicator badge */}
{feature.error && (
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
"top-2 left-2",
"bg-red-500/20 border border-red-500/50 text-red-400"
)}
data-testid={`error-badge-${feature.id}`}
title={feature.error}
>
<AlertCircle className="w-3 h-3" />
<span>Errored</span>
</div>
)}
{/* Branch badge - show when feature has a worktree */}
{hasWorktree && !isCurrentAutoTask && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10 cursor-default",
"bg-purple-500/20 border border-purple-500/50 text-purple-400",
// Position below error badge if present, otherwise use normal position
feature.error || feature.skipTests
? "top-8 left-2"
: "top-2 left-2"
)}
data-testid={`branch-badge-${feature.id}`}
>
<GitBranch className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[80px]">{feature.branchName?.replace("feature/", "")}</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[300px]">
<p className="font-mono text-xs break-all">{feature.branchName}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<CardHeader
className={cn(
"p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout
// Add extra top padding when badges are present to prevent text overlap
(feature.skipTests || feature.error) && "pt-10",
// Add even more top padding when both badges and branch are shown
hasWorktree && (feature.skipTests || feature.error) && "pt-14"
)}
>
{isCurrentAutoTask && (
<div className="absolute top-2 right-2 flex items-center justify-center gap-2 bg-running-indicator/20 border border-running-indicator rounded px-2 py-0.5">
<Loader2 className="w-4 h-4 text-running-indicator animate-spin" />
{feature.startedAt && (
<CountUpTimer
startedAt={feature.startedAt}
className="text-running-indicator"
/>
)}
</div>
)}
{!isCurrentAutoTask && (
<div className="absolute top-2 right-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`menu-${feature.id}`}
>
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
data-testid={`edit-feature-${feature.id}`}
>
<Edit className="w-3 h-3 mr-2" />
Edit
</DropdownMenuItem>
{onViewOutput && feature.status !== "backlog" && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
data-testid={`view-logs-${feature.id}`}
>
<FileText className="w-3 h-3 mr-2" />
Logs
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(e as unknown as React.MouseEvent);
}}
data-testid={`delete-feature-${feature.id}`}
>
<Trash2 className="w-3 h-3 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
<div className="flex items-start gap-2">
{isDraggable && (
<div
className="-ml-2 -mt-1 p-2 touch-none"
data-testid={`drag-handle-${feature.id}`}
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
</div>
)}
<div className="flex-1 min-w-0 overflow-hidden">
<CardTitle
className={cn(
"text-sm leading-tight break-words hyphens-auto overflow-hidden",
!isDescriptionExpanded && "line-clamp-3"
)}
>
{feature.description || feature.summary || feature.id}
</CardTitle>
{/* Show More/Less toggle - only show when description is likely truncated */}
{(feature.description || feature.summary || "").length > 100 && (
<button
onClick={(e) => {
e.stopPropagation();
setIsDescriptionExpanded(!isDescriptionExpanded);
}}
onPointerDown={(e) => e.stopPropagation()}
className="flex items-center gap-0.5 text-[10px] text-muted-foreground hover:text-foreground mt-1 transition-colors"
data-testid={`toggle-description-${feature.id}`}
>
{isDescriptionExpanded ? (
<>
<ChevronUp className="w-3 h-3" />
<span>Show Less</span>
</>
) : (
<>
<ChevronDown className="w-3 h-3" />
<span>Show More</span>
</>
)}
</button>
)}
<CardDescription className="text-xs mt-1 truncate">
{feature.category}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-3 pt-0">
{/* Steps Preview - Show in Standard and Detailed modes */}
{showSteps && feature.steps && feature.steps.length > 0 && (
<div className="mb-3 space-y-1">
{feature.steps.slice(0, 3).map((step, index) => (
<div
key={index}
className="flex items-start gap-2 text-xs text-muted-foreground"
>
{feature.status === "verified" ? (
<CheckCircle2 className="w-3 h-3 mt-0.5 text-green-500 shrink-0" />
) : (
<Circle className="w-3 h-3 mt-0.5 shrink-0" />
)}
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">{step}</span>
</div>
))}
{feature.steps.length > 3 && (
<p className="text-xs text-muted-foreground pl-5">
+{feature.steps.length - 3} more steps
</p>
)}
</div>
)}
{/* Agent Info Panel - shows for in_progress, waiting_approval, verified */}
{/* Detailed mode: Show all agent info */}
{showAgentInfo && feature.status !== "backlog" && agentInfo && (
<div className="mb-3 space-y-2 overflow-hidden">
{/* Model & Phase */}
<div className="flex items-center gap-2 text-xs flex-wrap">
<div className="flex items-center gap-1 text-cyan-400">
<Cpu className="w-3 h-3" />
<span className="font-medium">
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
</div>
{agentInfo.currentPhase && (
<div
className={cn(
"px-1.5 py-0.5 rounded text-[10px] font-medium",
agentInfo.currentPhase === "planning" &&
"bg-blue-500/20 text-blue-400",
agentInfo.currentPhase === "action" &&
"bg-amber-500/20 text-amber-400",
agentInfo.currentPhase === "verification" &&
"bg-green-500/20 text-green-400"
)}
>
{agentInfo.currentPhase}
</div>
)}
</div>
{/* Task List Progress (if todos found) */}
{agentInfo.todos.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<ListTodo className="w-3 h-3" />
<span>
{
agentInfo.todos.filter((t) => t.status === "completed")
.length
}
/{agentInfo.todos.length} tasks
</span>
</div>
<div className="space-y-0.5 max-h-16 overflow-y-auto">
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
<div
key={idx}
className="flex items-center gap-1.5 text-[10px]"
>
{todo.status === "completed" ? (
<CheckCircle2 className="w-2.5 h-2.5 text-green-500 shrink-0" />
) : todo.status === "in_progress" ? (
<Loader2 className="w-2.5 h-2.5 text-amber-400 animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground shrink-0" />
)}
<span
className={cn(
"break-words hyphens-auto line-clamp-2 leading-relaxed",
todo.status === "completed" &&
"text-muted-foreground line-through",
todo.status === "in_progress" && "text-amber-400",
todo.status === "pending" && "text-foreground-secondary"
)}
>
{todo.content}
</span>
</div>
))}
{agentInfo.todos.length > 3 && (
<p className="text-[10px] text-muted-foreground pl-4">
+{agentInfo.todos.length - 3} more
</p>
)}
</div>
</div>
)}
{/* Summary for waiting_approval and verified - prioritize feature.summary from UpdateFeatureStatus */}
{(feature.status === "waiting_approval" ||
feature.status === "verified") && (
<>
{(feature.summary || summary || agentInfo.summary) && (
<div className="space-y-1 pt-1 border-t border-border-glass overflow-hidden">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 text-[10px] text-green-400 min-w-0">
<Sparkles className="w-3 h-3 shrink-0" />
<span className="truncate">Summary</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setIsSummaryDialogOpen(true);
}}
onPointerDown={(e) => e.stopPropagation()}
className="p-0.5 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground shrink-0"
title="View full summary"
data-testid={`expand-summary-${feature.id}`}
>
<Expand className="w-3 h-3" />
</button>
</div>
<p className="text-[10px] text-foreground-secondary line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden">
{feature.summary || summary || agentInfo.summary}
</p>
</div>
)}
{/* Show tool count even without summary */}
{!feature.summary &&
!summary &&
!agentInfo.summary &&
agentInfo.toolCallCount > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground pt-1 border-t border-border-glass">
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
</span>
{agentInfo.todos.length > 0 && (
<span className="flex items-center gap-1">
<CheckCircle2 className="w-2.5 h-2.5 text-green-500" />
{
agentInfo.todos.filter(
(t) => t.status === "completed"
).length
}{" "}
tasks done
</span>
)}
</div>
)}
</>
)}
</div>
)}
{/* Actions */}
<div className="flex gap-2">
{isCurrentAutoTask && (
<>
{onViewOutput && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-action-view hover:bg-action-view-hover"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
Logs
{shortcutKey && (
<span
className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/10 border border-primary-foreground/20"
data-testid={`shortcut-key-${feature.id}`}
>
{shortcutKey}
</span>
)}
</Button>
)}
{onForceStop && (
<Button
variant="destructive"
size="sm"
className="h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onForceStop();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`force-stop-${feature.id}`}
>
<StopCircle className="w-3 h-3 mr-1" />
Stop
</Button>
)}
</>
)}
{!isCurrentAutoTask && feature.status === "in_progress" && (
<>
{/* skipTests features show manual verify button */}
{feature.skipTests && onManualVerify ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-primary hover:bg-primary/90"
onClick={(e) => {
e.stopPropagation();
onManualVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`manual-verify-${feature.id}`}
>
<CheckCircle2 className="w-3 h-3 mr-1" />
Verify
</Button>
) : hasContext && onResume ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-action-verify hover:bg-action-verify-hover"
onClick={(e) => {
e.stopPropagation();
onResume();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`resume-feature-${feature.id}`}
>
<RotateCcw className="w-3 h-3 mr-1" />
Resume
</Button>
) : onVerify ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-action-verify hover:bg-action-verify-hover"
onClick={(e) => {
e.stopPropagation();
onVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`verify-feature-${feature.id}`}
>
<PlayCircle className="w-3 h-3 mr-1" />
Resume
</Button>
) : null}
{onViewOutput && !feature.skipTests && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-inprogress-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
Logs
</Button>
)}
</>
)}
{!isCurrentAutoTask && feature.status === "verified" && (
<>
{/* Logs button if context exists */}
{hasContext && onViewOutput && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-verified-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
Logs
</Button>
)}
</>
)}
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
<>
{/* Revert button - only show when worktree exists (icon only to save space) */}
{hasWorktree && onRevert && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/20 shrink-0"
onClick={(e) => {
e.stopPropagation();
setIsRevertDialogOpen(true);
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`revert-${feature.id}`}
>
<Undo2 className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Revert changes</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Follow-up prompt button */}
{onFollowUp && (
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-xs min-w-0"
onClick={(e) => {
e.stopPropagation();
onFollowUp();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`follow-up-${feature.id}`}
>
<MessageSquare className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Follow-up</span>
</Button>
)}
{/* Merge button - only show when worktree exists */}
{hasWorktree && onMerge && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-purple-600 hover:bg-purple-700 min-w-0"
onClick={(e) => {
e.stopPropagation();
onMerge();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`merge-${feature.id}`}
title="Merge changes into main branch"
>
<GitMerge className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Merge</span>
</Button>
)}
{/* Commit and verify button - show when no worktree */}
{!hasWorktree && onCommit && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onCommit();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`commit-${feature.id}`}
>
<GitCommit className="w-3 h-3 mr-1" />
Commit
</Button>
)}
</>
)}
</div>
</CardContent>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent data-testid="delete-confirmation-dialog">
<DialogHeader>
<DialogTitle>Delete Feature</DialogTitle>
<DialogDescription>
Are you sure you want to delete this feature? This action cannot
be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-6">
<Button
variant="ghost"
onClick={handleCancelDelete}
data-testid="cancel-delete-button"
>
Cancel
</Button>
<HotkeyButton
variant="destructive"
onClick={handleConfirmDelete}
data-testid="confirm-delete-button"
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={isDeleteDialogOpen}
>
Delete
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Summary Modal */}
<Dialog open={isSummaryDialogOpen} onOpenChange={setIsSummaryDialogOpen}>
<DialogContent
className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col"
data-testid={`summary-dialog-${feature.id}`}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-green-400" />
Implementation Summary
</DialogTitle>
<DialogDescription className="text-sm" title={feature.description || feature.summary || ""}>
{(() => {
const displayText = feature.description || feature.summary || "No description";
return displayText.length > 100
? `${displayText.slice(0, 100)}...`
: displayText;
})()}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-4 bg-card rounded-lg border border-border">
<Markdown>
{feature.summary ||
summary ||
agentInfo?.summary ||
"No summary available"}
</Markdown>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setIsSummaryDialogOpen(false)}
data-testid="close-summary-button"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Revert Confirmation Dialog */}
<Dialog open={isRevertDialogOpen} onOpenChange={setIsRevertDialogOpen}>
<DialogContent data-testid="revert-confirmation-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-400">
<Undo2 className="w-5 h-5" />
Revert Changes
</DialogTitle>
<DialogDescription>
This will discard all changes made by the agent and move the feature back to the backlog.
{feature.branchName && (
<span className="block mt-2 font-medium">
Branch <code className="bg-muted px-1 py-0.5 rounded">{feature.branchName}</code> will be deleted.
</span>
)}
<span className="block mt-2 text-red-400 font-medium">
This action cannot be undone.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setIsRevertDialogOpen(false)}
data-testid="cancel-revert-button"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
setIsRevertDialogOpen(false);
onRevert?.();
}}
data-testid="confirm-revert-button"
>
<Undo2 className="w-4 h-4 mr-2" />
Revert Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
});

View File

@@ -0,0 +1,52 @@
"use client";
import { memo } from "react";
import { useDroppable } from "@dnd-kit/core";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
interface KanbanColumnProps {
id: string;
title: string;
color: string;
count: number;
children: ReactNode;
headerAction?: ReactNode;
}
export const KanbanColumn = memo(function KanbanColumn({
id,
title,
color,
count,
children,
headerAction,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div
ref={setNodeRef}
className={cn(
"flex flex-col h-full rounded-lg bg-card backdrop-blur-sm border border-border transition-colors w-72",
isOver && "bg-accent"
)}
data-testid={`kanban-column-${id}`}
>
{/* Column Header */}
<div className="flex items-center gap-2 p-3 border-b border-border">
<div className={cn("w-3 h-3 rounded-full", color)} />
<h3 className="font-medium text-sm flex-1">{title}</h3>
{headerAction}
<span className="text-xs text-muted-foreground bg-background px-2 py-0.5 rounded-full">
{count}
</span>
</div>
{/* Column Content */}
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{children}
</div>
</div>
);
});

View File

@@ -0,0 +1,718 @@
"use client";
import { useState, useMemo, useCallback, useEffect } from "react";
import {
useAppStore,
AIProfile,
AgentModel,
ThinkingLevel,
ModelProvider,
} from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn, modelSupportsThinking } from "@/lib/utils";
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
UserCircle,
Plus,
Pencil,
Trash2,
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
GripVertical,
Lock,
Check,
} from "lucide-react";
import { toast } from "sonner";
import {
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
// Icon mapping for profiles
const PROFILE_ICONS: Record<
string,
React.ComponentType<{ className?: string }>
> = {
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
};
// Available icons for selection
const ICON_OPTIONS = [
{ name: "Brain", icon: Brain },
{ name: "Zap", icon: Zap },
{ name: "Scale", icon: Scale },
{ name: "Cpu", icon: Cpu },
{ name: "Rocket", icon: Rocket },
{ name: "Sparkles", icon: Sparkles },
];
// Model options for the form
const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
{ id: "haiku", label: "Claude Haiku" },
{ id: "sonnet", label: "Claude Sonnet" },
{ id: "opus", label: "Claude Opus" },
];
const CODEX_MODELS: { id: AgentModel; label: string }[] = [
{ id: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
{ id: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
{ id: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex Mini" },
{ id: "gpt-5.1", label: "GPT-5.1" },
];
const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
{ id: "none", label: "None" },
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "ultrathink", label: "Ultrathink" },
];
// Helper to determine provider from model
function getProviderFromModel(model: AgentModel): ModelProvider {
if (model.startsWith("gpt")) {
return "codex";
}
return "claude";
}
// Sortable Profile Card Component
function SortableProfileCard({
profile,
onEdit,
onDelete,
}: {
profile: AIProfile;
onEdit: () => void;
onDelete: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: profile.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
const isCodex = profile.provider === "codex";
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"group relative flex items-start gap-4 p-4 rounded-xl border bg-card transition-all",
isDragging && "shadow-lg",
profile.isBuiltIn
? "border-border/50"
: "border-border hover:border-primary/50 hover:shadow-sm"
)}
data-testid={`profile-card-${profile.id}`}
>
{/* Drag Handle */}
<button
{...attributes}
{...listeners}
className="p-1 rounded hover:bg-accent cursor-grab active:cursor-grabbing flex-shrink-0 mt-1"
data-testid={`profile-drag-handle-${profile.id}`}
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
{/* Icon */}
<div
className={cn(
"flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center",
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
)}
>
{IconComponent && (
<IconComponent
className={cn(
"w-5 h-5",
isCodex ? "text-emerald-500" : "text-primary"
)}
/>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-foreground">{profile.name}</h3>
{profile.isBuiltIn && (
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
<Lock className="w-2.5 h-2.5" />
Built-in
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
{profile.description}
</p>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full border",
isCodex
? "border-emerald-500/30 text-emerald-600 dark:text-emerald-400 bg-emerald-500/10"
: "border-primary/30 text-primary bg-primary/10"
)}
>
{profile.model}
</span>
{profile.thinkingLevel !== "none" && (
<span className="text-xs px-2 py-0.5 rounded-full border border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/10">
{profile.thinkingLevel}
</span>
)}
</div>
</div>
{/* Actions */}
{!profile.isBuiltIn && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={onEdit}
className="h-8 w-8 p-0"
data-testid={`edit-profile-${profile.id}`}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
data-testid={`delete-profile-${profile.id}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
</div>
);
}
// Profile Form Component
function ProfileForm({
profile,
onSave,
onCancel,
isEditing,
hotkeyActive,
}: {
profile: Partial<AIProfile>;
onSave: (profile: Omit<AIProfile, "id">) => void;
onCancel: () => void;
isEditing: boolean;
hotkeyActive: boolean;
}) {
const [formData, setFormData] = useState({
name: profile.name || "",
description: profile.description || "",
model: profile.model || ("opus" as AgentModel),
thinkingLevel: profile.thinkingLevel || ("none" as ThinkingLevel),
icon: profile.icon || "Brain",
});
const provider = getProviderFromModel(formData.model);
const supportsThinking = modelSupportsThinking(formData.model);
const handleModelChange = (model: AgentModel) => {
const newProvider = getProviderFromModel(model);
setFormData({
...formData,
model,
// Reset thinking level when switching to Codex (doesn't support thinking)
thinkingLevel: newProvider === "codex" ? "none" : formData.thinkingLevel,
});
};
const handleSubmit = () => {
if (!formData.name.trim()) {
toast.error("Please enter a profile name");
return;
}
onSave({
name: formData.name.trim(),
description: formData.description.trim(),
model: formData.model,
thinkingLevel: supportsThinking ? formData.thinkingLevel : "none",
provider,
isBuiltIn: false,
icon: formData.icon,
});
};
return (
<div className="space-y-4">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Heavy Task, Quick Fix"
data-testid="profile-name-input"
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="profile-description">Description</Label>
<Textarea
id="profile-description"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="Describe when to use this profile..."
rows={2}
data-testid="profile-description-input"
/>
</div>
{/* Icon Selection */}
<div className="space-y-2">
<Label>Icon</Label>
<div className="flex gap-2 flex-wrap">
{ICON_OPTIONS.map(({ name, icon: Icon }) => (
<button
key={name}
type="button"
onClick={() => setFormData({ ...formData, icon: name })}
className={cn(
"w-10 h-10 rounded-lg flex items-center justify-center border transition-colors",
formData.icon === name
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`icon-select-${name}`}
>
<Icon className="w-5 h-5" />
</button>
))}
</div>
</div>
{/* Model Selection - Claude */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-primary" />
Claude Models
</Label>
<div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => handleModelChange(id)}
className={cn(
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
formData.model === id
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`model-select-${id}`}
>
{label.replace("Claude ", "")}
</button>
))}
</div>
</div>
{/* Model Selection - Codex */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Zap className="w-4 h-4 text-emerald-500" />
Codex Models
</Label>
<div className="flex gap-2 flex-wrap">
{CODEX_MODELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => handleModelChange(id)}
className={cn(
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
formData.model === id
? "bg-emerald-600 text-white border-emerald-500"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`model-select-${id}`}
>
{label.replace("GPT-5.1 ", "").replace("Codex ", "")}
</button>
))}
</div>
</div>
{/* Thinking Level - Only for Claude models */}
{supportsThinking && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-amber-500" />
Thinking Level
</Label>
<div className="flex gap-2 flex-wrap">
{THINKING_LEVELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => {
setFormData({ ...formData, thinkingLevel: id });
if (id === "ultrathink") {
toast.warning("Ultrathink uses extensive reasoning", {
description:
"Best for complex architecture, migrations, or deep debugging (~$0.48/task).",
duration: 4000,
});
}
}}
className={cn(
"flex-1 min-w-[70px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
formData.thinkingLevel === id
? "bg-amber-500 text-white border-amber-400"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`thinking-select-${id}`}
>
{label}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
Higher levels give more time to reason through complex problems.
</p>
</div>
)}
{/* Actions */}
<DialogFooter className="pt-4">
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
<HotkeyButton
onClick={handleSubmit}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={hotkeyActive}
data-testid="save-profile-button"
>
{isEditing ? "Save Changes" : "Create Profile"}
</HotkeyButton>
</DialogFooter>
</div>
);
}
export function ProfilesView() {
const {
aiProfiles,
addAIProfile,
updateAIProfile,
removeAIProfile,
reorderAIProfiles,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingProfile, setEditingProfile] = useState<AIProfile | null>(null);
// Sensors for drag-and-drop
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5,
},
})
);
// Separate built-in and custom profiles
const builtInProfiles = useMemo(
() => aiProfiles.filter((p) => p.isBuiltIn),
[aiProfiles]
);
const customProfiles = useMemo(
() => aiProfiles.filter((p) => !p.isBuiltIn),
[aiProfiles]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = aiProfiles.findIndex((p) => p.id === active.id);
const newIndex = aiProfiles.findIndex((p) => p.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
reorderAIProfiles(oldIndex, newIndex);
}
}
},
[aiProfiles, reorderAIProfiles]
);
const handleAddProfile = (profile: Omit<AIProfile, "id">) => {
addAIProfile(profile);
setShowAddDialog(false);
toast.success("Profile created", {
description: `Created "${profile.name}" profile`,
});
};
const handleUpdateProfile = (profile: Omit<AIProfile, "id">) => {
if (editingProfile) {
updateAIProfile(editingProfile.id, profile);
setEditingProfile(null);
toast.success("Profile updated", {
description: `Updated "${profile.name}" profile`,
});
}
};
const handleDeleteProfile = (profile: AIProfile) => {
if (profile.isBuiltIn) return;
removeAIProfile(profile.id);
toast.success("Profile deleted", {
description: `Deleted "${profile.name}" profile`,
});
};
// Build keyboard shortcuts for profiles view
const profilesShortcuts: KeyboardShortcut[] = useMemo(() => {
const shortcutsList: KeyboardShortcut[] = [];
// Add profile shortcut - when in profiles view
shortcutsList.push({
key: shortcuts.addProfile,
action: () => setShowAddDialog(true),
description: "Create new profile",
});
return shortcutsList;
}, [shortcuts]);
// Register keyboard shortcuts for profiles view
useKeyboardShortcuts(profilesShortcuts);
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="profiles-view"
>
{/* Header Section */}
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
<div className="px-8 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
<UserCircle className="w-5 h-5 text-primary-foreground" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">
AI Profiles
</h1>
<p className="text-sm text-muted-foreground">
Create and manage model configuration presets
</p>
</div>
</div>
<HotkeyButton
onClick={() => setShowAddDialog(true)}
hotkey={shortcuts.addProfile}
hotkeyActive={false}
data-testid="add-profile-button"
>
<Plus className="w-4 h-4 mr-2" />
New Profile
</HotkeyButton>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-4xl mx-auto space-y-8">
{/* Custom Profiles Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-foreground">
Custom Profiles
</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{customProfiles.length}
</span>
</div>
{customProfiles.length === 0 ? (
<div className="rounded-xl border border-dashed border-border p-8 text-center">
<Sparkles className="w-10 h-10 text-muted-foreground mx-auto mb-3 opacity-50" />
<p className="text-muted-foreground">
No custom profiles yet. Create one to get started!
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => setShowAddDialog(true)}
>
<Plus className="w-4 h-4 mr-2" />
Create Profile
</Button>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={customProfiles.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{customProfiles.map((profile) => (
<SortableProfileCard
key={profile.id}
profile={profile}
onEdit={() => setEditingProfile(profile)}
onDelete={() => handleDeleteProfile(profile)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
{/* Built-in Profiles Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-foreground">
Built-in Profiles
</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
{builtInProfiles.length}
</span>
</div>
<p className="text-sm text-muted-foreground mb-4">
Pre-configured profiles for common use cases. These cannot be
edited or deleted.
</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={builtInProfiles.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{builtInProfiles.map((profile) => (
<SortableProfileCard
key={profile.id}
profile={profile}
onEdit={() => {}}
onDelete={() => {}}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
</div>
</div>
{/* Add Profile Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent data-testid="add-profile-dialog">
<DialogHeader>
<DialogTitle>Create New Profile</DialogTitle>
<DialogDescription>
Define a reusable model configuration preset.
</DialogDescription>
</DialogHeader>
<ProfileForm
profile={{}}
onSave={handleAddProfile}
onCancel={() => setShowAddDialog(false)}
isEditing={false}
hotkeyActive={showAddDialog}
/>
</DialogContent>
</Dialog>
{/* Edit Profile Dialog */}
<Dialog
open={!!editingProfile}
onOpenChange={() => setEditingProfile(null)}
>
<DialogContent data-testid="edit-profile-dialog">
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Modify your profile settings.</DialogDescription>
</DialogHeader>
{editingProfile && (
<ProfileForm
profile={editingProfile}
onSave={handleUpdateProfile}
onCancel={() => setEditingProfile(null)}
isEditing={true}
hotkeyActive={!!editingProfile}
/>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,210 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Bot, Folder, Loader2, RefreshCw, Square, Activity } from "lucide-react";
import { getElectronAPI, RunningAgent } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function RunningAgentsView() {
const [runningAgents, setRunningAgents] = useState<RunningAgent[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const { setCurrentProject, projects, setCurrentView } = useAppStore();
const fetchRunningAgents = useCallback(async () => {
try {
const api = getElectronAPI();
if (api.runningAgents) {
const result = await api.runningAgents.getAll();
if (result.success && result.runningAgents) {
setRunningAgents(result.runningAgents);
}
}
} catch (error) {
console.error("[RunningAgentsView] Error fetching running agents:", error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
// Initial fetch
useEffect(() => {
fetchRunningAgents();
}, [fetchRunningAgents]);
// Auto-refresh every 2 seconds
useEffect(() => {
const interval = setInterval(() => {
fetchRunningAgents();
}, 2000);
return () => clearInterval(interval);
}, [fetchRunningAgents]);
// Subscribe to auto-mode events to update in real-time
useEffect(() => {
const api = getElectronAPI();
if (!api.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event) => {
// When a feature completes or errors, refresh the list
if (
event.type === "auto_mode_feature_complete" ||
event.type === "auto_mode_error"
) {
fetchRunningAgents();
}
});
return () => {
unsubscribe();
};
}, [fetchRunningAgents]);
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchRunningAgents();
}, [fetchRunningAgents]);
const handleStopAgent = useCallback(async (featureId: string) => {
try {
const api = getElectronAPI();
if (api.autoMode) {
await api.autoMode.stopFeature(featureId);
// Refresh list after stopping
fetchRunningAgents();
}
} catch (error) {
console.error("[RunningAgentsView] Error stopping agent:", error);
}
}, [fetchRunningAgents]);
const handleNavigateToProject = useCallback((agent: RunningAgent) => {
// Find the project by path
const project = projects.find((p) => p.path === agent.projectPath);
if (project) {
setCurrentProject(project);
setCurrentView("board");
}
}, [projects, setCurrentProject, setCurrentView]);
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex-1 flex flex-col overflow-hidden p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-brand-500/10">
<Activity className="h-6 w-6 text-brand-500" />
</div>
<div>
<h1 className="text-2xl font-bold">Running Agents</h1>
<p className="text-sm text-muted-foreground">
{runningAgents.length === 0
? "No agents currently running"
: `${runningAgents.length} agent${runningAgents.length === 1 ? "" : "s"} running across all projects`}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={refreshing}
>
<RefreshCw
className={cn("h-4 w-4 mr-2", refreshing && "animate-spin")}
/>
Refresh
</Button>
</div>
{/* Content */}
{runningAgents.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<Bot className="h-12 w-12 text-muted-foreground" />
</div>
<h2 className="text-lg font-medium mb-2">No Running Agents</h2>
<p className="text-muted-foreground max-w-md">
Agents will appear here when they are actively working on features.
Start an agent from the Kanban board by dragging a feature to "In Progress".
</p>
</div>
) : (
<div className="flex-1 overflow-auto">
<div className="space-y-3">
{runningAgents.map((agent) => (
<div
key={`${agent.projectPath}-${agent.featureId}`}
className="flex items-center justify-between p-4 rounded-lg border border-border bg-card hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-4 min-w-0">
{/* Status indicator */}
<div className="relative">
<Bot className="h-8 w-8 text-brand-500" />
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
</span>
</div>
{/* Agent info */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">
{agent.featureId}
</span>
{agent.isAutoMode && (
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-brand-500/10 text-brand-500 border border-brand-500/30">
AUTO
</span>
)}
</div>
<button
onClick={() => handleNavigateToProject(agent)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Folder className="h-3.5 w-3.5" />
<span className="truncate">{agent.projectName}</span>
</button>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => handleNavigateToProject(agent)}
className="text-muted-foreground hover:text-foreground"
>
View Project
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleStopAgent(agent.featureId)}
>
<Square className="h-3.5 w-3.5 mr-1.5" />
Stop
</Button>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,253 @@
"use client";
import { useState } from "react";
import { useAppStore } from "@/store/app-store";
import { Label } from "@/components/ui/label";
import {
Key,
Palette,
Terminal,
Atom,
FlaskConical,
Trash2,
Settings2,
Volume2,
VolumeX,
} from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { useCliStatus } from "./settings-view/hooks/use-cli-status";
import { useScrollTracking } from "@/hooks/use-scroll-tracking";
import { SettingsHeader } from "./settings-view/components/settings-header";
import { KeyboardMapDialog } from "./settings-view/components/keyboard-map-dialog";
import { DeleteProjectDialog } from "./settings-view/components/delete-project-dialog";
import { SettingsNavigation } from "./settings-view/components/settings-navigation";
import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section";
import { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status";
import { CodexCliStatus } from "./settings-view/cli-status/codex-cli-status";
import { AppearanceSection } from "./settings-view/appearance/appearance-section";
import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section";
import { FeatureDefaultsSection } from "./settings-view/feature-defaults/feature-defaults-section";
import { DangerZoneSection } from "./settings-view/danger-zone/danger-zone-section";
import type {
Project as SettingsProject,
Theme,
} from "./settings-view/shared/types";
import type { Project as ElectronProject } from "@/lib/electron";
// Navigation items for the side panel
const NAV_ITEMS = [
{ id: "api-keys", label: "API Keys", icon: Key },
{ id: "claude", label: "Claude", icon: Terminal },
{ id: "codex", label: "Codex", icon: Atom },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "audio", label: "Audio", icon: Volume2 },
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
{ id: "danger", label: "Danger Zone", icon: Trash2 },
];
export function SettingsView() {
const {
theme,
setTheme,
setProjectTheme,
defaultSkipTests,
setDefaultSkipTests,
useWorktrees,
setUseWorktrees,
showProfilesOnly,
setShowProfilesOnly,
muteDoneSound,
setMuteDoneSound,
currentProject,
moveProjectToTrash,
} = useAppStore();
// Convert electron Project to settings-view Project type
const convertProject = (
project: ElectronProject | null
): SettingsProject | null => {
if (!project) return null;
return {
id: project.id,
name: project.name,
path: project.path,
theme: project.theme as Theme | undefined,
};
};
const settingsProject = convertProject(currentProject);
// Compute the effective theme for the current project
const effectiveTheme = (settingsProject?.theme || theme) as Theme;
// Handler to set theme - saves to project if one is selected, otherwise to global
const handleSetTheme = (newTheme: typeof theme) => {
if (currentProject) {
setProjectTheme(currentProject.id, newTheme);
} else {
setTheme(newTheme);
}
};
// Use CLI status hook
const {
claudeCliStatus,
codexCliStatus,
isCheckingClaudeCli,
isCheckingCodexCli,
handleRefreshClaudeCli,
handleRefreshCodexCli,
} = useCliStatus();
// Use scroll tracking hook
const { activeSection, scrollToSection, scrollContainerRef } =
useScrollTracking({
items: NAV_ITEMS,
filterFn: (item) => item.id !== "danger" || !!currentProject,
initialSection: "api-keys",
});
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="settings-view"
>
{/* Header Section */}
<SettingsHeader />
{/* Content Area with Sidebar */}
<div className="flex-1 flex overflow-hidden">
{/* Sticky Side Navigation */}
<SettingsNavigation
navItems={NAV_ITEMS}
activeSection={activeSection}
currentProject={currentProject}
onNavigate={scrollToSection}
/>
{/* Scrollable Content */}
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-8">
<div className="max-w-4xl mx-auto space-y-6 pb-96">
{/* API Keys Section */}
<ApiKeysSection />
{/* Claude CLI Status Section */}
{claudeCliStatus && (
<ClaudeCliStatus
status={claudeCliStatus}
isChecking={isCheckingClaudeCli}
onRefresh={handleRefreshClaudeCli}
/>
)}
{/* Codex CLI Status Section */}
{codexCliStatus && (
<CodexCliStatus
status={codexCliStatus}
isChecking={isCheckingCodexCli}
onRefresh={handleRefreshCodexCli}
/>
)}
{/* Appearance Section */}
<AppearanceSection
effectiveTheme={effectiveTheme}
currentProject={settingsProject}
onThemeChange={handleSetTheme}
/>
{/* Keyboard Shortcuts Section */}
<KeyboardShortcutsSection
onOpenKeyboardMap={() => setShowKeyboardMapDialog(true)}
/>
{/* Audio Section */}
<div
id="audio"
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
>
<div className="p-6 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<Volume2 className="w-5 h-5 text-brand-500" />
<h2 className="text-lg font-semibold text-foreground">
Audio
</h2>
</div>
<p className="text-sm text-muted-foreground">
Configure audio and notification settings.
</p>
</div>
<div className="p-6 space-y-4">
{/* Mute Done Sound Setting */}
<div className="space-y-3">
<div className="flex items-start space-x-3">
<Checkbox
id="mute-done-sound"
checked={muteDoneSound}
onCheckedChange={(checked) =>
setMuteDoneSound(checked === true)
}
className="mt-0.5"
data-testid="mute-done-sound-checkbox"
/>
<div className="space-y-1">
<Label
htmlFor="mute-done-sound"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<VolumeX className="w-4 h-4 text-brand-500" />
Mute notification sound when agents complete
</Label>
<p className="text-xs text-muted-foreground">
When enabled, disables the &quot;ding&quot; sound that
plays when an agent completes a feature. The feature
will still move to the completed column, but without
audio notification.
</p>
</div>
</div>
</div>
</div>
</div>
{/* Feature Defaults Section */}
<FeatureDefaultsSection
showProfilesOnly={showProfilesOnly}
defaultSkipTests={defaultSkipTests}
useWorktrees={useWorktrees}
onShowProfilesOnlyChange={setShowProfilesOnly}
onDefaultSkipTestsChange={setDefaultSkipTests}
onUseWorktreesChange={setUseWorktrees}
/>
{/* Danger Zone Section - Only show when a project is selected */}
<DangerZoneSection
project={settingsProject}
onDeleteClick={() => setShowDeleteDialog(true)}
/>
</div>
</div>
</div>
{/* Keyboard Map Dialog */}
<KeyboardMapDialog
open={showKeyboardMapDialog}
onOpenChange={setShowKeyboardMapDialog}
/>
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AlertCircle, CheckCircle2, Eye, EyeOff, Loader2, Zap } from "lucide-react";
import type { ProviderConfig } from "@/config/api-providers";
interface ApiKeyFieldProps {
config: ProviderConfig;
}
export function ApiKeyField({ config }: ApiKeyFieldProps) {
const {
label,
inputId,
placeholder,
value,
setValue,
showValue,
setShowValue,
hasStoredKey,
inputTestId,
toggleTestId,
testButton,
result,
resultTestId,
resultMessageTestId,
descriptionPrefix,
descriptionLinkHref,
descriptionLinkText,
descriptionSuffix,
} = config;
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Label htmlFor={inputId} className="text-foreground">
{label}
</Label>
{hasStoredKey && <CheckCircle2 className="w-4 h-4 text-brand-500" />}
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id={inputId}
type={showValue ? "text" : "password"}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
className="pr-10 bg-input border-border text-foreground placeholder:text-muted-foreground"
data-testid={inputTestId}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:text-foreground hover:bg-transparent"
onClick={() => setShowValue(!showValue)}
data-testid={toggleTestId}
>
{showValue ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
<Button
type="button"
variant="secondary"
onClick={testButton.onClick}
disabled={testButton.disabled}
className="bg-secondary hover:bg-accent text-secondary-foreground border border-border"
data-testid={testButton.testId}
>
{testButton.loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Testing...
</>
) : (
<>
<Zap className="w-4 h-4 mr-2" />
Test
</>
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{descriptionPrefix}{" "}
<a
href={descriptionLinkHref}
target="_blank"
rel="noopener noreferrer"
className="text-brand-500 hover:text-brand-400 hover:underline"
>
{descriptionLinkText}
</a>
{descriptionSuffix}
</p>
{result && (
<div
className={`flex items-center gap-2 p-3 rounded-lg ${
result.success
? "bg-green-500/10 border border-green-500/20 text-green-400"
: "bg-red-500/10 border border-red-500/20 text-red-400"
}`}
data-testid={resultTestId}
>
{result.success ? (
<CheckCircle2 className="w-4 h-4" />
) : (
<AlertCircle className="w-4 h-4" />
)}
<span className="text-sm" data-testid={resultMessageTestId}>
{result.message}
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { useAppStore } from "@/store/app-store";
import { useSetupStore } from "@/store/setup-store";
import { Button } from "@/components/ui/button";
import { Key, CheckCircle2 } from "lucide-react";
import { ApiKeyField } from "./api-key-field";
import { buildProviderConfigs } from "@/config/api-providers";
import { AuthenticationStatusDisplay } from "./authentication-status-display";
import { SecurityNotice } from "./security-notice";
import { useApiKeyManagement } from "./hooks/use-api-key-management";
export function ApiKeysSection() {
const { apiKeys } = useAppStore();
const { claudeAuthStatus, codexAuthStatus } = useSetupStore();
const { providerConfigParams, apiKeyStatus, handleSave, saved } =
useApiKeyManagement();
const providerConfigs = buildProviderConfigs(providerConfigParams);
return (
<div
id="api-keys"
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
>
<div className="p-6 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<Key className="w-5 h-5 text-brand-500" />
<h2 className="text-lg font-semibold text-foreground">API Keys</h2>
</div>
<p className="text-sm text-muted-foreground">
Configure your AI provider API keys. Keys are stored locally in your
browser.
</p>
</div>
<div className="p-6 space-y-6">
{/* API Key Fields */}
{providerConfigs.map((provider) => (
<ApiKeyField key={provider.key} config={provider} />
))}
{/* Authentication Status Display */}
<AuthenticationStatusDisplay
claudeAuthStatus={claudeAuthStatus}
codexAuthStatus={codexAuthStatus}
apiKeyStatus={apiKeyStatus}
apiKeys={apiKeys}
/>
{/* Security Notice */}
<SecurityNotice />
{/* Save Button */}
<div className="flex items-center gap-4 pt-2">
<Button
onClick={handleSave}
data-testid="save-settings"
className="min-w-[120px] bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-primary-foreground border-0"
>
{saved ? (
<>
<CheckCircle2 className="w-4 h-4 mr-2" />
Saved!
</>
) : (
"Save API Keys"
)}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,182 @@
import { Label } from "@/components/ui/label";
import {
CheckCircle2,
AlertCircle,
Info,
Terminal,
Atom,
Sparkles,
} from "lucide-react";
import type { ClaudeAuthStatus, CodexAuthStatus } from "@/store/setup-store";
interface AuthenticationStatusDisplayProps {
claudeAuthStatus: ClaudeAuthStatus | null;
codexAuthStatus: CodexAuthStatus | null;
apiKeyStatus: {
hasAnthropicKey: boolean;
hasOpenAIKey: boolean;
hasGoogleKey: boolean;
} | null;
apiKeys: {
anthropic: string;
google: string;
openai: string;
};
}
export function AuthenticationStatusDisplay({
claudeAuthStatus,
codexAuthStatus,
apiKeyStatus,
apiKeys,
}: AuthenticationStatusDisplayProps) {
return (
<div className="space-y-4 pt-4 border-t border-border">
<div className="flex items-center gap-2 mb-3">
<Info className="w-4 h-4 text-brand-500" />
<Label className="text-foreground font-semibold">
Current Authentication Configuration
</Label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Claude Authentication Status */}
<div className="p-3 rounded-lg bg-card border border-border">
<div className="flex items-center gap-2 mb-1.5">
<Terminal className="w-4 h-4 text-brand-500" />
<span className="text-sm font-medium text-foreground">
Claude (Anthropic)
</span>
</div>
<div className="space-y-1.5 text-xs min-h-12">
{claudeAuthStatus?.authenticated ? (
<>
<div className="flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
<span className="text-green-400 font-medium">Authenticated</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="w-3 h-3 shrink-0" />
<span>
{claudeAuthStatus.method === "oauth_token_env"
? "Using CLAUDE_CODE_OAUTH_TOKEN"
: claudeAuthStatus.method === "oauth_token"
? "Using stored OAuth token"
: claudeAuthStatus.method === "api_key_env"
? "Using ANTHROPIC_API_KEY"
: claudeAuthStatus.method === "api_key"
? "Using stored API key"
: "Unknown method"}
</span>
</div>
</>
) : apiKeyStatus?.hasAnthropicKey ? (
<div className="flex items-center gap-2 text-blue-400">
<Info className="w-3 h-3 shrink-0" />
<span>Using environment variable (ANTHROPIC_API_KEY)</span>
</div>
) : apiKeys.anthropic ? (
<div className="flex items-center gap-2 text-blue-400">
<Info className="w-3 h-3 shrink-0" />
<span>Using manual API key from settings</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-yellow-500 py-0.5">
<AlertCircle className="w-3 h-3 shrink-0" />
<span className="text-xs">Not configured</span>
</div>
)}
</div>
</div>
{/* Codex/OpenAI Authentication Status */}
<div className="p-3 rounded-lg bg-card border border-border">
<div className="flex items-center gap-2 mb-1.5">
<Atom className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium text-foreground">
Codex (OpenAI)
</span>
</div>
<div className="space-y-1.5 text-xs min-h-12">
{codexAuthStatus?.authenticated ? (
<>
<div className="flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
<span className="text-green-400 font-medium">Authenticated</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="w-3 h-3 shrink-0" />
<span>
{codexAuthStatus.method === "cli_verified" ||
codexAuthStatus.method === "cli_tokens"
? "Using CLI login (OpenAI account)"
: codexAuthStatus.method === "api_key"
? "Using stored API key"
: codexAuthStatus.method === "env"
? "Using OPENAI_API_KEY"
: "Unknown method"}
</span>
</div>
</>
) : apiKeyStatus?.hasOpenAIKey ? (
<div className="flex items-center gap-2 text-blue-400">
<Info className="w-3 h-3 shrink-0" />
<span>Using environment variable (OPENAI_API_KEY)</span>
</div>
) : apiKeys.openai ? (
<div className="flex items-center gap-2 text-blue-400">
<Info className="w-3 h-3 shrink-0" />
<span>Using manual API key from settings</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-yellow-500 py-0.5">
<AlertCircle className="w-3 h-3 shrink-0" />
<span className="text-xs">Not configured</span>
</div>
)}
</div>
</div>
{/* Google/Gemini Authentication Status */}
<div className="p-3 rounded-lg bg-card border border-border">
<div className="flex items-center gap-2 mb-1.5">
<Sparkles className="w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-foreground">
Gemini (Google)
</span>
</div>
<div className="space-y-1.5 text-xs min-h-12">
{apiKeyStatus?.hasGoogleKey ? (
<>
<div className="flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
<span className="text-green-400 font-medium">Authenticated</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="w-3 h-3 shrink-0" />
<span>Using GOOGLE_API_KEY</span>
</div>
</>
) : apiKeys.google ? (
<>
<div className="flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
<span className="text-green-400 font-medium">Authenticated</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="w-3 h-3 shrink-0" />
<span>Using stored API key</span>
</div>
</>
) : (
<div className="flex items-center gap-1.5 text-yellow-500 py-0.5">
<AlertCircle className="w-3 h-3 shrink-0" />
<span className="text-xs">Not configured</span>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,265 @@
import { useState, useEffect } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import type { ProviderConfigParams } from "@/config/api-providers";
interface TestResult {
success: boolean;
message: string;
}
interface ApiKeyStatus {
hasAnthropicKey: boolean;
hasOpenAIKey: boolean;
hasGoogleKey: boolean;
}
/**
* Custom hook for managing API key state and operations
* Handles input values, visibility toggles, connection testing, and saving
*/
export function useApiKeyManagement() {
const { apiKeys, setApiKeys } = useAppStore();
// API key values
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
const [googleKey, setGoogleKey] = useState(apiKeys.google);
const [openaiKey, setOpenaiKey] = useState(apiKeys.openai);
// Visibility toggles
const [showAnthropicKey, setShowAnthropicKey] = useState(false);
const [showGoogleKey, setShowGoogleKey] = useState(false);
const [showOpenaiKey, setShowOpenaiKey] = useState(false);
// Test connection states
const [testingConnection, setTestingConnection] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [testingGeminiConnection, setTestingGeminiConnection] = useState(false);
const [geminiTestResult, setGeminiTestResult] = useState<TestResult | null>(
null
);
const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false);
const [openaiTestResult, setOpenaiTestResult] = useState<TestResult | null>(
null
);
// API key status from environment
const [apiKeyStatus, setApiKeyStatus] = useState<ApiKeyStatus | null>(null);
// Save state
const [saved, setSaved] = useState(false);
// Sync local state with store
useEffect(() => {
setAnthropicKey(apiKeys.anthropic);
setGoogleKey(apiKeys.google);
setOpenaiKey(apiKeys.openai);
}, [apiKeys]);
// Check API key status from environment on mount
useEffect(() => {
const checkApiKeyStatus = async () => {
const api = getElectronAPI();
if (api?.setup?.getApiKeys) {
try {
const status = await api.setup.getApiKeys();
if (status.success) {
setApiKeyStatus({
hasAnthropicKey: status.hasAnthropicKey,
hasOpenAIKey: status.hasOpenAIKey,
hasGoogleKey: status.hasGoogleKey,
});
}
} catch (error) {
console.error("Failed to check API key status:", error);
}
}
};
checkApiKeyStatus();
}, []);
// Test Anthropic/Claude connection
const handleTestAnthropicConnection = async () => {
setTestingConnection(true);
setTestResult(null);
try {
const response = await fetch("/api/claude/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ apiKey: anthropicKey }),
});
const data = await response.json();
if (response.ok && data.success) {
setTestResult({
success: true,
message: data.message || "Connection successful! Claude responded.",
});
} else {
setTestResult({
success: false,
message: data.error || "Failed to connect to Claude API.",
});
}
} catch {
setTestResult({
success: false,
message: "Network error. Please check your connection.",
});
} finally {
setTestingConnection(false);
}
};
// Test Google/Gemini connection
const handleTestGeminiConnection = async () => {
setTestingGeminiConnection(true);
setGeminiTestResult(null);
try {
const response = await fetch("/api/gemini/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ apiKey: googleKey }),
});
const data = await response.json();
if (response.ok && data.success) {
setGeminiTestResult({
success: true,
message: data.message || "Connection successful! Gemini responded.",
});
} else {
setGeminiTestResult({
success: false,
message: data.error || "Failed to connect to Gemini API.",
});
}
} catch {
setGeminiTestResult({
success: false,
message: "Network error. Please check your connection.",
});
} finally {
setTestingGeminiConnection(false);
}
};
// Test OpenAI connection
const handleTestOpenaiConnection = async () => {
setTestingOpenaiConnection(true);
setOpenaiTestResult(null);
try {
const api = getElectronAPI();
if (api?.testOpenAIConnection) {
const result = await api.testOpenAIConnection(openaiKey);
if (result.success) {
setOpenaiTestResult({
success: true,
message:
result.message || "Connection successful! OpenAI API responded.",
});
} else {
setOpenaiTestResult({
success: false,
message: result.error || "Failed to connect to OpenAI API.",
});
}
} else {
// Fallback to web API test
const response = await fetch("/api/openai/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ apiKey: openaiKey }),
});
const data = await response.json();
if (response.ok && data.success) {
setOpenaiTestResult({
success: true,
message:
data.message || "Connection successful! OpenAI API responded.",
});
} else {
setOpenaiTestResult({
success: false,
message: data.error || "Failed to connect to OpenAI API.",
});
}
}
} catch {
setOpenaiTestResult({
success: false,
message: "Network error. Please check your connection.",
});
} finally {
setTestingOpenaiConnection(false);
}
};
// Save API keys
const handleSave = () => {
setApiKeys({
anthropic: anthropicKey,
google: googleKey,
openai: openaiKey,
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
// Build provider config params for buildProviderConfigs
const providerConfigParams: ProviderConfigParams = {
apiKeys,
anthropic: {
value: anthropicKey,
setValue: setAnthropicKey,
show: showAnthropicKey,
setShow: setShowAnthropicKey,
testing: testingConnection,
onTest: handleTestAnthropicConnection,
result: testResult,
},
google: {
value: googleKey,
setValue: setGoogleKey,
show: showGoogleKey,
setShow: setShowGoogleKey,
testing: testingGeminiConnection,
onTest: handleTestGeminiConnection,
result: geminiTestResult,
},
openai: {
value: openaiKey,
setValue: setOpenaiKey,
show: showOpenaiKey,
setShow: setShowOpenaiKey,
testing: testingOpenaiConnection,
onTest: handleTestOpenaiConnection,
result: openaiTestResult,
},
};
return {
// Provider config params for buildProviderConfigs
providerConfigParams,
// API key status from environment
apiKeyStatus,
// Save handler and state
handleSave,
saved,
};
}

View File

@@ -0,0 +1,21 @@
import { AlertCircle } from "lucide-react";
interface SecurityNoticeProps {
title?: string;
message?: string;
}
export function SecurityNotice({
title = "Security Notice",
message = "API keys are stored in your browser's local storage. Never share your API keys or commit them to version control.",
}: SecurityNoticeProps) {
return (
<div className="flex items-start gap-3 p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5 shrink-0" />
<div className="text-sm">
<p className="font-medium text-yellow-500">{title}</p>
<p className="text-yellow-500/80 text-xs mt-1">{message}</p>
</div>
</div>
);
}

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