Compare commits

...

55 Commits

Author SHA1 Message Date
Web Dev Cody
a2d2f52cf8 Merge pull request #24 from AutoMaker-Org/fix-build
feat: add PR build check workflow and enhance feature management
2025-12-10 23:47:44 -05:00
Cody Seibert
7c7a044417 feat: update application icons and add marketing assets
- Replaced the application icon in package.json with a larger version for better visibility.
- Added a new logo image file `logo_larger.png` to the public directory.
- Introduced a Dockerfile for the marketing section to serve static files using Nginx.
- Created a new `index.html` file for the marketing site, featuring a responsive layout and sections for features and technology stack.
2025-12-10 23:43:23 -05:00
Cody Seibert
58a59aa391 Merge branch 'main' into fix-build 2025-12-10 23:37:14 -05:00
Web Dev Cody
9c6ff4c2e3 Merge pull request #21 from AutoMaker-Org/fix/auth-status-display-and-console-cleanup
fix: improve auth status display and remove verbose console logging
2025-12-10 23:32:46 -05:00
Cody Seibert
f2a443afad feat: move "Report Bug / Feature Request" button to header
- Relocated the "Report Bug / Feature Request" button from the bottom of the sidebar to the header, next to the AutoMaker logo for improved accessibility.
- Updated the button to be a compact icon-only version with a tooltip on hover.
- Adjusted the header layout to accommodate the new button placement.
- Removed the old button from the sidebar to streamline the interface.
2025-12-10 23:28:29 -05:00
Cody Seibert
9dc3124738 feat: update package.json with project metadata
- Added project description, homepage, author details, and maintainer information to package.json for better project documentation and visibility.
2025-12-10 23:17:47 -05:00
SuperComboGamer
e553b39454 fix 2025-12-10 23:14:23 -05:00
SuperComboGamer
0fca89e0b5 Merge pull request #23 from AutoMaker-Org/wsl
wsl
2025-12-10 23:13:28 -05:00
Cody Seibert
67a448ce91 feat: add PR build check workflow and enhance feature management
- Introduced a new GitHub Actions workflow for PR build checks to ensure code quality and consistency.
- Updated `analysis-view.tsx`, `interview-view.tsx`, and `setup-view.tsx` to incorporate a new `Feature` type for better feature management.
- Refactored various components to improve code readability and maintainability.
- Adjusted type imports in `delete-project-dialog.tsx` and `settings-navigation.tsx` for consistency.
- Enhanced project initialization logic in `project-init.ts` to ensure proper type handling.
- Updated Electron API types in `electron.d.ts` for better clarity and functionality.
2025-12-10 23:10:04 -05:00
SuperComboGamer
081fc09e4f wsl 2025-12-10 23:09:15 -05:00
SuperComboGamer
f96fa6561e Update README.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-10 20:08:13 -08:00
SuperComboGamer
c667c1c682 Update app/electron/services/claude-cli-detector.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-10 20:07:54 -08:00
SuperComboGamer
d474208e8b fix: improve auth status display and remove verbose console logging
- Fix authentication status display in settings showing "Method: Unknown"
  - Add support for CLAUDE_CODE_OAUTH_TOKEN environment variable
  - Update ClaudeAuthStatus type to include all auth methods
  - Fix method mapping in use-cli-status hook
  - Display correct auth method labels in UI

- Remove verbose console logging from:
  - claude-cli-detector.js
  - codex-cli-detector.js
  - agent-service.js
  - main.js (IPC, Security logs)

- Fix TypeScript errors:
  - Add proper type exports for AutoModeEvent
  - Fix Project import paths
  - Add null checks for api.features
  - Add openExternalLink to ElectronAPI type
  - Add type annotation for REQUIRED_STRUCTURE

- Update README with clearer getting started guide

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 22:28:27 -05:00
Web Dev Cody
41f14167a6 Merge pull request #20 from AutoMaker-Org/running-agents-list
feat: implement running agents view and enhance auto mode functionality
2025-12-10 22:04:48 -05:00
Cody Seibert
f17abc93c2 Merge branch 'main' into running-agents-list 2025-12-10 21:53:41 -05:00
Cody Seibert
d08f922631 feat: implement running agents view and enhance auto mode functionality
- Added a new `RunningAgentsView` component to display currently active agents working on features.
- Implemented auto-refresh functionality for the running agents list every 2 seconds.
- Enhanced the auto mode service to support project-specific operations, including starting and stopping auto mode for individual projects.
- Updated IPC handlers to manage auto mode status and running agents more effectively.
- Introduced audio settings to mute notifications when agents complete tasks.
- Refactored existing components to accommodate new features and improve overall user experience.
2025-12-10 21:51:00 -05:00
Shirone
bfc6be9589 Merge pull request #18 from AutoMaker-Org/feat/rework-keybinds-and-setting-page
Feat/rework keybinds and setting page
2025-12-11 02:31:08 +01:00
Kacper
43c90adbc0 fix: implement copilot suggestions 2025-12-11 02:07:15 +01:00
Cody Seibert
5ac81ce5a9 feat: add new feature configuration for 'do nothing, code nothing, print yolo'
- Created a new feature JSON file to define a feature that requires no code changes.
- Added corresponding git state file to track the feature's status and untracked files.
2025-12-10 19:59:04 -05:00
Kacper
085f5d5d39 Merge main into feat/rework-keybinds-and-setting-page - resolved conflicts in settings-view.tsx 2025-12-11 01:35:02 +01:00
Kacper
08de89344c chore: add trailing newlines for consistency
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 01:26:10 +01:00
Kacper
6ee50853dc refactor(settings): remove unused back-to-home button from SettingsView
- Eliminate the Back to Home button and its associated JSX from SettingsView component
- Clean up code by removing unnecessary imports and comments
- Enhance readability and maintainability of the SettingsView component
2025-12-11 01:22:03 +01:00
Web Dev Cody
d1b97a46a4 Merge pull request #17 from AutoMaker-Org/refactor-features-into-directories
feat: restructure feature management and update project files
2025-12-10 19:20:21 -05:00
Cody Seibert
def02444db Merge branch 'main' into refactor-features-into-directories 2025-12-10 19:20:14 -05:00
Web Dev Cody
802e8812f0 Merge pull request #16 from AutoMaker-Org/feature/ui-fixes-path-correction
UI fixes for path issues in project name, adjusted some UI styling in…
2025-12-10 19:14:55 -05:00
Web Dev Cody
4763355987 Merge pull request #14 from AutoMaker-Org/copilot/add-markdown-rendering
Fix markdown rendering in New Project Interview view
2025-12-10 19:14:00 -05:00
Cody Seibert
d9e459fdfb Merge branch 'main' into refactor-features-into-directories 2025-12-10 19:12:51 -05:00
Cody Seibert
15981c8e1b feat: restructure feature management and update project files
- Introduced a new `package-lock.json` to manage dependencies.
- Removed obsolete `.automaker/feature_list.json` and replaced it with a new structure under `.automaker/features/{id}/feature.json` for better organization.
- Updated various components to utilize the new features API for managing features, including creation, updates, and deletions.
- Enhanced the UI to reflect changes in feature management, including updates to the sidebar and board view.
- Improved documentation and comments throughout the codebase to clarify the new feature management process.
2025-12-10 19:11:36 -05:00
Kacper
772b0e9e5c refactor: move configs and hooks to global locations for reusability
Move previously nested configs and hooks to global src/ folders to make
them reusable across the application, reduce nesting, and establish
clearer organization patterns.

**New Global Structure:**
- src/config/theme-options.ts (moved from appearance/config/)
- src/config/api-providers.ts (moved from api-keys/config/)
- src/hooks/use-scroll-tracking.ts (moved from settings-view/hooks/)

**Changes:**
- Move theme-options.ts to src/config/ - app-wide theme configuration
- Move api-provider-config.ts to src/config/api-providers.ts - global API config
- Move use-scroll-tracking.ts to src/hooks/ - reusable scroll navigation hook
- Make useScrollTracking generic and more flexible with options object
- Update all imports across settings-view components
- Remove duplicate api-provider-config.ts from shared/ folder
- Remove empty config/ folders (appearance/config, api-keys/config)

**Benefits:**
 Single source of truth for themes and API providers
 Reusable scroll tracking hook available globally
 Cleaner structure with less nesting
 Better discoverability for developers
 No duplicate configuration files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 01:10:36 +01:00
Kacper
82cc8abd29 refactor(settings): enhance project handling in SettingsView
- Introduce type conversion for ElectronProject to SettingsProject
- Update effective theme calculation to use converted settingsProject
- Refactor currentProject references to use settingsProject in Appearance and DangerZone sections
- Improve type safety and maintainability in settings-view.tsx
2025-12-11 01:00:42 +01:00
Kacper
ac3ea90950 refactor(settings): extract settings header to component
- Create components/settings-header.tsx
- Move header section JSX to new component
- Update settings-view.tsx to use SettingsHeader component
- Remove unused Settings icon import
- Make header configurable with title and description props
- Reduce settings-view.tsx by ~16 lines
- Improve component modularity and reusability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 00:55:33 +01:00
Kacper
215ae87950 refactor(settings): extract settings navigation to component
- Create components/settings-navigation.tsx
- Move side navigation JSX to new component
- Update settings-view.tsx to use SettingsNavigation component
- Remove unused cn utility import
- Reduce settings-view.tsx by ~30 lines
- Improve component modularity and reusability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 00:54:09 +01:00
Kacper
f71d6da37d refactor(settings): extract delete project dialog to component
- Create components/delete-project-dialog.tsx
- Move delete project confirmation dialog JSX to new component
- Update settings-view.tsx to use DeleteProjectDialog component
- Remove unused Trash2, Folder icon imports
- Remove unused Dialog component imports
- Reduce settings-view.tsx by ~50 lines
- Improve component modularity and testability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 00:52:18 +01:00
Kacper
d8f55f26db refactor(settings): extract keyboard map dialog to component
- Create components/keyboard-map-dialog.tsx
- Move keyboard shortcut map dialog JSX to new component
- Update settings-view.tsx to use KeyboardMapDialog component
- Remove unused Keyboard icon import
- Remove unused KeyboardMap and ShortcutReferencePanel imports
- Remove unused useSetupStore import and destructuring
- Reduce settings-view.tsx by ~30 lines
- Improve component modularity and reusability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 00:50:38 +01:00
Kacper
8010a03a7c refactor(settings): extract navigation config to separate file
- Create config/navigation.ts with NAV_ITEMS and NavigationItem type
- Remove NAV_ITEMS constant from settings-view.tsx
- Update use-scroll-tracking.ts to import NavigationItem type
- Remove unused icon imports from settings-view.tsx
- Improve code organization and maintainability
- Reduce settings-view.tsx by ~10 lines

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 00:48:18 +01:00
Kacper
60fc043b1e refactor(settings): extract scroll tracking into custom hook
- Create hooks/use-scroll-tracking.ts for scroll-based navigation
- Move scroll position tracking logic and useEffect to hook
- Move scrollToSection callback to hook
- Update settings-view.tsx to use new useScrollTracking hook
- Remove useState, useEffect, useRef, useCallback imports (no longer needed)
- Reduce settings-view.tsx by ~60 lines
- Improve code organization and testability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 00:46:42 +01:00
Kacper
6bbcc36409 refactor(settings): extract CLI status into custom hook
- Create hooks/use-cli-status.ts to manage all CLI status logic
- Move Claude and Codex CLI status state management to hook
- Move CLI checking useEffect and refresh handlers to hook
- Update settings-view.tsx to use new useCliStatus hook
- Reduce settings-view.tsx by ~130 lines
- Improve testability by isolating CLI status logic

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 00:44:41 +01:00
Kacper
2d937bc47f refactor(settings): move types.ts to shared folder
- Move types.ts to shared/types.ts
- Update all section files to import from ../shared/types
- Update theme-options.ts to import from ../../shared/types
- All TypeScript diagnostics passing
- Completes settings-view folder restructuring

Final structure:
- api-keys/ (with hooks/, config/)
- appearance/ (with config/)
- cli-status/ (claude, codex)
- feature-defaults/
- keyboard-shortcuts/
- kanban-display/
- danger-zone/
- shared/ (types.ts)
2025-12-11 00:35:45 +01:00
Kacper
45bd2c64b9 refactor(settings): move remaining sections into folders
- Move feature-defaults-section.tsx into feature-defaults/
- Move keyboard-shortcuts-section.tsx into keyboard-shortcuts/
- Move kanban-display-section.tsx into kanban-display/
- Move danger-zone-section.tsx into danger-zone/
- Update settings-view.tsx to import from new locations
- Update type imports in kanban-display and danger-zone to ../types
- All TypeScript diagnostics passing
- Git preserves file history with rename detection
2025-12-11 00:34:11 +01:00
Kacper
2afb5a7645 refactor(settings): split CLI status into separate components
- Split cli-status-section.tsx into two separate files:
  - claude-cli-status.tsx (ClaudeCliStatus component)
  - codex-cli-status.tsx (CodexCliStatus component)
- Move both components into cli-status/ folder
- Update settings-view.tsx to import from new locations
- Update type imports to use ../types
- All TypeScript diagnostics passing
- Improves modularity and follows one-component-per-file pattern
2025-12-11 00:32:03 +01:00
Kacper
bd1ae73bb9 refactor(settings): remove empty shared directory 2025-12-11 00:29:16 +01:00
Kacper
7e3819da4b refactor(settings): reorganize appearance section into folder
- Move appearance-section.tsx into appearance/ folder
- Move theme-options.ts into appearance/config/
- Update import paths in appearance-section.tsx
- Update settings-view.tsx to import from new location
- All TypeScript diagnostics passing
- Follows api-keys folder pattern
2025-12-11 00:29:06 +01:00
Kacper
9af6866a9d refactor(settings): remove empty hooks directory 2025-12-11 00:24:36 +01:00
Kacper
da78bed47d refactor(settings): reorganize api-keys section into folder
- Move api-keys-section.tsx into api-keys/ folder
- Move child components (api-key-field, authentication-status-display, security-notice) into api-keys/
- Move custom hook (use-api-key-management) into api-keys/hooks/
- Move config (api-provider-config) into api-keys/config/
- Update import paths in use-api-key-management.ts
- Update settings-view.tsx to import from new location
- All TypeScript diagnostics passing
- Improves code organization and maintainability
2025-12-11 00:24:18 +01:00
trueheads
9954563581 UI fixes for path issues in project name, adjusted some UI styling inconsistencies as well. 2025-12-10 17:23:56 -06:00
copilot-swe-agent[bot]
a4dc21fd84 Add cleanup function to setTimeout in useEffect to prevent memory leaks
Co-authored-by: GTheMachine <156854865+GTheMachine@users.noreply.github.com>
2025-12-10 22:42:17 +00:00
Kacper
d5d6cdf80f refactor(auth): enhance authentication detection and status handling
- Improved the CodexCliDetector to provide detailed logging and better error handling when reading the authentication file.
- Updated the authentication method determination in the settings and setup views to prioritize CLI-based methods over traditional API key methods.
- Expanded the CodexAuthStatus interface to include new authentication methods, ensuring accurate representation of the authentication state.
- Enhanced UI feedback in the settings view to reflect the new authentication methods, improving user experience.
2025-12-10 23:35:09 +01:00
Kacper
6086d22a44 refactor(settings-view): streamline authentication status handling
- Removed unused state variables related to shortcut editing in the settings view.
- Updated authentication status handling for Claude and Codex to use more precise type definitions, improving type safety and clarity.
- Enhanced the ElectronAPI and SetupAPI interfaces to include optional properties for stored OAuth and API keys, ensuring better alignment with the runtime API responses.
2025-12-10 23:19:09 +01:00
Kacper
8a6309ccc9 Merge origin/main: resolve import conflict in settings-view.tsx 2025-12-10 23:16:55 +01:00
Kacper
0d462ba080 fix(settings-view): adjust padding and remove unused dialog footer
- Updated padding in the settings view for better layout consistency.
- Removed the unused dialog footer containing the close button to streamline the interface.
2025-12-10 23:16:16 +01:00
Kacper
7886a29089 fix: scrolling issue 2025-12-10 23:09:27 +01:00
Kacper
a6da65e318 feat(keyboard): introduce visual keyboard map and shortcut customization
- Added a new `KeyboardMap` component for a visual representation of keyboard shortcuts, allowing users to easily customize their shortcuts with single-modifier support.
- Integrated `ShortcutReferencePanel` for editing shortcuts directly within the settings view.
- Updated the settings view to include a button for opening the keyboard map dialog, enhancing user experience in managing keyboard shortcuts.
- Refactored keyboard shortcut handling to support modifier keys and improve shortcut parsing and formatting.
2025-12-10 22:54:29 +01:00
copilot-swe-agent[bot]
7cf9a9f11a Add markdown rendering to interview-view using Markdown component from ui/markdown
Co-authored-by: GTheMachine <156854865+GTheMachine@users.noreply.github.com>
2025-12-10 21:29:56 +00:00
copilot-swe-agent[bot]
0bfe77f9f1 Initial plan 2025-12-10 21:18:37 +00:00
Kacper
344651a981 refactor(markdown): update styling for theme adaptability
- Enhanced the Markdown component to support theme-aware styling, ensuring proper rendering across all predefined themes.
- Updated typography and color classes for headings, paragraphs, lists, code blocks, strong text, links, blockquotes, and horizontal rules to align with the new theme structure.
2025-12-10 21:56:56 +01:00
90 changed files with 8406 additions and 4710 deletions

13
.automaker/.gitignore vendored
View File

@@ -1,13 +0,0 @@
# Backup files - these are created automatically by the UpdateFeatureStatus tool
feature_list.backup.json
# Agent context files - created during feature execution
agents-context/
# Attached images - uploaded by users as feature context
images/
# Launch script - local development script
launch.sh
.cursor

View File

@@ -1,8 +0,0 @@
📋 Planning implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
⚡ Executing implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
❌ Error: Reconnecting... 1/5
📋 Planning implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
⚡ Executing implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
📋 Planning implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
⚡ Executing implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task

View File

@@ -1,4 +0,0 @@
📋 Planning implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
⚡ Executing implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
📋 Planning implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
⚡ Executing implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task

View File

@@ -1,87 +1,116 @@
<project_specification>
<project_name>Automaker - Autonomous AI Development Studio</project_name>
<overview>
Automaker is a native desktop application that empowers developers to build software autonomously. It acts as an intelligent orchestrator, managing the entire development lifecycle from specification to implementation. Built with Electron and Next.js, it provides a seamless GUI for configuring projects, defining requirements (.automaker/app_spec.txt), and tracking progress via an interactive Kanban board. It leverages a dual-model architecture: Claude 3.5 Opus for complex logic/architecture and Gemini 3 Pro for UI/UX design.
Automaker is a sophisticated desktop application that empowers developers to build software autonomously through AI-powered agents. Built with Electron and Next.js, it provides an intelligent GUI for project management, feature tracking via Kanban boards, and autonomous code generation. The application leverages multiple AI models (Claude, GPT) and supports complex workflows including git worktree isolation, testing automation, and multi-model agent execution. It acts as a complete development orchestrator, managing the entire lifecycle from specification to verified implementation.
</overview>
<technology_stack>
<frontend>
<framework>Next.js (App Router)</framework>
<ui_library>shadcn/ui</ui_library>
<styling>Tailwind CSS</styling>
<state_management>Zustand / TanStack Query</state_management>
<drag_drop>dnd-kit (for Kanban)</drag_drop>
<framework>Next.js 16.0.7 (App Router)</framework>
<ui_library>shadcn/ui with Radix UI primitives</ui_library>
<styling>Tailwind CSS 4.0</styling>
<state_management>Zustand with persistence</state_management>
<drag_drop>@dnd-kit for Kanban board</drag_drop>
<icons>Lucide React</icons>
<query_client>TanStack Query for server state</query_client>
</frontend>
<desktop_shell>
<framework>Electron</framework>
<language>TypeScript</language>
<inter_process_communication>Electron IPC (tRPC or raw IPC)</inter_process_communication>
<file_system>Node.js fs/promises</file_system>
<framework>Electron 39.2.6</framework>
<language>TypeScript 5.x</language>
<inter_process_communication>Electron IPC with security sandboxing</inter_process_communication>
<file_system>Node.js fs/promises with path validation</file_system>
</desktop_shell>
<ai_engine>
<logic_model>Claude 3.5 Opus (via Anthropic SDK)</logic_model>
<design_model>Gemini 3 Pro (via Google Generative AI SDK)</design_model>
<orchestration>LangChain or Custom Agent Loop</orchestration>
<primary_model>Claude 3.5 (Opus, Sonnet, Haiku) via Anthropic Claude Agent SDK</primary_model>
<secondary_model>GPT-5.1 Codex family via OpenAI CLI</secondary_model>
<orchestration>Custom Agent Service with streaming responses</orchestration>
<model_registry>Dynamic model provider system with CLI detection</model_registry>
</ai_engine>
<testing>
<framework>Playwright (for E2E testing of Automaker itself)</framework>
<unit>Vitest</unit>
<framework>Playwright for E2E testing</framework>
<unit>Jest/Vitest compatible</unit>
<integration>Agent-driven test execution and verification</integration>
</testing>
<version_control>
<system>Git with worktree isolation support</system>
<branching>Feature branch management</branching>
<workflow>Automated commit and merge capabilities</workflow>
</version_control>
</technology_stack>
<core_capabilities>
<project_management>
- Open existing local projects
- Create new projects from scratch (Wizard/Interview Mode)
- Project configuration (name, path, ignore patterns)
- Visual file explorer
- Open and manage multiple local projects
- Project-specific themes and configurations
- Session management with project context
- Recently used project cycling (Q/E shortcuts)
- Project search and type-ahead selection
- Trash and restore functionality for projects
</project_management>
<intelligent_analysis>
- "Project Ingestion": Analyzes existing codebases to understand structure
- Auto-generation and updating of app_spec.txt
- Feature extraction from existing codebases
- Technology stack detection and documentation
- Project structure analysis with file tree visualization - "Project Ingestion": Analyzes existing codebases to understand structure
- Auto-generation of `.automaker/app_spec.txt` based on codebase analysis
- Auto-generation of `.automaker/feature_list.json`:
- Auto-generation of features in `.automaker/features/{id}/feature.json`:
- Scans code for implemented features
- Creates test cases for existing features
- Marks existing features as "passes": true automatically
</intelligent_analysis>
<kanban_workflow>
- Visual representation of `.automaker/feature_list.json`
- Columns: Backlog, Planned, In Progress, Review, Verified (Passed), Failed
- Visual representation of features from `.automaker/features/` folder
- Drag-and-drop interface to reprioritize tasks
- direct editing of feature details (steps, description) from the card
- "Play" button on cards to trigger the agent for that specific feature
- Visual Kanban board with drag-and-drop functionality
- Multiple status columns: Backlog, In Progress, Waiting Approval, Verified
- Feature cards with detailed information display (3 detail levels)
- Real-time status updates during agent execution
- Search and filtering capabilities
- Category management and autocomplete
- Image attachment support for feature descriptions
</kanban_workflow>
<autonomous_agent_engine>
- **The Architect (Claude 3.5 Opus)**:
- Reads spec and feature list
- Plans implementation steps
- Writes functional code (backend, logic, state)
- Writes tests
- Uses standard prompts (e.g. `.automaker/coding_prompt.md`) to ensure quality and consistency.
- **The Designer (Gemini 3 Pro)**:
- Receives UI requirements
- Generates Tailwind classes and React components
- Ensures visual consistency and aesthetics
- **The Interviewer**:
- Interactive chat mode to gather requirements for new projects.
- Asks clarifying questions to define the `.automaker/app_spec.txt`.
- Suggests tech stacks and features based on user intent.
- **The QA Bot**:
- Runs local tests (Playwright/Jest) in the target project
- Reports results back to the Kanban board
- Updates "passes" status automatically
- Multi-model agent system with profile-based execution
- Streaming agent output with real-time logs
- Git worktree isolation for safe feature development
- Automatic testing and verification workflows
- Context-aware prompt generation
- Agent memory and learning capabilities
- Concurrent feature processing with configurable limits
- Follow-up and resume capabilities
</autonomous_agent_engine>
<advanced_workflows>
- Git worktree management for isolated development
- Feature-specific branching and merging
- Automated commit generation with file tracking
- Test-driven development support
- Code review and approval workflows
- Revert and rollback capabilities
</advanced_workflows>
<user_interface>
- Dark/Light theme support with 12 custom themes
- Per-project theme configurations
- Comprehensive keyboard shortcut system
- Sidebar navigation with project switching
- Multi-view architecture (Board, Spec, Agent, Context, Settings)
- Setup wizard for first-time configuration
- CLI integration status monitoring
</user_interface>
<extensibility>
- Workflow Editor: Configure the agent loop (e.g., Plan -> Code -> Test -> Review)
- Prompt Manager: Edit system prompts for Architect and Designer. Defaults to using `.automaker/coding_prompt.md` as the base instruction set.
- Model Registry: Add/Configure different models (OpenAI, Groq, local LLMs)
- Plugin System: Hooks for pre/post generation steps
- AI Profile system for model/thinking level presets
- Keyboard shortcut customization
- Model provider plugin architecture
- Context file management for agent guidance
- Feature suggestion generation
- Spec regeneration workflows
</extensibility>
</core_capabilities>
@@ -90,7 +119,7 @@
- Sidebar: Project List, Settings, Logs, Plugins
- Main Content:
- **Spec View**: Split editor for `.automaker/app_spec.txt`
- **Board View**: Kanban board for `.automaker/feature_list.json`
- **Board View**: Kanban board for `.automaker/features/` folder
- **Code View**: Read-only Monaco editor to see what the agent is writing
- **Agent View**: Chat-like interface showing agent thought process and tool usage. Also used for the "New Project Interview".
</window_structure>
@@ -109,24 +138,61 @@
</local_testing>
</development_workflow>
<implemented_features>
- Complete Kanban board with drag-and-drop functionality
- Multi-model AI agent execution (Claude + GPT/Codex)
- Git worktree isolation for features
- Real-time agent output streaming and logging
- Project management with session persistence
- Theme system with 12 themes + per-project themes
- Comprehensive settings panel with all configurations
- Feature image attachment and context system
- Agent profiles with model/thinking level presets
- Keyboard shortcut system with customization
- CLI integration detection (Claude Code + Codex CLI)
- Auto mode for autonomous feature processing
- Feature suggestions generation
- Spec regeneration and project analysis
- Context file management
- Chat history and session management
- File diff viewing and git integration
- Search and filtering across all features
- Category management and autocomplete
- Test automation and verification workflows
</implemented_features>
<implementation_roadmap>
<phase_1_foundation>
- Setup Next.js + Electron boilerplate
- Implement IPC bridge
- Create Project Management UI (Open/Create)
- Enhanced error handling and recovery mechanisms
- Performance optimization for large projects
- Improved memory management for long-running sessions
- Advanced logging and debugging capabilities
</phase_1_foundation>
<phase_2_core_logic>
- Port python agent logic to TypeScript
- Implement "Project Ingestion" (Spec/Feature List generation)
- Integrate Claude 3.5 Opus and Gemini 3 Pro
- Implement "New Project Interview" workflow
- Plugin system for custom model providers
- Advanced workflow customization engine
- Team collaboration features
- Cloud synchronization capabilities
- Advanced project templates and scaffolding
</phase_2_core_logic>
<phase_3_kanban_and_interaction>
- Build Kanban board with drag-and-drop
- Connect Kanban state to `.automaker/feature_list.json` filesystem
- Connect Kanban state to `.automaker/features/` filesystem
- Implement "Run Feature" capability
- Integrate standard prompts library
</phase_3_kanban_and_interaction>
<phase_3_polish>
- Enhanced accessibility features
- Advanced theme customization
- Performance monitoring and analytics
- Documentation generation automation
- Integration with external development tools
- Advanced security auditing and sandboxing
</phase_3_polish>
<phase_4_polish>
- Advanced terminal integration
- Settings & Extensibility

View File

@@ -4,5 +4,6 @@
"Kanban",
"Other",
"Settings",
"Uncategorized"
"Uncategorized",
"ka"
]

View File

@@ -1,395 +0,0 @@
[
{
"id": "feature-1765387670653-bl83444lj",
"category": "Kanban",
"description": "In the output logs of the proc agent output in the file diffs Can you add a scroll bar so it actually scroll to see all these new styles right now it seems like I can't scroll",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T17:42:09.158Z",
"imagePaths": [],
"skipTests": true,
"summary": "Fixed scrolling for file diffs in agent output modal. Changed approach: parent container (agent-output-modal.tsx) now handles scrolling with overflow-y-auto, while GitDiffPanel uses natural height without flex-based scrolling. Modified: agent-output-modal.tsx (line 304), git-diff-panel.tsx (lines 461, 500, 525, 614).",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765387746902-na752mp1y",
"category": "Kanban",
"description": "When the add feature modal pops up, make sure that the description is always the main focus. When it first loads up. Do not focus the prompt tab, which is currently doing this.",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T17:29:13.854Z",
"imagePaths": [],
"skipTests": true,
"summary": "Added autoFocus prop to DescriptionImageDropZone component. Modified: description-image-dropzone.tsx (added autoFocus prop support), board-view.tsx (enabled autoFocus on add feature modal). Now the description textarea receives focus when the modal opens instead of the prompt tab.",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765388139100-ln31jgp5n",
"category": "Uncategorized",
"description": "Can you add a disclaimer .md file to this project saying that this uses a bunch of AI related tooling which could have access to your operating system and change and delete files and so use at your own risk. We tried to check it for security of vulnerability to make sure it's good. But you assume the risk and you should be reviewing the code yourself before you try to run it. And also sandboxing this so it doesn't have access to your whole operating system like using Docker to sandbox before you run it or use a virtual machine to sandbox it. and that we do not recommend running locally on your computer due to the risk of it having access to everything on your computer.\n\nUpdate or read me with a short paragraph overview/description at the top followed by a disclaimer section in red that points to the disclaimer file with the same disclaimer information.\n\nThen a section that lists out all the features of cool emojis.",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T17:35:40.700Z",
"imagePaths": [],
"skipTests": true,
"summary": "Created DISCLAIMER.md with comprehensive security warnings about AI tooling risks and sandboxing recommendations. Updated README.md with project overview, red caution disclaimer section linking to DISCLAIMER.md, and features list with emojis covering all major functionality (Kanban, AI agents, multi-model support, etc.).",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765388388144-oa1dewze9",
"category": "Uncategorized",
"description": "Please fix the styling of the hotkeys to be more using the theme colors. Notice that they're kind of gray. I would rather than have some type of like light green if they're not active and then the brighter green if they are active and also the add feature but in the top right it's not very legible. So fix the accessibility of the hotkey but also keep it within the theme. You might just have to change the text inside of it to be bright green.",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T17:40:02.745Z",
"imagePaths": [
{
"id": "img-1765388352835-dgx4ishp0",
"path": "/Users/webdevcody/Library/Application Support/automaker/images/1765388352832-6jnbgw8kg_Screenshot_2025-12-10_at_12.39.10_PM.png",
"filename": "Screenshot 2025-12-10 at 12.39.10PM.png",
"mimeType": "image/png"
},
{
"id": "img-1765388356955-a0gdovp5b",
"path": "/Users/webdevcody/Library/Application Support/automaker/images/1765388356954-d59a65nf9_Screenshot_2025-12-10_at_12.39.15_PM.png",
"filename": "Screenshot 2025-12-10 at 12.39.15PM.png",
"mimeType": "image/png"
}
],
"skipTests": true,
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765388402095-x66aduwg3",
"category": "Uncategorized",
"description": "Can you please add some spacing and fix the styling of the hotkey with the command enter and make it so they're both vertically aligned for those icons?",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T17:44:08.667Z",
"imagePaths": [
{
"id": "img-1765388390408-eefybe95t",
"path": "/Users/webdevcody/Library/Application Support/automaker/images/1765388390408-nn320yoyc_Screenshot_2025-12-10_at_12.39.47_PM.png",
"filename": "Screenshot 2025-12-10 at 12.39.47PM.png",
"mimeType": "image/png"
}
],
"skipTests": true,
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765388662444-as3hqn7be",
"category": "Uncategorized",
"description": "Fix the styling on all the buttons when I hover over them with my mouse they never change to a click mouse cursor. In order they seem to show any type of like hover state changes, if they do, at least for the certain game I'm using, it's not very obvious that you're hovering over the button.",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T17:45:59.666Z",
"imagePaths": [],
"skipTests": true,
"summary": "Fixed hover cursor styling on all interactive elements. Modified: button.tsx (added cursor-pointer to base styles), dropdown-menu.tsx (added cursor-pointer to all menu items), checkbox.tsx (added cursor-pointer), tabs.tsx (added cursor-pointer to triggers), dialog.tsx (added cursor-pointer to close button), slider.tsx (added cursor-grab to thumb, cursor-pointer to track), globals.css (added global CSS rules for clickable elements to ensure consistent cursor behavior).",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765388693856-yx1dk1acj",
"category": "Kanban",
"description": "The tabs in the add new feature modal for the prompt model and testing tabs. They don't seem to look like tabs when I'm on a certain theme. Can you verify that those are hooked into the theme? And make sure that the active one is colored differently than the unactive ones. Keep the primary colors when doing this.",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T17:46:00.019Z",
"imagePaths": [],
"skipTests": true,
"summary": "Fixed tabs component theme integration. Modified: tabs.tsx. Changes: (1) Added visible border to TabsList container using theme's border color, (2) Changed inactive tab text to foreground/70 for better contrast, (3) Enhanced active tab with shadow-md and semi-transparent primary border, (4) Improved hover state with full accent background. Active tabs now properly use bg-primary/text-primary-foreground which adapts to each theme.",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765388754462-bek0flvkj",
"category": "Uncategorized",
"description": "There's a strange issue when I when when these agents are like doing things it seems like it completely refreshes the whole Kanban board and there's like a black flash. Can you verify that the data loading does not cause the entire component to refresh? Maybe there's an issue with the react effect or how the component is rendered maybe we need some used memos or something but it shouldn't refresh the whole page it should just like update the individual cards when they change.",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T17:47:20.170Z",
"imagePaths": [],
"skipTests": true,
"summary": "Fixed Kanban board flash/refresh issue. Changes: (1) board-view.tsx - Added isInitialLoadRef to only show loading spinner on initial load, not on feature reloads; memoized column features with useMemo to prevent recalculation on every render. (2) kanban-card.tsx - Wrapped with React.memo to prevent unnecessary re-renders. (3) kanban-column.tsx - Wrapped with React.memo for performance. The flash was caused by loadFeatures setting isLoading=true on every reload, which caused the entire board to unmount and show a loading spinner.",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765388793845-yhluf0sry",
"category": "Uncategorized",
"description": "Add in the ability so that every project can have its own selected theme. This will allow me to have different projects have different themes so I can easily differentiate when I have one project selected or not.",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T18:00:33.814Z",
"imagePaths": [],
"skipTests": true,
"summary": "Fixed per-project theme support. Modified: settings-view.tsx (now saves theme to project when project is selected, shows label indicating scope), page.tsx (computes effectiveTheme from currentProject?.theme || theme), app-store.ts (added setProjectTheme action, theme property on Project interface). When a project is selected, changing theme in Settings saves to that project only.",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765389333728-y74hmz2yp",
"category": "Agent Runner",
"description": "On the Agent Runner, I took a screenshot and dropped it into the text area and after a certain amount of time, it's like the image preview just completely went away. Can you debug and fix this on the Agent Runner?",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T18:11:17.561Z",
"imagePaths": [],
"skipTests": true,
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765389352488-j9bez5ztx",
"category": "Kanban",
"description": "It seems like the category typehead is no longer working. Can you double check that code didn't break? It should have kept track of categories inside of the categories.json file inside the .automaker folder when adding new features modal",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T18:17:22.274Z",
"imagePaths": [],
"skipTests": true,
"summary": "Fixed category typeahead dropdown being clipped by overflow containers. Modified: category-autocomplete.tsx. Changed dropdown to use React Portal (createPortal) to render to document.body with fixed positioning and z-index 9999. Added scroll/resize position tracking to keep dropdown aligned with input.",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765389420151-jzdsjzn9u",
"category": "Kanban",
"description": "Add in the ability to just click and drag a card from the waiting approval directly into the verify column as I can usually just commit it manually if I want to.",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T18:05:08.252Z",
"imagePaths": [],
"skipTests": true,
"summary": "Fixed drag-and-drop from waiting_approval to verified column. The issue was condition ordering in handleDragEnd - the skipTests check was intercepting waiting_approval features before they could be handled. Moved waiting_approval status check before skipTests check in board-view.tsx:731-752. Also updated agent memory with this lesson.",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765389468077-9x3vt1yjq",
"category": "Uncategorized",
"description": "The commit functionality on the waiting approval cards doesn't seem to work. It just committed everything in my working copy for git. I think I should be a little bit more intelligent and figure out what files it changed for that AI session and then only try to git add those individual files and commit those. Right now it just basically did a git add all and committed those. Re-factor the prompting or figure out a way to make it so it's more specific on what it's going to commit with the future change.",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T18:17:22.580Z",
"imagePaths": [],
"skipTests": true,
"summary": "Fixed commit functionality to only commit files changed during the AI session, not all working directory changes. Added git state tracking in context-manager.js (saveInitialGitState, getFilesChangedDuringSession methods) and updated commit prompt in feature-executor.js to use specific file lists instead of 'git add .'",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765389502705-6deep7mvi",
"category": "Uncategorized",
"description": "I'm noticing that a lot of buttons in the UI, especially the ones that are submitting, are either missing the submit hotkey or they're not styled properly. Look at the add feature submit button that's on the add feature modal and abstract away a submit button so that on every single page that needs to submit something I can reuse this type of hotkey functionality. In fact, every single button should be abstracted enough where I can provide a hotkey and it will automatically listen if I press that hotkey when it's in view.",
"steps": [],
"status": "waiting_approval",
"startedAt": "2025-12-10T19:03:41.338Z",
"imagePaths": [],
"skipTests": true,
"summary": "Fixed duplicate hotkey listener issue. When HotkeyButton was used with simple keys (N, F, G) that were already handled by useKeyboardShortcuts, it created duplicate listeners. Added hotkeyActive={false} to HotkeyButton instances in board-view.tsx (Add Feature, Start Next), context-view.tsx (Add File), profiles-view.tsx (New Profile), and session-manager.tsx (New) where useKeyboardShortcuts already handles the hotkey. Also updated memory.md with this lesson learned.",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765389772166-an3yk3kpo",
"category": "Uncategorized",
"description": "Can you add some more padding to the bottom of the settings panel? Notice that I can't scroll down all the way. And that doesn't highlight the left sub navigation to highlight it pink when I'm on that section. I should be able to scroll a bit further and just have like blank space at the bottom. So I can eventually get to that actual section.",
"steps": [],
"status": "verified",
"imagePaths": [
{
"id": "img-1765389750685-jhq6rcidc",
"path": "/Users/webdevcody/Library/Application Support/automaker/images/1765389750683-mqb0j7a3z_Screenshot_2025-12-10_at_1.02.26_PM.png",
"filename": "Screenshot 2025-12-10 at 1.02.26PM.png",
"mimeType": "image/png"
}
],
"skipTests": true,
"summary": "Added bottom padding (pb-96) to settings panel content area to allow scrolling past last section. Improved scroll detection to highlight the last navigation item when scrolled to bottom. Modified: settings-view.tsx",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765389829239-bbk596u6z",
"category": "Uncategorized",
"description": "Add some type of XML highlighting to the spec editor view. Right now it's just all grayscale and it's kind of ugly to look at. And try to make the syntax highlighting match the current selected theme.",
"steps": [],
"status": "verified",
"imagePaths": [],
"skipTests": true,
"summary": "Added XML syntax highlighting to spec editor view. Created: xml-syntax-editor.tsx component with custom XML tokenizer and theme-aware syntax highlighting. Modified: spec-view.tsx to use new editor, globals.css with 500+ lines of theme-specific syntax highlighting colors for all 12 themes (light, dark, retro, dracula, nord, monokai, tokyonight, solarized, gruvbox, catppuccin, onedark, synthwave). Features: highlights tag brackets, tag names, attribute names, attribute values, comments, CDATA, DOCTYPE. Tab key indentation supported.",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765389859334-si9ivtehw",
"category": "Uncategorized",
"description": "Add a search bar to the top of the Kanban column that allows me to search the filter down just to show the cards I'm interested in by keyword.",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T18:09:26.193Z",
"imagePaths": [],
"skipTests": true,
"summary": "Added forward slash (/) keyboard shortcut to focus search input. Modified: board-view.tsx - added searchInputRef, registered '/' shortcut in boardShortcuts, updated placeholder to show hint '(Press / to focus)'",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765390022638-nalulsdxv",
"category": "Uncategorized",
"description": "In the project select can you actually remove the whole like 1 2 3 4 5 hotkeys instead? Just make it be a type ahead so when I open the panel I just should be able to type in the first letter or two of the project that I want and press enter and that should Just select it for me",
"steps": [],
"status": "verified",
"imagePaths": [],
"skipTests": true,
"summary": "Replaced hotkey-based project selection (1-9) with type-ahead search. Modified: sidebar.tsx. Added search input with filtering, arrow key navigation (↑↓), Enter to select, and visual highlighting. Auto-focuses search when dropdown opens.",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765390055621-ewc4w7k5h",
"category": "Uncategorized",
"description": "In the add new feature prompt, instead of disabling the add feature button until we type into the description, keep it enabled. But if you click it, make sure you just show the client side validation and turn the description box in any other required field as red so that the user knows they have to fill it in.",
"steps": [],
"status": "verified",
"imagePaths": [],
"skipTests": true,
"summary": "Added client-side validation for the Add Feature dialog. The Add Feature button is now always enabled. When clicked without a description, it shows a red border around the description field using aria-invalid styling. Modified: board-view.tsx (added descriptionError state, validation in handleAddFeature, error prop passing), description-image-dropzone.tsx (added error prop that sets aria-invalid on textarea).",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765390131625-ymqxr5gln",
"category": "Uncategorized",
"description": "Can you please in the top right of the Kanban board use the show three icons for the Kanban card display formatting. You can look at the settings page to see that there's three different settings that we use for displaying the Kanban card information. But I also just want this to be really quickly accessible at the top right of the Kanban that they can switch between those three toggles. Keep them simple only just icons you don't need to put words in them. Make sure they do have harbor states though, or tooltips I mean.",
"steps": [],
"status": "waiting_approval",
"startedAt": "2025-12-10T18:58:21.431Z",
"imagePaths": [],
"skipTests": true,
"summary": "Moved Kanban card detail toggle icons to search bar row. Modified: board-view.tsx. Three icons (Minimize2, Square, Maximize2) now appear on the right side of the search bar with tooltips and hover states.",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765390359456-n0vvdurjb",
"category": "Kanban",
"description": "When the item is in the backlog, do not show the logs. There's no reason for a user to look at the logs if it's in the backlog. So remove the logs button from the card, the Kanban card, if it's in the backlog.",
"steps": [],
"status": "verified",
"imagePaths": [],
"skipTests": true,
"summary": "Removed logs button from Kanban cards when feature is in backlog. Modified: kanban-card.tsx - removed the dedicated logs button section for backlog items (lines 743-761) and added condition to hide logs option in dropdown menu for backlog items.",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765390428237-4ekiscpsf",
"category": "Uncategorized",
"description": "On the Kanban search bar, instead of press slash to focus, just use the normal shortcut display button that we've been using everywhere else in the application. Can you keep it consistent, please?",
"steps": [],
"status": "verified",
"imagePaths": [
{
"id": "img-1765390414226-66vm6cly4",
"path": "/Users/webdevcody/Library/Application Support/automaker/images/1765390414225-7o9wizw90_Screenshot_2025-12-10_at_1.13.32_PM.png",
"filename": "Screenshot 2025-12-10 at 1.13.32PM.png",
"mimeType": "image/png"
}
],
"skipTests": true,
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765390699789-uaxtse6hn",
"category": "Uncategorized",
"description": "Please fix the styling of this on the Agent Runner, make it match the theme of the project.",
"steps": [],
"status": "verified",
"imagePaths": [
{
"id": "img-1765390692809-0hahbe30j",
"path": "/Users/webdevcody/Library/Application Support/automaker/images/1765390692808-u8dgwxx9n_Screenshot_2025-12-10_at_1.18.07_PM.png",
"filename": "Screenshot 2025-12-10 at 1.18.07PM.png",
"mimeType": "image/png"
}
],
"skipTests": true,
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765393057026-cjgr70d97",
"category": "Kanban",
"description": "there is a major bug: stopping auto mode should not cancel all running tasks, it should just turn off the auto toggle.",
"steps": [],
"status": "waiting_approval",
"startedAt": "2025-12-10T18:57:56.137Z",
"imagePaths": [],
"skipTests": true,
"summary": "Fixed auto mode stop to only turn off the toggle, not cancel running tasks. Modified: auto-mode-service.js (removed abort/clear logic from stop()), use-auto-mode.ts (removed clearRunningTasks from stop callback). Running features now complete naturally.",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765393405243-xe047s4h5",
"category": "Uncategorized",
"description": "fix the style of the input on the kanban route to add a border around the entire input",
"steps": [],
"status": "waiting_approval",
"startedAt": "2025-12-10T19:08:38.024Z",
"imagePaths": [
{
"id": "img-1765393386453-nd1qucdne",
"path": "/Users/webdevcody/Workspace/automaker/.automaker/images/1765393386452-bz4q5pbkw_Screenshot_2025-12-10_at_2.03.04_PM.png",
"filename": "Screenshot 2025-12-10 at 2.03.04PM.png",
"mimeType": "image/png"
}
],
"skipTests": true,
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765395197833-hkxty2nb9",
"category": "Uncategorized",
"description": "on the agent runner chat window, style the text to match the theme primary or secondary, restyle it to match the selected theme",
"steps": [],
"status": "waiting_approval",
"startedAt": "2025-12-10T19:33:18.742Z",
"imagePaths": [
{
"id": "img-1765395175327-vdj77vwtb",
"path": "/Users/webdevcody/Workspace/automaker/.automaker/images/1765395175325-plp2txel3_Screenshot_2025-12-10_at_2.32.52_PM.png",
"filename": "Screenshot 2025-12-10 at 2.32.52PM.png",
"mimeType": "image/png"
}
],
"skipTests": true,
"summary": "Styled agent chat messages to use theme primary colors. Modified: agent-view.tsx, interview-view.tsx. Assistant messages now have border-l-4 border-primary, text-primary for content and timestamps. Loading/thinking indicators also styled with theme colors.",
"model": "opus",
"thinkingLevel": "none"
},
{
"id": "feature-1765395217816-22cwdpnu9",
"category": "Uncategorized",
"description": "the add feature shortcut on the add new feature modal does not work anymore, please fix it",
"steps": [],
"status": "waiting_approval",
"startedAt": "2025-12-10T19:35:54.453Z",
"imagePaths": [],
"skipTests": true,
"summary": "Fixed Cmd+Enter shortcut not working in Add Feature modal when input is focused. Modified: hotkey-button.tsx - Changed logic to allow cmdCtrl modifier shortcuts even when typing in input fields, since they are intentional submit actions.",
"model": "opus",
"thinkingLevel": "none"
}
]

View File

@@ -0,0 +1,53 @@
📋 Planning implementation for: move the report bug request button to top in header next to the automaker logo
⚡ Executing implementation for: move the report bug request button to top in header next to the automaker logo
Starting code implementation...
🔧 Tool: TodoWrite
Let me start by exploring the header component and understanding the current structure.
🔧 Tool: Glob
🔧 Tool: Grep
🔧 Tool: Read
🔧 Tool: Glob
🔧 Tool: TodoWrite
I found the sidebar component. The "Report Bug / Feature Request" button is currently in the bottom section of the sidebar (lines 1150-1180). The feature request is to move this button to the top header, next to the Automaker logo.
Looking at the current structure:
1. The logo section is at lines 742-770
2. The bug report button is in the bottom section at lines 1150-1180
I'll move the bug report button to be next to the logo in the header area. Let me make this change:
🔧 Tool: Edit
Now I need to remove the old bug report button from the bottom section:
🔧 Tool: Edit
The change is complete. Let me verify the build compiles correctly:
🔧 Tool: Bash
🔧 Tool: Bash
🔧 Tool: Bash
🔧 Tool: Bash
The TypeScript check passed with no errors. Let me mark the task as completed and update the feature status:
🔧 Tool: TodoWrite
🔧 Tool: mcp__automaker-tools__UpdateFeatureStatus
🔧 Tool: TodoWrite
I've completed the feature implementation. Here's a summary of what was done:
**Changes Made:**
1. **Modified `app/src/components/layout/sidebar.tsx`:**
- Moved the "Report Bug / Feature Request" button from the bottom sidebar section (between Running Agents and Settings) to the header area, next to the AutoMaker logo
- Changed the header container from `justify-center` to `justify-between` to accommodate the new button placement
- The button is now a compact icon-only button with a tooltip on hover
- Kept the same test ID (`bug-report-link`) for consistency
- Removed the old button from the bottom section
The bug report button now appears as a small bug icon in the header, right next to the AutoMaker logo, making it more accessible and visible to users.✅ Verifying implementation for: move the report bug request button to top in header next to the automaker logo
Verifying implementation and checking test results...
✓ Verification successful: All tests passed

View File

@@ -0,0 +1,14 @@
{
"category": "Uncategorized",
"description": "move the report bug request button to top in header next to the automaker logo",
"steps": [],
"status": "waiting_approval",
"images": [],
"imagePaths": [],
"skipTests": true,
"model": "opus",
"thinkingLevel": "none",
"id": "feature-1765426941179-om12nlapa",
"startedAt": "2025-12-11T04:22:21.750Z",
"summary": "Moved the Report Bug / Feature Request button from the bottom sidebar section to the header, next to the AutoMaker logo. Modified: app/src/components/layout/sidebar.tsx. The button now appears as a compact icon button in the header area."
}

View File

@@ -0,0 +1,11 @@
{
"timestamp": "2025-12-11T04:22:21.809Z",
"modifiedFiles": [
"app/src/components/views/analysis-view.tsx",
"app/src/components/views/interview-view.tsx"
],
"untrackedFiles": [
".automaker/features/feature-1765426941179-om12nlapa/feature.json",
"marketing/index.html"
]
}

Submodule .automaker/worktrees/176536627888-implement-profile-view-and-in-the-sideba deleted from a78b6763de

Submodule .automaker/worktrees/176536775869-so-we-added-ai-profiles-add-a-default-op deleted from a78b6763de

33
.github/workflows/pr-check.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: PR Build Check
on:
pull_request:
branches:
- "*"
push:
branches:
- main
- master
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: app/package-lock.json
- name: Install dependencies
working-directory: ./app
run: npm ci
- name: Run build:electron
working-directory: ./app
run: npm run build:electron

221
LICENSE
View File

@@ -1,21 +1,208 @@
MIT License
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (c) 2025 Cody Seibert
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Preamble
The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software.
A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public.
The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version.
An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS 0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based on the Program.
To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work.
A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices".
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements.
You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see <https://www.gnu.org/licenses/>.

View File

@@ -20,54 +20,54 @@ Automaker is an autonomous AI development studio that helps you build software f
## Getting Started
**Step 1:** Clone this repository:
### Prerequisites
- Node.js 18+
- npm
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
### Quick Start
```bash
# 1. Clone the repo
git clone git@github.com:AutoMaker-Org/automaker.git
cd automaker
```
**Step 2:** Install dependencies:
```bash
# 2. Install dependencies
npm install
```
**Step 3:** Run the Claude Code setup token command:
```bash
# 3. Get your Claude Code OAuth token
claude setup-token
```
# ⚠️ This prints your token - don't share your screen!
> **⚠️ 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
# 4. Set the token and run
export CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat01-..."
npm run dev:electron
```
This will start both the Next.js development server and the Electron application.
### Authentication Options
**Step 6:** MOST IMPORANT: Run the Following after all is setup
Automaker supports multiple authentication methods (in order of priority):
| Method | Environment Variable | Description |
|--------|---------------------|-------------|
| OAuth Token (env) | `CLAUDE_CODE_OAUTH_TOKEN` | From `claude setup-token` - uses your Claude subscription |
| OAuth Token (stored) | — | Stored in app credentials file |
| API Key (stored) | — | Anthropic API key stored in app |
| API Key (env) | `ANTHROPIC_API_KEY` | Pay-per-use API key |
**Recommended:** Use `CLAUDE_CODE_OAUTH_TOKEN` if you have a Claude subscription.
### Persistent Setup (Optional)
Add to your `~/.bashrc` or `~/.zshrc`:
```bash
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
export CLAUDE_CODE_OAUTH_TOKEN="YOUR_TOKEN_HERE"
```
Then restart your terminal or run `source ~/.bashrc`.
## Features
- 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages

View File

@@ -19,14 +19,12 @@ class AgentService {
this.stateDir = path.join(appDataPath, "agent-sessions");
this.metadataFile = path.join(appDataPath, "sessions-metadata.json");
await fs.mkdir(this.stateDir, { recursive: true });
console.log("[AgentService] Initialized with state dir:", this.stateDir);
}
/**
* Start or resume a conversation
*/
async startConversation({ sessionId, workingDirectory }) {
console.log("[AgentService] Starting conversation:", sessionId);
// Initialize session if it doesn't exist
if (!this.sessions.has(sessionId)) {
@@ -307,7 +305,7 @@ class AgentService {
};
} catch (error) {
if (error instanceof AbortError || error?.name === "AbortError") {
console.log("[AgentService] Query aborted");
// Query aborted
session.isRunning = false;
session.abortController = null;
return { success: false, aborted: true };
@@ -441,20 +439,9 @@ class AgentService {
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.
**🚨 CRITICAL FILE PROTECTION 🚨**
THE FOLLOWING FILE IS ABSOLUTELY FORBIDDEN FROM DIRECT MODIFICATION:
- .automaker/feature_list.json
**YOU MUST NEVER:**
- Use the Write tool on .automaker/feature_list.json
- Use the Edit tool on .automaker/feature_list.json
- Use any Bash command that writes to .automaker/feature_list.json
- Attempt to read and rewrite .automaker/feature_list.json
**CATASTROPHIC CONSEQUENCES:**
Directly modifying .automaker/feature_list.json can erase all project features permanently.
This file is managed by specialized tools only. NEVER touch it directly.
**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
@@ -462,7 +449,7 @@ Your role is to:
- Suggest technical approaches and architectures
- Guide them through the development process
- Be conversational and helpful
- Write, edit, and modify code files as requested (EXCEPT .automaker/feature_list.json)
- Write, edit, and modify code files as requested
- Execute commands and tests
- Search and analyze the codebase
@@ -474,10 +461,10 @@ When discussing projects, help users think through:
- Testing strategies
You have full access to the codebase and can:
- Read files to understand existing code (including .automaker/feature_list.json for viewing only)
- Write new files (NEVER .automaker/feature_list.json)
- Edit existing files (NEVER .automaker/feature_list.json)
- Run bash commands (but never commands that modify .automaker/feature_list.json)
- Read files to understand existing code
- Write new files
- Edit existing files
- Run bash commands
- Search for code patterns
- Execute tests and builds

View File

@@ -20,11 +20,64 @@ class AutoModeService {
constructor() {
// Track multiple concurrent feature executions
this.runningFeatures = new Map(); // featureId -> { abortController, query, projectPath, sendToRenderer }
this.autoLoopRunning = false; // Separate flag for the auto loop
this.autoLoopAbortController = null;
this.autoLoopInterval = null; // Timer for periodic checking
// Per-project auto loop state (keyed by projectPath)
this.projectLoops = new Map(); // projectPath -> { isRunning, interval, abortController, sendToRenderer, maxConcurrency }
this.checkIntervalMs = 5000; // Check every 5 seconds
this.maxConcurrency = 3; // Default max concurrency
this.maxConcurrency = 3; // Default max concurrency (global default)
}
/**
* Get or create project loop state
*/
getProjectLoopState(projectPath) {
if (!this.projectLoops.has(projectPath)) {
this.projectLoops.set(projectPath, {
isRunning: false,
interval: null,
abortController: null,
sendToRenderer: null,
maxConcurrency: this.maxConcurrency,
});
}
return this.projectLoops.get(projectPath);
}
/**
* Check if any project has auto mode running
*/
hasAnyAutoLoopRunning() {
for (const [, state] of this.projectLoops) {
if (state.isRunning) return true;
}
return false;
}
/**
* Get running features for a specific project
*/
getRunningFeaturesForProject(projectPath) {
const features = [];
for (const [featureId, execution] of this.runningFeatures) {
if (execution.projectPath === projectPath) {
features.push(featureId);
}
}
return features;
}
/**
* Count running features for a specific project
*/
getRunningCountForProject(projectPath) {
let count = 0;
for (const [, execution] of this.runningFeatures) {
if (execution.projectPath === projectPath) {
count++;
}
}
return count;
}
/**
@@ -43,6 +96,18 @@ class AutoModeService {
return context;
}
/**
* Helper to emit event with projectPath included
*/
emitEvent(projectPath, sendToRenderer, event) {
if (sendToRenderer) {
sendToRenderer({
...event,
projectPath,
});
}
}
/**
* Setup worktree for a feature
* Creates an isolated git worktree where the agent can work
@@ -65,7 +130,7 @@ class AutoModeService {
return { useWorktree: false, workPath: projectPath };
}
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_progress",
featureId: feature.id,
content: "Creating isolated worktree for feature...\n",
@@ -75,7 +140,7 @@ class AutoModeService {
if (!result.success) {
console.warn(`[AutoMode] Failed to create worktree: ${result.error}. Falling back to main project.`);
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_progress",
featureId: feature.id,
content: `Warning: Could not create worktree (${result.error}). Working directly on main project.\n`,
@@ -84,13 +149,13 @@ class AutoModeService {
}
console.log(`[AutoMode] Created worktree at: ${result.worktreePath}, branch: ${result.branchName}`);
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_progress",
featureId: feature.id,
content: `Working in isolated branch: ${result.branchName}\n`,
});
// Update feature with worktree info in feature_list.json
// Update feature with worktree info
await featureLoader.updateFeatureWorktree(
feature.id,
projectPath,
@@ -107,46 +172,56 @@ class AutoModeService {
}
/**
* Start auto mode - continuously implement features
* Start auto mode for a specific project - continuously implement features
* Each project can have its own independent auto mode loop
*/
async start({ projectPath, sendToRenderer, maxConcurrency }) {
if (this.autoLoopRunning) {
throw new Error("Auto mode loop is already running");
const projectState = this.getProjectLoopState(projectPath);
if (projectState.isRunning) {
throw new Error(`Auto mode loop is already running for project: ${projectPath}`);
}
this.autoLoopRunning = true;
this.maxConcurrency = maxConcurrency || 3;
projectState.isRunning = true;
projectState.maxConcurrency = maxConcurrency || 3;
projectState.sendToRenderer = sendToRenderer;
console.log(
`[AutoMode] Starting auto mode for project: ${projectPath} with max concurrency: ${this.maxConcurrency}`
`[AutoMode] Starting auto mode for project: ${projectPath} with max concurrency: ${projectState.maxConcurrency}`
);
// Start the periodic checking loop
this.runPeriodicLoop(projectPath, sendToRenderer);
// Start the periodic checking loop for this project
this.runPeriodicLoopForProject(projectPath);
return { success: true };
}
/**
* Stop auto mode - stops the auto loop but lets running features complete
* Stop auto mode for a specific project - stops the auto loop but lets running features complete
* This only turns off the auto toggle to prevent picking up new features.
* Running tasks will continue until they complete naturally.
*/
async stop() {
console.log("[AutoMode] Stopping auto mode (letting running features complete)");
async stop({ projectPath }) {
console.log(`[AutoMode] Stopping auto mode for project: ${projectPath} (letting running features complete)`);
this.autoLoopRunning = false;
const projectState = this.projectLoops.get(projectPath);
if (!projectState) {
console.log(`[AutoMode] No auto mode state found for project: ${projectPath}`);
return { success: true, runningFeatures: 0 };
}
// Clear the interval timer
if (this.autoLoopInterval) {
clearInterval(this.autoLoopInterval);
this.autoLoopInterval = null;
projectState.isRunning = false;
// Clear the interval timer for this project
if (projectState.interval) {
clearInterval(projectState.interval);
projectState.interval = null;
}
// Abort auto loop if running
if (this.autoLoopAbortController) {
this.autoLoopAbortController.abort();
this.autoLoopAbortController = null;
if (projectState.abortController) {
projectState.abortController.abort();
projectState.abortController = null;
}
// NOTE: We intentionally do NOT abort running features here.
@@ -154,23 +229,58 @@ class AutoModeService {
// from being picked up. Running features will complete naturally.
// Use stopFeature() to cancel a specific running feature if needed.
const runningCount = this.runningFeatures.size;
console.log(`[AutoMode] Auto loop stopped. ${runningCount} feature(s) still running and will complete.`);
const runningCount = this.getRunningCountForProject(projectPath);
console.log(`[AutoMode] Auto loop stopped for ${projectPath}. ${runningCount} feature(s) still running and will complete.`);
return { success: true, runningFeatures: runningCount };
}
/**
* Get status of auto mode
* Get status of auto mode (global and per-project)
*/
getStatus() {
getStatus({ projectPath } = {}) {
// If projectPath is specified, return status for that project
if (projectPath) {
const projectState = this.projectLoops.get(projectPath);
return {
autoLoopRunning: projectState?.isRunning || false,
runningFeatures: this.getRunningFeaturesForProject(projectPath),
runningCount: this.getRunningCountForProject(projectPath),
};
}
// Otherwise return global status
const allRunningProjects = [];
for (const [path, state] of this.projectLoops) {
if (state.isRunning) {
allRunningProjects.push(path);
}
}
return {
autoLoopRunning: this.autoLoopRunning,
autoLoopRunning: this.hasAnyAutoLoopRunning(),
runningProjects: allRunningProjects,
runningFeatures: Array.from(this.runningFeatures.keys()),
runningCount: this.runningFeatures.size,
};
}
/**
* Get status for all projects with auto mode
*/
getAllProjectStatuses() {
const statuses = {};
for (const [projectPath, state] of this.projectLoops) {
statuses[projectPath] = {
isRunning: state.isRunning,
runningFeatures: this.getRunningFeaturesForProject(projectPath),
runningCount: this.getRunningCountForProject(projectPath),
maxConcurrency: state.maxConcurrency,
};
}
return statuses;
}
/**
* Run a specific feature by ID
* @param {string} projectPath - Path to the project
@@ -218,7 +328,7 @@ class AutoModeService {
projectPath
);
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_feature_start",
featureId: feature.id,
feature: { ...feature, worktreePath: worktreeSetup.workPath, branchName: worktreeSetup.branchName },
@@ -253,7 +363,7 @@ class AutoModeService {
// Keep context file for viewing output later (deleted only when card is removed)
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_feature_complete",
featureId: feature.id,
passes: result.passes,
@@ -288,7 +398,7 @@ class AutoModeService {
console.error("[AutoMode] Failed to update feature status after error:", statusError);
}
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_error",
error: error.message,
featureId: featureId,
@@ -333,7 +443,7 @@ class AutoModeService {
console.log(`[AutoMode] Verifying feature: ${feature.description}`);
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_feature_start",
featureId: feature.id,
feature: feature,
@@ -357,7 +467,7 @@ class AutoModeService {
// Keep context file for viewing output later (deleted only when card is removed)
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_feature_complete",
featureId: feature.id,
passes: result.passes,
@@ -392,7 +502,7 @@ class AutoModeService {
console.error("[AutoMode] Failed to update feature status after error:", statusError);
}
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_error",
error: error.message,
featureId: featureId,
@@ -437,7 +547,7 @@ class AutoModeService {
console.log(`[AutoMode] Resuming feature: ${feature.description}`);
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_feature_start",
featureId: feature.id,
feature: feature,
@@ -481,7 +591,7 @@ class AutoModeService {
`\n\n🔄 Auto-retry #${attempts} - Continuing implementation...\n\n`
);
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_progress",
featureId: feature.id,
content: `\n🔄 Auto-retry #${attempts} - Agent ended early, continuing...\n`,
@@ -524,7 +634,7 @@ class AutoModeService {
// Keep context file for viewing output later (deleted only when card is removed)
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_feature_complete",
featureId: feature.id,
passes: finalResult.passes,
@@ -559,7 +669,7 @@ class AutoModeService {
console.error("[AutoMode] Failed to update feature status after error:", statusError);
}
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_error",
error: error.message,
featureId: featureId,
@@ -572,42 +682,52 @@ class AutoModeService {
}
/**
* New periodic loop - checks available slots and starts features up to max concurrency
* New periodic loop for a specific project - checks available slots and starts features up to max concurrency
* This loop continues running even if there are no backlog items
*/
runPeriodicLoop(projectPath, sendToRenderer) {
runPeriodicLoopForProject(projectPath) {
const projectState = this.getProjectLoopState(projectPath);
console.log(
`[AutoMode] Starting periodic loop with interval: ${this.checkIntervalMs}ms`
`[AutoMode] Starting periodic loop for ${projectPath} with interval: ${this.checkIntervalMs}ms`
);
// Initial check immediately
this.checkAndStartFeatures(projectPath, sendToRenderer);
this.checkAndStartFeaturesForProject(projectPath);
// Then check periodically
this.autoLoopInterval = setInterval(() => {
if (this.autoLoopRunning) {
this.checkAndStartFeatures(projectPath, sendToRenderer);
projectState.interval = setInterval(() => {
if (projectState.isRunning) {
this.checkAndStartFeaturesForProject(projectPath);
}
}, this.checkIntervalMs);
}
/**
* Check how many features are running and start new ones if under max concurrency
* Check how many features are running for a specific project and start new ones if under max concurrency
*/
async checkAndStartFeatures(projectPath, sendToRenderer) {
async checkAndStartFeaturesForProject(projectPath) {
const projectState = this.projectLoops.get(projectPath);
if (!projectState || !projectState.isRunning) {
return;
}
const sendToRenderer = projectState.sendToRenderer;
const maxConcurrency = projectState.maxConcurrency;
try {
// Check how many are currently running
const currentRunningCount = this.runningFeatures.size;
// Check how many are currently running FOR THIS PROJECT
const currentRunningCount = this.getRunningCountForProject(projectPath);
console.log(
`[AutoMode] Checking features - Running: ${currentRunningCount}/${this.maxConcurrency}`
`[AutoMode] [${projectPath}] Checking features - Running: ${currentRunningCount}/${maxConcurrency}`
);
// Calculate available slots
const availableSlots = this.maxConcurrency - currentRunningCount;
// Calculate available slots for this project
const availableSlots = maxConcurrency - currentRunningCount;
if (availableSlots <= 0) {
console.log("[AutoMode] At max concurrency, waiting...");
console.log(`[AutoMode] [${projectPath}] At max concurrency, waiting...`);
return;
}
@@ -616,7 +736,7 @@ class AutoModeService {
const backlogFeatures = features.filter((f) => f.status === "backlog");
if (backlogFeatures.length === 0) {
console.log("[AutoMode] No backlog features available, waiting...");
console.log(`[AutoMode] [${projectPath}] No backlog features available, waiting...`);
return;
}
@@ -624,7 +744,7 @@ class AutoModeService {
const featuresToStart = backlogFeatures.slice(0, availableSlots);
console.log(
`[AutoMode] Starting ${featuresToStart.length} feature(s) from backlog`
`[AutoMode] [${projectPath}] Starting ${featuresToStart.length} feature(s) from backlog`
);
// Start each feature (don't await - run in parallel like drag operations)
@@ -632,7 +752,7 @@ class AutoModeService {
this.startFeatureAsync(feature, projectPath, sendToRenderer);
}
} catch (error) {
console.error("[AutoMode] Error checking/starting features:", error);
console.error(`[AutoMode] [${projectPath}] Error checking/starting features:`, error);
}
}
@@ -678,7 +798,7 @@ class AutoModeService {
projectPath
);
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_feature_start",
featureId: feature.id,
feature: { ...feature, worktreePath: worktreeSetup.workPath, branchName: worktreeSetup.branchName },
@@ -713,7 +833,7 @@ class AutoModeService {
// Keep context file for viewing output later (deleted only when card is removed)
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_feature_complete",
featureId: feature.id,
passes: result.passes,
@@ -746,7 +866,7 @@ class AutoModeService {
console.error("[AutoMode] Failed to update feature status after error:", statusError);
}
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_error",
error: error.message,
featureId: featureId,
@@ -778,7 +898,7 @@ class AutoModeService {
this.runningFeatures.set(analysisId, execution);
try {
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_feature_start",
featureId: analysisId,
feature: {
@@ -796,7 +916,7 @@ class AutoModeService {
execution
);
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_feature_complete",
featureId: analysisId,
passes: result.success,
@@ -806,7 +926,7 @@ class AutoModeService {
return { success: true, message: result.message };
} catch (error) {
console.error("[AutoMode] Error analyzing project:", error);
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_error",
error: error.message,
featureId: analysisId,
@@ -911,7 +1031,7 @@ class AutoModeService {
projectPath
);
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_feature_start",
featureId: feature.id,
feature: feature,
@@ -956,7 +1076,7 @@ class AutoModeService {
// Keep context file for viewing output later (deleted only when card is removed)
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_feature_complete",
featureId: feature.id,
passes: result.passes,
@@ -989,7 +1109,7 @@ class AutoModeService {
console.error("[AutoMode] Failed to update feature status after error:", statusError);
}
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_error",
error: error.message,
featureId: featureId,
@@ -1021,13 +1141,13 @@ class AutoModeService {
throw new Error(`Feature ${featureId} not found`);
}
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_feature_start",
featureId: feature.id,
feature: { ...feature, description: "Committing changes..." },
});
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_phase",
featureId,
phase: "action",
@@ -1051,7 +1171,7 @@ class AutoModeService {
// Keep context file for viewing output later (deleted only when card is removed)
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_feature_complete",
featureId: feature.id,
passes: true,
@@ -1061,7 +1181,7 @@ class AutoModeService {
return { success: true };
} catch (error) {
console.error("[AutoMode] Error committing feature:", error);
sendToRenderer({
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_error",
error: error.message,
featureId: featureId,
@@ -1108,26 +1228,22 @@ class AutoModeService {
// Delete context file
await contextManager.deleteContextFile(projectPath, featureId);
if (sendToRenderer) {
sendToRenderer({
type: "auto_mode_feature_complete",
featureId: featureId,
passes: false,
message: "Feature reverted - all changes discarded",
});
}
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_feature_complete",
featureId: featureId,
passes: false,
message: "Feature reverted - all changes discarded",
});
console.log(`[AutoMode] Feature ${featureId} reverted successfully`);
return { success: true, removedPath: result.removedPath };
} catch (error) {
console.error("[AutoMode] Error reverting feature:", error);
if (sendToRenderer) {
sendToRenderer({
type: "auto_mode_error",
error: error.message,
featureId: featureId,
});
}
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_error",
error: error.message,
featureId: featureId,
});
return { success: false, error: error.message };
}
}
@@ -1147,13 +1263,11 @@ class AutoModeService {
throw new Error(`Feature ${featureId} not found`);
}
if (sendToRenderer) {
sendToRenderer({
type: "auto_mode_progress",
featureId: featureId,
content: "Merging feature branch into main...\n",
});
}
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_progress",
featureId: featureId,
content: "Merging feature branch into main...\n",
});
// Merge the worktree
const result = await worktreeManager.mergeWorktree(projectPath, featureId, {
@@ -1171,26 +1285,22 @@ class AutoModeService {
// Update feature status to verified
await featureLoader.updateFeatureStatus(featureId, "verified", projectPath);
if (sendToRenderer) {
sendToRenderer({
type: "auto_mode_feature_complete",
featureId: featureId,
passes: true,
message: `Feature merged into ${result.intoBranch}`,
});
}
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_feature_complete",
featureId: featureId,
passes: true,
message: `Feature merged into ${result.intoBranch}`,
});
console.log(`[AutoMode] Feature ${featureId} merged successfully`);
return { success: true, mergedBranch: result.mergedBranch };
} catch (error) {
console.error("[AutoMode] Error merging feature:", error);
if (sendToRenderer) {
sendToRenderer({
type: "auto_mode_error",
error: error.message,
featureId: featureId,
});
}
this.emitEvent(projectPath, sendToRenderer, {
type: "auto_mode_error",
error: error.message,
featureId: featureId,
});
return { success: false, error: error.message };
}
}

View File

@@ -70,9 +70,6 @@ app.whenReady().then(async () => {
addAllowedPath(session.projectPath);
}
});
console.log(
`[Security] Pre-loaded ${allowedPaths.size} allowed paths from history`
);
} catch (error) {
console.error("Failed to load sessions for security whitelist:", error);
}
@@ -101,7 +98,6 @@ const allowedPaths = new Set();
function addAllowedPath(pathToAdd) {
if (!pathToAdd) return;
allowedPaths.add(path.resolve(pathToAdd));
console.log(`[Security] Added allowed path: ${pathToAdd}`);
}
/**
@@ -341,7 +337,6 @@ ipcMain.handle(
// Write image to file
await fs.writeFile(imageFilePath, base64Data, "base64");
console.log("[IPC] Saved image to .automaker/images:", imageFilePath);
return { success: true, path: imageFilePath };
} catch (error) {
console.error("[IPC] Failed to save image:", error);
@@ -355,6 +350,17 @@ ipcMain.handle("ping", () => {
return "pong";
});
// Open external link in default browser
ipcMain.handle("shell:openExternal", async (_, url) => {
try {
await shell.openExternal(url);
return { success: true };
} catch (error) {
console.error("[IPC] shell:openExternal error:", error);
return { success: false, error: error.message };
}
});
// ============================================================================
// Agent IPC Handlers
// ============================================================================
@@ -574,11 +580,11 @@ ipcMain.handle(
);
/**
* Stop auto mode
* Stop auto mode for a specific project
*/
ipcMain.handle("auto-mode:stop", async () => {
ipcMain.handle("auto-mode:stop", async (_, { projectPath }) => {
try {
return await autoModeService.stop();
return await autoModeService.stop({ projectPath });
} catch (error) {
console.error("[IPC] auto-mode:stop error:", error);
return { success: false, error: error.message };
@@ -586,11 +592,11 @@ ipcMain.handle("auto-mode:stop", async () => {
});
/**
* Get auto mode status
* Get auto mode status (optionally for a specific project)
*/
ipcMain.handle("auto-mode:status", () => {
ipcMain.handle("auto-mode:status", (_, { projectPath } = {}) => {
try {
return { success: true, ...autoModeService.getStatus() };
return { success: true, ...autoModeService.getStatus({ projectPath }) };
} catch (error) {
console.error("[IPC] auto-mode:status error:", error);
return { success: false, error: error.message };
@@ -629,10 +635,6 @@ ipcMain.handle(
ipcMain.handle(
"auto-mode:verify-feature",
async (_, { projectPath, featureId }) => {
console.log("[IPC] auto-mode:verify-feature called with:", {
projectPath,
featureId,
});
try {
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -658,10 +660,6 @@ ipcMain.handle(
ipcMain.handle(
"auto-mode:resume-feature",
async (_, { projectPath, featureId }) => {
console.log("[IPC] auto-mode:resume-feature called with:", {
projectPath,
featureId,
});
try {
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -712,7 +710,6 @@ ipcMain.handle(
* and update the app_spec.txt with tech stack and implemented features
*/
ipcMain.handle("auto-mode:analyze-project", async (_, { projectPath }) => {
console.log("[IPC] auto-mode:analyze-project called with:", { projectPath });
try {
// Add project path to allowed paths
addAllowedPath(projectPath);
@@ -737,7 +734,6 @@ ipcMain.handle("auto-mode:analyze-project", async (_, { projectPath }) => {
* Stop a specific feature
*/
ipcMain.handle("auto-mode:stop-feature", async (_, { featureId }) => {
console.log("[IPC] auto-mode:stop-feature called with:", { featureId });
try {
return await autoModeService.stopFeature({ featureId });
} catch (error) {
@@ -752,12 +748,6 @@ ipcMain.handle("auto-mode:stop-feature", async (_, { featureId }) => {
ipcMain.handle(
"auto-mode:follow-up-feature",
async (_, { projectPath, featureId, prompt, imagePaths }) => {
console.log("[IPC] auto-mode:follow-up-feature called with:", {
projectPath,
featureId,
prompt,
imagePaths,
});
try {
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -785,10 +775,6 @@ ipcMain.handle(
ipcMain.handle(
"auto-mode:commit-feature",
async (_, { projectPath, featureId }) => {
console.log("[IPC] auto-mode:commit-feature called with:", {
projectPath,
featureId,
});
try {
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -819,7 +805,10 @@ ipcMain.handle("claude:check-cli", async () => {
try {
const claudeCliDetector = require("./services/claude-cli-detector");
const path = require("path");
const credentialsPath = path.join(app.getPath("userData"), "credentials.json");
const credentialsPath = path.join(
app.getPath("userData"),
"credentials.json"
);
const fullStatus = claudeCliDetector.getFullStatus(credentialsPath);
// Return in format expected by settings view (status: "installed" | "not_installed")
@@ -833,7 +822,9 @@ ipcMain.handle("claude:check-cli", async () => {
recommendation: fullStatus.installed
? null
: "Install Claude Code CLI for optimal performance with ultrathink.",
installCommands: fullStatus.installed ? null : claudeCliDetector.getInstallCommands(),
installCommands: fullStatus.installed
? null
: claudeCliDetector.getInstallCommands(),
};
} catch (error) {
console.error("[IPC] claude:check-cli error:", error);
@@ -937,10 +928,10 @@ let suggestionsExecution = null;
/**
* Generate feature suggestions by analyzing the project
* @param {string} projectPath - The path to the project
* @param {string} suggestionType - Type of suggestions: "features", "refactoring", "security", "performance"
*/
ipcMain.handle("suggestions:generate", async (_, { projectPath }) => {
console.log("[IPC] suggestions:generate called with:", { projectPath });
ipcMain.handle("suggestions:generate", async (_, { projectPath, suggestionType = "features" }) => {
try {
// Check if already running
if (suggestionsExecution && suggestionsExecution.isActive()) {
@@ -965,7 +956,7 @@ ipcMain.handle("suggestions:generate", async (_, { projectPath }) => {
// Start generating suggestions (runs in background)
featureSuggestionsService
.generateSuggestions(projectPath, sendToRenderer, suggestionsExecution)
.generateSuggestions(projectPath, sendToRenderer, suggestionsExecution, suggestionType)
.catch((error) => {
console.error("[IPC] suggestions:generate background error:", error);
sendToRenderer({
@@ -990,7 +981,6 @@ ipcMain.handle("suggestions:generate", async (_, { projectPath }) => {
* Stop the current suggestions generation
*/
ipcMain.handle("suggestions:stop", async () => {
console.log("[IPC] suggestions:stop called");
try {
if (suggestionsExecution && suggestionsExecution.abortController) {
suggestionsExecution.abortController.abort();
@@ -1063,10 +1053,6 @@ ipcMain.handle("openai:test-connection", async (_, { apiKey }) => {
ipcMain.handle(
"worktree:revert-feature",
async (_, { projectPath, featureId }) => {
console.log("[IPC] worktree:revert-feature called with:", {
projectPath,
featureId,
});
try {
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -1099,10 +1085,6 @@ let specRegenerationExecution = null;
ipcMain.handle(
"spec-regeneration:generate",
async (_, { projectPath, projectDefinition }) => {
console.log("[IPC] spec-regeneration:generate called with:", {
projectPath,
});
try {
// Add project path to allowed paths
addAllowedPath(projectPath);
@@ -1164,7 +1146,6 @@ ipcMain.handle(
* Stop the current spec regeneration
*/
ipcMain.handle("spec-regeneration:stop", async () => {
console.log("[IPC] spec-regeneration:stop called");
try {
if (
specRegenerationExecution &&
@@ -1198,11 +1179,6 @@ ipcMain.handle("spec-regeneration:status", () => {
ipcMain.handle(
"spec-regeneration:create",
async (_, { projectPath, projectOverview, generateFeatures = true }) => {
console.log("[IPC] spec-regeneration:create called with:", {
projectPath,
generateFeatures,
});
try {
// Add project path to allowed paths
addAllowedPath(projectPath);
@@ -1264,11 +1240,6 @@ ipcMain.handle(
ipcMain.handle(
"worktree:merge-feature",
async (_, { projectPath, featureId, options }) => {
console.log("[IPC] worktree:merge-feature called with:", {
projectPath,
featureId,
options,
});
try {
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -1389,9 +1360,11 @@ ipcMain.handle("git:get-file-diff", async (_, { projectPath, filePath }) => {
ipcMain.handle("setup:claude-status", async () => {
try {
const claudeCliDetector = require("./services/claude-cli-detector");
const credentialsPath = path.join(app.getPath("userData"), "credentials.json");
const credentialsPath = path.join(
app.getPath("userData"),
"credentials.json"
);
const result = claudeCliDetector.getFullStatus(credentialsPath);
console.log("[IPC] setup:claude-status result:", result);
return result;
} catch (error) {
console.error("[IPC] setup:claude-status error:", error);
@@ -1424,7 +1397,7 @@ ipcMain.handle("setup:install-claude", async (event) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("setup:install-progress", {
cli: "claude",
...progress
...progress,
});
}
};
@@ -1448,7 +1421,7 @@ ipcMain.handle("setup:install-codex", async (event) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("setup:install-progress", {
cli: "codex",
...progress
...progress,
});
}
};
@@ -1472,7 +1445,7 @@ ipcMain.handle("setup:auth-claude", async (event) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("setup:auth-progress", {
cli: "claude",
...progress
...progress,
});
}
};
@@ -1496,7 +1469,7 @@ ipcMain.handle("setup:auth-codex", async (event, { apiKey }) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("setup:auth-progress", {
cli: "codex",
...progress
...progress,
});
}
};
@@ -1516,7 +1489,6 @@ ipcMain.handle("setup:auth-codex", async (event, { apiKey }) => {
*/
ipcMain.handle("setup:store-api-key", async (_, { provider, apiKey }) => {
try {
console.log("[IPC] setup:store-api-key called for provider:", provider);
const configPath = path.join(app.getPath("userData"), "credentials.json");
let credentials = {};
@@ -1532,9 +1504,12 @@ ipcMain.handle("setup:store-api-key", async (_, { provider, apiKey }) => {
credentials[provider] = apiKey;
// Write back
await fs.writeFile(configPath, JSON.stringify(credentials, null, 2), "utf-8");
await fs.writeFile(
configPath,
JSON.stringify(credentials, null, 2),
"utf-8"
);
console.log("[IPC] setup:store-api-key stored successfully for:", provider);
return { success: true };
} catch (error) {
console.error("[IPC] setup:store-api-key error:", error);
@@ -1559,7 +1534,7 @@ ipcMain.handle("setup:get-api-keys", async () => {
hasAnthropicKey: !!credentials.anthropic,
hasAnthropicOAuthToken: !!credentials.anthropic_oauth_token,
hasOpenAIKey: !!credentials.openai,
hasGoogleKey: !!credentials.google
hasGoogleKey: !!credentials.google,
};
} catch (e) {
return {
@@ -1567,7 +1542,7 @@ ipcMain.handle("setup:get-api-keys", async () => {
hasAnthropicKey: false,
hasAnthropicOAuthToken: false,
hasOpenAIKey: false,
hasGoogleKey: false
hasGoogleKey: false,
};
}
} catch (error) {
@@ -1582,9 +1557,16 @@ ipcMain.handle("setup:get-api-keys", async () => {
ipcMain.handle("setup:configure-codex-mcp", async (_, { projectPath }) => {
try {
const codexConfigManager = require("./services/codex-config-manager");
const mcpServerPath = path.join(__dirname, "services", "mcp-server-factory.js");
const mcpServerPath = path.join(
__dirname,
"services",
"mcp-server-factory.js"
);
const configPath = await codexConfigManager.configureMcpServer(projectPath, mcpServerPath);
const configPath = await codexConfigManager.configureMcpServer(
projectPath,
mcpServerPath
);
return { success: true, configPath };
} catch (error) {
@@ -1605,6 +1587,193 @@ ipcMain.handle("setup:get-platform", async () => {
homeDir: os.homedir(),
isWindows: process.platform === "win32",
isMac: process.platform === "darwin",
isLinux: process.platform === "linux"
isLinux: process.platform === "linux",
};
});
// ============================================================================
// Features IPC Handlers
// ============================================================================
/**
* Get all features for a project
*/
ipcMain.handle("features:getAll", async (_, { projectPath }) => {
try {
// Security check
if (!isPathAllowed(projectPath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
const featureLoader = require("./services/feature-loader");
const features = await featureLoader.getAll(projectPath);
return { success: true, features };
} catch (error) {
console.error("[IPC] features:getAll error:", error);
return { success: false, error: error.message };
}
});
/**
* Get a single feature by ID
*/
ipcMain.handle("features:get", async (_, { projectPath, featureId }) => {
try {
// Security check
if (!isPathAllowed(projectPath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
const featureLoader = require("./services/feature-loader");
const feature = await featureLoader.get(projectPath, featureId);
if (!feature) {
return { success: false, error: "Feature not found" };
}
return { success: true, feature };
} catch (error) {
console.error("[IPC] features:get error:", error);
return { success: false, error: error.message };
}
});
/**
* Create a new feature
*/
ipcMain.handle("features:create", async (_, { projectPath, feature }) => {
try {
// Security check
if (!isPathAllowed(projectPath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
const featureLoader = require("./services/feature-loader");
const createdFeature = await featureLoader.create(projectPath, feature);
return { success: true, feature: createdFeature };
} catch (error) {
console.error("[IPC] features:create error:", error);
return { success: false, error: error.message };
}
});
/**
* Update a feature (partial updates supported)
*/
ipcMain.handle(
"features:update",
async (_, { projectPath, featureId, updates }) => {
try {
// Security check
if (!isPathAllowed(projectPath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
const featureLoader = require("./services/feature-loader");
const updatedFeature = await featureLoader.update(
projectPath,
featureId,
updates
);
return { success: true, feature: updatedFeature };
} catch (error) {
console.error("[IPC] features:update error:", error);
return { success: false, error: error.message };
}
}
);
/**
* Delete a feature and its folder
*/
ipcMain.handle("features:delete", async (_, { projectPath, featureId }) => {
try {
// Security check
if (!isPathAllowed(projectPath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
const featureLoader = require("./services/feature-loader");
await featureLoader.delete(projectPath, featureId);
return { success: true };
} catch (error) {
console.error("[IPC] features:delete error:", error);
return { success: false, error: error.message };
}
});
/**
* Get agent output for a feature
*/
ipcMain.handle(
"features:getAgentOutput",
async (_, { projectPath, featureId }) => {
try {
// Security check
if (!isPathAllowed(projectPath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
const featureLoader = require("./services/feature-loader");
const content = await featureLoader.getAgentOutput(projectPath, featureId);
return { success: true, content };
} catch (error) {
console.error("[IPC] features:getAgentOutput error:", error);
return { success: false, error: error.message };
}
}
);
// ============================================================================
// Running Agents IPC Handlers
// ============================================================================
/**
* Get all currently running agents across all projects
*/
ipcMain.handle("running-agents:getAll", () => {
try {
const status = autoModeService.getStatus();
const allStatuses = autoModeService.getAllProjectStatuses();
// Build a list of running agents with their details
const runningAgents = [];
for (const [projectPath, projectStatus] of Object.entries(allStatuses)) {
for (const featureId of projectStatus.runningFeatures) {
runningAgents.push({
featureId,
projectPath,
projectName: projectPath.split(/[/\\]/).pop() || projectPath,
isAutoMode: projectStatus.isRunning,
});
}
}
return {
success: true,
runningAgents,
totalCount: status.runningCount,
autoLoopRunning: status.autoLoopRunning,
};
} catch (error) {
console.error("[IPC] running-agents:getAll error:", error);
return { success: false, error: error.message };
}
});

View File

@@ -6,6 +6,9 @@ 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),
@@ -24,7 +27,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
// App APIs
getPath: (name) => ipcRenderer.invoke("app:getPath", name),
saveImageToTemp: (data, filename, mimeType, projectPath) =>
ipcRenderer.invoke("app:saveImageToTemp", { data, filename, mimeType, projectPath }),
ipcRenderer.invoke("app:saveImageToTemp", {
data,
filename,
mimeType,
projectPath,
}),
// Agent APIs
agent: {
@@ -34,19 +42,22 @@ contextBridge.exposeInMainWorld("electronAPI", {
// Send a message to the agent
send: (sessionId, message, workingDirectory, imagePaths) =>
ipcRenderer.invoke("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 }),
stop: (sessionId) => ipcRenderer.invoke("agent:stop", { sessionId }),
// Clear conversation
clear: (sessionId) =>
ipcRenderer.invoke("agent:clear", { sessionId }),
clear: (sessionId) => ipcRenderer.invoke("agent:clear", { sessionId }),
// Subscribe to streaming events
onStream: (callback) => {
@@ -65,7 +76,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
// Create a new session
create: (name, projectPath, workingDirectory) =>
ipcRenderer.invoke("sessions:create", { name, projectPath, workingDirectory }),
ipcRenderer.invoke("sessions:create", {
name,
projectPath,
workingDirectory,
}),
// Update session metadata
update: (sessionId, name, tags) =>
@@ -80,37 +95,49 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.invoke("sessions:unarchive", { sessionId }),
// Delete a session permanently
delete: (sessionId) =>
ipcRenderer.invoke("sessions:delete", { sessionId }),
delete: (sessionId) => ipcRenderer.invoke("sessions:delete", { sessionId }),
},
// Auto Mode API
autoMode: {
// Start auto mode
// Start auto mode for a specific project
start: (projectPath, maxConcurrency) =>
ipcRenderer.invoke("auto-mode:start", { projectPath, maxConcurrency }),
// Stop auto mode
stop: () => ipcRenderer.invoke("auto-mode:stop"),
// Stop auto mode for a specific project
stop: (projectPath) => ipcRenderer.invoke("auto-mode:stop", { projectPath }),
// Get auto mode status
status: () => ipcRenderer.invoke("auto-mode:status"),
// 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 }),
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 }),
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 }),
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 }),
ipcRenderer.invoke("auto-mode:context-exists", {
projectPath,
featureId,
}),
// Analyze a new project - kicks off an agent to analyze codebase
analyzeProject: (projectPath) =>
@@ -122,11 +149,19 @@ contextBridge.exposeInMainWorld("electronAPI", {
// Follow-up on a feature with additional prompt
followUpFeature: (projectPath, featureId, prompt, imagePaths) =>
ipcRenderer.invoke("auto-mode:follow-up-feature", { 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 }),
ipcRenderer.invoke("auto-mode:commit-feature", {
projectPath,
featureId,
}),
// Listen for auto mode events
onEvent: (callback) => {
@@ -167,7 +202,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
// Merge feature worktree changes back to main branch
mergeFeature: (projectPath, featureId, options) =>
ipcRenderer.invoke("worktree:merge-feature", { projectPath, featureId, options }),
ipcRenderer.invoke("worktree:merge-feature", {
projectPath,
featureId,
options,
}),
// Get worktree info for a feature
getInfo: (projectPath, featureId) =>
@@ -178,8 +217,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.invoke("worktree:get-status", { projectPath, featureId }),
// List all feature worktrees
list: (projectPath) =>
ipcRenderer.invoke("worktree:list", { projectPath }),
list: (projectPath) => ipcRenderer.invoke("worktree:list", { projectPath }),
// Get file diffs for a feature worktree
getDiffs: (projectPath, featureId) =>
@@ -187,7 +225,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
// Get diff for a specific file in a worktree
getFileDiff: (projectPath, featureId, filePath) =>
ipcRenderer.invoke("worktree:get-file-diff", { projectPath, featureId, filePath }),
ipcRenderer.invoke("worktree:get-file-diff", {
projectPath,
featureId,
filePath,
}),
},
// Git Operations APIs (for non-worktree operations)
@@ -204,8 +246,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
// Feature Suggestions API
suggestions: {
// Generate feature suggestions
generate: (projectPath) =>
ipcRenderer.invoke("suggestions:generate", { projectPath }),
// 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"),
@@ -229,11 +272,18 @@ contextBridge.exposeInMainWorld("electronAPI", {
specRegeneration: {
// Create initial app spec for a new project
create: (projectPath, projectOverview, generateFeatures = true) =>
ipcRenderer.invoke("spec-regeneration:create", { projectPath, projectOverview, generateFeatures }),
ipcRenderer.invoke("spec-regeneration:create", {
projectPath,
projectOverview,
generateFeatures,
}),
// Regenerate the app spec
generate: (projectPath, projectDefinition) =>
ipcRenderer.invoke("spec-regeneration:generate", { projectPath, projectDefinition }),
ipcRenderer.invoke("spec-regeneration:generate", {
projectPath,
projectDefinition,
}),
// Stop regenerating spec
stop: () => ipcRenderer.invoke("spec-regeneration:stop"),
@@ -305,6 +355,43 @@ contextBridge.exposeInMainWorld("electronAPI", {
};
},
},
// 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

View File

@@ -1,7 +1,7 @@
const { execSync, spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync, spawn } = require("child_process");
const fs = require("fs");
const path = require("path");
const os = require("os");
/**
* Claude CLI Detector
@@ -21,41 +21,43 @@ class ClaudeCliDetector {
*/
static getUpdatedPathFromShellConfig() {
const homeDir = os.homedir();
const shell = process.env.SHELL || '/bin/bash';
const shell = process.env.SHELL || "/bin/bash";
const shellName = path.basename(shell);
// Common shell config files
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'));
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"));
}
// Also check common locations
const commonPaths = [
path.join(homeDir, '.local', 'bin'),
path.join(homeDir, '.cargo', 'bin'),
'/usr/local/bin',
'/opt/homebrew/bin',
path.join(homeDir, 'bin'),
path.join(homeDir, ".local", "bin"),
path.join(homeDir, ".cargo", "bin"),
"/usr/local/bin",
"/opt/homebrew/bin",
path.join(homeDir, "bin"),
];
// Try to extract PATH additions from config files
for (const configFile of configFiles) {
if (fs.existsSync(configFile)) {
try {
const content = fs.readFileSync(configFile, 'utf-8');
// Look for PATH exports that might include claude installation paths
const pathMatches = content.match(/export\s+PATH=["']?([^"'\n]+)["']?/g);
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('$'));
const pathValue = match
.replace(/export\s+PATH=["']?/, "")
.replace(/["']?$/, "");
const paths = pathValue
.split(":")
.filter((p) => p && !p.includes("$"));
commonPaths.push(...paths);
}
}
@@ -64,45 +66,47 @@ class ClaudeCliDetector {
}
}
}
return [...new Set(commonPaths)]; // Remove duplicates
return [...new Set(commonPaths)];
}
static detectClaudeInstallation() {
console.log('[ClaudeCliDetector] Detecting Claude installation...');
try {
// Method 1: Check if 'claude' command is in PATH (Unix)
if (process.platform !== 'win32') {
// 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();
const claudePath = execSync("which claude 2>/dev/null", {
encoding: "utf-8",
}).trim();
if (claudePath) {
const version = this.getClaudeVersion(claudePath);
console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version);
return {
installed: true,
path: claudePath,
version: version,
method: 'cli'
method: "cli",
};
}
} catch (error) {
// CLI not in PATH, continue checking other locations
// CLI not in PATH
}
}
// Method 2: Check Windows path
if (process.platform === 'win32') {
// Check Windows path
if (process.platform === "win32") {
try {
const claudePath = execSync('where claude 2>nul', { encoding: 'utf-8' }).trim().split('\n')[0];
const claudePath = execSync("where claude 2>nul", {
encoding: "utf-8",
})
.trim()
.split("\n")[0];
if (claudePath) {
const version = this.getClaudeVersion(claudePath);
console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version);
return {
installed: true,
path: claudePath,
version: version,
method: 'cli'
method: "cli",
};
}
} catch (error) {
@@ -110,90 +114,93 @@ class ClaudeCliDetector {
}
}
// Method 3: Check for local installation
const localClaudePath = path.join(os.homedir(), '.claude', 'local', 'claude');
// Check for local installation
const localClaudePath = path.join(
os.homedir(),
".claude",
"local",
"claude"
);
if (fs.existsSync(localClaudePath)) {
const version = this.getClaudeVersion(localClaudePath);
console.log('[ClaudeCliDetector] Found local claude at:', localClaudePath, 'version:', version);
return {
installed: true,
path: localClaudePath,
version: version,
method: 'cli-local'
method: "cli-local",
};
}
// Method 4: Check common installation locations (including those from shell config)
// Check common installation locations
const commonPaths = this.getUpdatedPathFromShellConfig();
const binaryNames = ['claude', 'claude-code'];
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);
console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version);
return {
installed: true,
path: claudePath,
version: version,
method: 'cli'
method: "cli",
};
} catch (error) {
// File exists but can't get version, might not be executable
// File exists but can't get version
}
}
}
}
// Method 5: Try to source shell config and check PATH again (for Unix)
if (process.platform !== 'win32') {
// Try to source shell config and check PATH again (Unix)
if (process.platform !== "win32") {
try {
const shell = process.env.SHELL || '/bin/bash';
const shell = process.env.SHELL || "/bin/bash";
const shellName = path.basename(shell);
const homeDir = os.homedir();
let sourceCmd = '';
if (shellName.includes('zsh')) {
let sourceCmd = "";
if (shellName.includes("zsh")) {
sourceCmd = `source ${homeDir}/.zshrc 2>/dev/null && which claude`;
} else if (shellName.includes('bash')) {
} 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 claudePath = execSync(`bash -c "${sourceCmd}"`, {
encoding: "utf-8",
timeout: 2000,
}).trim();
if (claudePath && claudePath.startsWith("/")) {
const version = this.getClaudeVersion(claudePath);
console.log('[ClaudeCliDetector] Found claude via shell config at:', claudePath, 'version:', version);
return {
installed: true,
path: claudePath,
version: version,
method: 'cli'
method: "cli",
};
}
}
} catch (error) {
// Failed to source shell config or find claude
// Failed to source shell config
}
}
console.log('[ClaudeCliDetector] Claude CLI not found');
return {
installed: false,
path: null,
version: null,
method: 'none'
method: "none",
};
} catch (error) {
console.error('[ClaudeCliDetector] Error detecting Claude installation:', error);
return {
installed: false,
path: null,
version: null,
method: 'none',
error: error.message
method: "none",
error: error.message,
};
}
}
@@ -206,8 +213,8 @@ class ClaudeCliDetector {
static getClaudeVersion(claudePath) {
try {
const version = execSync(`"${claudePath}" --version 2>/dev/null`, {
encoding: 'utf-8',
timeout: 5000
encoding: "utf-8",
timeout: 5000,
}).trim();
return version || null;
} catch (error) {
@@ -226,61 +233,68 @@ class ClaudeCliDetector {
* @returns {Object} Authentication status
*/
static getAuthStatus(appCredentialsPath) {
console.log('[ClaudeCliDetector] Checking auth status...');
const envApiKey = process.env.ANTHROPIC_API_KEY;
console.log('[ClaudeCliDetector] Env ANTHROPIC_API_KEY:', !!envApiKey);
const envOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
// Check app's stored credentials
let storedOAuthToken = null;
let storedApiKey = null;
if (appCredentialsPath && fs.existsSync(appCredentialsPath)) {
try {
const content = fs.readFileSync(appCredentialsPath, 'utf-8');
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;
console.log('[ClaudeCliDetector] App credentials:', {
hasOAuthToken: !!storedOAuthToken,
hasApiKey: !!storedApiKey
});
storedApiKey =
credentials.anthropic || credentials.anthropic_api_key || null;
} catch (error) {
console.error('[ClaudeCliDetector] Error reading app credentials:', error);
// Ignore credential read errors
}
}
// Determine authentication method
// Priority: Stored OAuth Token > Stored API Key > Env API Key
// 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';
let method = "none";
if (storedOAuthToken) {
if (envOAuthToken) {
authenticated = true;
method = 'oauth_token';
console.log('[ClaudeCliDetector] Using stored OAuth token (subscription)');
method = "oauth_token_env";
} else if (storedOAuthToken) {
authenticated = true;
method = "oauth_token";
} else if (storedApiKey) {
authenticated = true;
method = 'api_key';
console.log('[ClaudeCliDetector] Using stored API key');
method = "api_key";
} else if (envApiKey) {
authenticated = true;
method = 'api_key_env';
console.log('[ClaudeCliDetector] Using environment API key');
} else {
console.log('[ClaudeCliDetector] No authentication found');
method = "api_key_env";
}
const result = {
return {
authenticated,
method,
hasStoredOAuthToken: !!storedOAuthToken,
hasStoredApiKey: !!storedApiKey,
hasEnvApiKey: !!envApiKey
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,
};
console.log('[ClaudeCliDetector] Auth status result:', result);
return result;
}
/**
@@ -294,12 +308,36 @@ class ClaudeCliDetector {
return {
success: true,
status: installation.installed ? 'installed' : 'not_installed',
status: installation.installed ? "installed" : "not_installed",
installed: installation.installed,
path: installation.path,
version: installation.version,
method: installation.method,
auth
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()
};
}
@@ -309,9 +347,9 @@ class ClaudeCliDetector {
*/
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'
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",
};
}
@@ -325,64 +363,69 @@ class ClaudeCliDetector {
const platform = process.platform;
let command, args;
if (platform === 'win32') {
command = 'powershell';
args = ['-Command', 'irm https://claude.ai/install.ps1 | iex'];
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'];
command = "bash";
args = ["-c", "curl -fsSL https://claude.ai/install.sh | bash"];
}
console.log('[ClaudeCliDetector] Installing Claude CLI...');
console.log("[ClaudeCliDetector] Installing Claude CLI...");
const proc = spawn(command, args, {
stdio: ['pipe', 'pipe', 'pipe'],
shell: false
stdio: ["pipe", "pipe", "pipe"],
shell: false,
});
let output = '';
let errorOutput = '';
let output = "";
let errorOutput = "";
proc.stdout.on('data', (data) => {
proc.stdout.on("data", (data) => {
const text = data.toString();
output += text;
if (onProgress) {
onProgress({ type: 'stdout', data: text });
onProgress({ type: "stdout", data: text });
}
});
proc.stderr.on('data', (data) => {
proc.stderr.on("data", (data) => {
const text = data.toString();
errorOutput += text;
if (onProgress) {
onProgress({ type: 'stderr', data: text });
onProgress({ type: "stderr", data: text });
}
});
proc.on('close', (code) => {
proc.on("close", (code) => {
if (code === 0) {
console.log('[ClaudeCliDetector] Installation completed successfully');
console.log(
"[ClaudeCliDetector] Installation completed successfully"
);
resolve({
success: true,
output,
message: 'Claude CLI installed successfully'
message: "Claude CLI installed successfully",
});
} else {
console.error('[ClaudeCliDetector] Installation failed with code:', code);
console.error(
"[ClaudeCliDetector] Installation failed with code:",
code
);
reject({
success: false,
error: errorOutput || `Installation failed with code ${code}`,
output
output,
});
}
});
proc.on('error', (error) => {
console.error('[ClaudeCliDetector] Installation error:', error);
proc.on("error", (error) => {
console.error("[ClaudeCliDetector] Installation error:", error);
reject({
success: false,
error: error.message,
output
output,
});
});
});
@@ -398,22 +441,22 @@ class ClaudeCliDetector {
if (!detection.installed) {
return {
success: false,
error: 'Claude CLI is not installed. Please install it first.',
installCommands: this.getInstallCommands()
error: "Claude CLI is not installed. Please install it first.",
installCommands: this.getInstallCommands(),
};
}
return {
success: true,
command: 'claude setup-token',
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'
"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.'
note: "This token is from your Claude subscription and allows you to use Claude without API charges.",
};
}
}

View File

@@ -32,35 +32,28 @@ class CodexCliDetector {
* @returns {Object} Authentication status
*/
static checkAuth() {
console.log('[CodexCliDetector] Checking auth status...');
try {
const authPath = this.getAuthPath();
const envApiKey = process.env.OPENAI_API_KEY;
console.log('[CodexCliDetector] Auth path:', authPath);
console.log('[CodexCliDetector] Has env API key:', !!envApiKey);
// First, try to verify authentication using codex CLI command if available
// Try to verify authentication using codex CLI command if available
try {
const detection = this.detectCodexInstallation();
if (detection.installed) {
try {
// Use 'codex login status' to verify authentication
const statusOutput = execSync(`"${detection.path || 'codex'}" login status 2>/dev/null`, {
const statusOutput = execSync(`"${detection.path || 'codex'}" login status 2>/dev/null`, {
encoding: 'utf-8',
timeout: 5000
timeout: 5000
});
// If command succeeds and shows logged in status
if (statusOutput && (statusOutput.includes('Logged in') || statusOutput.includes('Authenticated'))) {
const result = {
return {
authenticated: true,
method: 'cli_verified',
hasAuthFile: fs.existsSync(authPath),
hasEnvKey: !!envApiKey,
authPath
};
console.log('[CodexCliDetector] Auth result (cli_verified):', result);
return result;
}
} catch (statusError) {
// status command failed, continue with file-based check
@@ -72,14 +65,38 @@ class CodexCliDetector {
// Check if auth file exists
if (fs.existsSync(authPath)) {
const content = fs.readFileSync(authPath, 'utf-8');
const auth = JSON.parse(content);
let auth = null;
try {
const content = fs.readFileSync(authPath, 'utf-8');
auth = JSON.parse(content);
// Check for token object structure (from codex auth login)
// Structure: { token: { Id_token, access_token, refresh_token }, last_refresh: ... }
if (auth.token && typeof auth.token === 'object') {
const token = auth.token;
if (token.Id_token || token.access_token || token.refresh_token || token.id_token) {
// 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',
@@ -88,126 +105,82 @@ class CodexCliDetector {
authPath
};
}
} catch (error) {
return {
authenticated: false,
method: 'none',
hasAuthFile: false,
hasEnvKey: !!envApiKey,
authPath
};
}
// Check for various possible auth fields that codex might use
if (auth.api_key || auth.openai_api_key || auth.access_token || auth.apiKey) {
if (!auth) {
return {
authenticated: true,
method: 'auth_file',
authenticated: false,
method: 'none',
hasAuthFile: true,
hasEnvKey: !!envApiKey,
authPath
};
}
// Also check if the file has any meaningful content (non-empty object)
const keys = Object.keys(auth);
if (keys.length > 0) {
// File exists and has content, likely authenticated
// Try to verify by checking if codex command works
try {
const detection = this.detectCodexInstallation();
if (detection.installed) {
// Try to verify auth by running a simple command
try {
execSync(`"${detection.path || 'codex'}" --version 2>/dev/null`, {
encoding: 'utf-8',
timeout: 3000
});
// If command succeeds, assume authenticated
return {
authenticated: true,
method: 'auth_file',
hasAuthFile: true,
hasEnvKey: !!envApiKey,
authPath
};
} catch (cmdError) {
// Command failed, but file exists - might still be authenticated
// Return authenticated if file has content
return {
authenticated: true,
method: 'auth_file',
hasAuthFile: true,
hasEnvKey: !!envApiKey,
authPath
};
}
}
} catch (verifyError) {
// Verification failed, but file exists with content
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: 'auth_file',
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) {
const result = {
return {
authenticated: true,
method: 'env_var',
hasAuthFile: false,
hasEnvKey: true,
authPath
};
console.log('[CodexCliDetector] Auth result (env_var):', result);
return result;
}
// If auth file exists but we didn't find standard keys,
// check if codex CLI is installed and try to verify auth
if (fs.existsSync(authPath)) {
try {
const detection = this.detectCodexInstallation();
if (detection.installed) {
// Auth file exists and CLI is installed - likely authenticated
// The file existing is a good indicator that login was successful
return {
authenticated: true,
method: 'auth_file',
hasAuthFile: true,
hasEnvKey: !!envApiKey,
authPath
};
}
} catch (verifyError) {
// Verification attempt failed, but file exists
// Assume authenticated if file exists
return {
authenticated: true,
method: 'auth_file',
hasAuthFile: true,
hasEnvKey: !!envApiKey,
authPath
};
}
}
const result = {
return {
authenticated: false,
method: 'none',
hasAuthFile: false,
hasEnvKey: false,
authPath
};
console.log('[CodexCliDetector] Auth result (not authenticated):', result);
return result;
} catch (error) {
console.error('[CodexCliDetector] Error checking auth:', error);
const result = {
return {
authenticated: false,
method: 'none',
error: error.message
};
console.log('[CodexCliDetector] Auth result (error):', result);
return result;
}
}
/**
@@ -327,7 +300,7 @@ class CodexCliDetector {
method: 'none'
};
} catch (error) {
console.error('[CodexCliDetector] Error detecting Codex installation:', error);
// Error detecting Codex installation
return {
installed: false,
path: null,

View File

@@ -349,3 +349,5 @@ class CodexConfigManager {
}
module.exports = new CodexConfigManager();

View File

@@ -12,16 +12,21 @@ class ContextManager {
if (!projectPath) return;
try {
const contextDir = path.join(projectPath, ".automaker", "agents-context");
const featureDir = path.join(
projectPath,
".automaker",
"features",
featureId
);
// Ensure directory exists
// Ensure feature directory exists
try {
await fs.access(contextDir);
await fs.access(featureDir);
} catch {
await fs.mkdir(contextDir, { recursive: true });
await fs.mkdir(featureDir, { recursive: true });
}
const filePath = path.join(contextDir, `${featureId}.md`);
const filePath = path.join(featureDir, "agent-output.md");
// Append to existing file or create new one
try {
@@ -43,8 +48,9 @@ class ContextManager {
const contextPath = path.join(
projectPath,
".automaker",
"agents-context",
`${featureId}.md`
"features",
featureId,
"agent-output.md"
);
const content = await fs.readFile(contextPath, "utf-8");
return content;
@@ -64,8 +70,9 @@ class ContextManager {
const contextPath = path.join(
projectPath,
".automaker",
"agents-context",
`${featureId}.md`
"features",
featureId,
"agent-output.md"
);
await fs.unlink(contextPath);
console.log(
@@ -213,13 +220,18 @@ This helps future agent runs avoid the same pitfalls.
try {
const { execSync } = require("child_process");
const contextDir = path.join(projectPath, ".automaker", "agents-context");
const featureDir = path.join(
projectPath,
".automaker",
"features",
featureId
);
// Ensure directory exists
// Ensure feature directory exists
try {
await fs.access(contextDir);
await fs.access(featureDir);
} catch {
await fs.mkdir(contextDir, { recursive: true });
await fs.mkdir(featureDir, { recursive: true });
}
// Get list of modified files (both staged and unstaged)
@@ -233,25 +245,34 @@ This helps future agent runs avoid the same pitfalls.
modifiedFiles = modifiedOutput.split("\n").filter(Boolean);
}
} catch (error) {
console.log("[ContextManager] No modified files or git error:", error.message);
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();
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);
console.log(
"[ContextManager] Error getting untracked files:",
error.message
);
}
// Save the initial state to a JSON file
const stateFile = path.join(contextDir, `${featureId}-git-state.json`);
const stateFile = path.join(featureDir, "git-state.json");
const state = {
timestamp: new Date().toISOString(),
modifiedFiles,
@@ -259,14 +280,20 @@ This helps future agent runs avoid the same pitfalls.
};
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,
});
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);
console.error(
"[ContextManager] Failed to save initial git state:",
error
);
return { modifiedFiles: [], untrackedFiles: [] };
}
}
@@ -284,13 +311,16 @@ This helps future agent runs avoid the same pitfalls.
const stateFile = path.join(
projectPath,
".automaker",
"agents-context",
`${featureId}-git-state.json`
"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}`);
console.log(
`[ContextManager] No initial git state found for ${featureId}`
);
return null;
}
}
@@ -307,15 +337,19 @@ This helps future agent runs avoid the same pitfalls.
const stateFile = path.join(
projectPath,
".automaker",
"agents-context",
`${featureId}-git-state.json`
"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);
console.error(
"[ContextManager] Failed to delete git state file:",
error
);
}
}
}
@@ -334,7 +368,10 @@ This helps future agent runs avoid the same pitfalls.
const { execSync } = require("child_process");
// Get initial state
const initialState = await this.getInitialGitState(projectPath, featureId);
const initialState = await this.getInitialGitState(
projectPath,
featureId
);
// Get current state
let currentModified = [];
@@ -352,10 +389,13 @@ This helps future agent runs avoid the same pitfalls.
let currentUntracked = [];
try {
const untrackedOutput = execSync("git ls-files --others --exclude-standard", {
cwd: projectPath,
encoding: "utf-8",
}).trim();
const untrackedOutput = execSync(
"git ls-files --others --exclude-standard",
{
cwd: projectPath,
encoding: "utf-8",
}
).trim();
if (untrackedOutput) {
currentUntracked = untrackedOutput.split("\n").filter(Boolean);
}
@@ -365,7 +405,9 @@ This helps future agent runs avoid the same pitfalls.
if (!initialState) {
// No initial state - all current changes are considered from this session
console.log("[ContextManager] No initial state found, returning all current changes");
console.log(
"[ContextManager] No initial state found, returning all current changes"
);
return {
newFiles: currentUntracked,
modifiedFiles: currentModified,
@@ -377,21 +419,31 @@ This helps future agent runs avoid the same pitfalls.
const initialUntrackedSet = new Set(initialState.untrackedFiles || []);
// New files = current untracked - initial untracked
const newFiles = currentUntracked.filter(f => !initialUntrackedSet.has(f));
const newFiles = currentUntracked.filter(
(f) => !initialUntrackedSet.has(f)
);
// Modified files = current modified - initial modified
const modifiedFiles = currentModified.filter(f => !initialModifiedSet.has(f));
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,
});
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);
console.error(
"[ContextManager] Failed to calculate changed files:",
error
);
return { newFiles: [], modifiedFiles: [] };
}
}

View File

@@ -2,36 +2,343 @@ const path = require("path");
const fs = require("fs/promises");
/**
* Feature Loader - Handles loading and selecting features from feature_list.json
* Feature Loader - Handles loading and managing features from individual feature folders
* Each feature is stored in .automaker/features/{featureId}/feature.json
*/
class FeatureLoader {
/**
* Load features from .automaker/feature_list.json
* Get the features directory path
*/
async loadFeatures(projectPath) {
const featuresPath = path.join(
projectPath,
".automaker",
"feature_list.json"
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 content = await fs.readFile(featuresPath, "utf-8");
const features = JSON.parse(content);
const featuresDir = this.getFeaturesDir(projectPath);
// Ensure each feature has an ID
return features.map((f, index) => ({
...f,
id: f.id || `feature-${index}-${Date.now()}`,
}));
// 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 {
const content = await fs.readFile(featureJsonPath, "utf-8");
const feature = JSON.parse(content);
features.push(feature);
} catch (error) {
console.error(
`[FeatureLoader] Failed to load feature ${featureId}:`,
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 load features:", error);
console.error("[FeatureLoader] Failed to get all features:", error);
return [];
}
}
/**
* Update feature status in .automaker/feature_list.json
* 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
* @param {string} featureId - The ID of the feature to update
* @param {string} status - The new status
* @param {string} projectPath - Path to the project
@@ -39,126 +346,26 @@ class FeatureLoader {
* @param {string} [error] - Optional error message if feature errored
*/
async updateFeatureStatus(featureId, status, projectPath, summary, error) {
const featuresPath = path.join(
projectPath,
".automaker",
"feature_list.json"
);
// 🛡️ SAFETY: Create backup before any modification
const backupPath = path.join(
projectPath,
".automaker",
"feature_list.backup.json"
);
try {
const originalContent = await fs.readFile(featuresPath, "utf-8");
await fs.writeFile(backupPath, originalContent, "utf-8");
console.log(`[FeatureLoader] Created backup at ${backupPath}`);
} catch (error) {
console.warn(`[FeatureLoader] Could not create backup: ${error.message}`);
const updates = { status };
if (summary !== undefined) {
updates.summary = summary;
}
const features = await this.loadFeatures(projectPath);
// 🛡️ VALIDATION: Ensure we loaded features successfully
if (!Array.isArray(features)) {
throw new Error("CRITICAL: features is not an array - aborting to prevent data loss");
}
if (features.length === 0) {
console.warn(`[FeatureLoader] WARNING: Feature list is empty. This may indicate corruption.`);
// Try to restore from backup
try {
const backupContent = await fs.readFile(backupPath, "utf-8");
const backupFeatures = JSON.parse(backupContent);
if (Array.isArray(backupFeatures) && backupFeatures.length > 0) {
console.log(`[FeatureLoader] Restored ${backupFeatures.length} features from backup`);
// Use backup features instead
features.length = 0;
features.push(...backupFeatures);
}
} catch (backupError) {
console.error(`[FeatureLoader] Could not restore from backup: ${backupError.message}`);
}
}
const feature = features.find((f) => f.id === featureId);
if (!feature) {
console.error(`[FeatureLoader] Feature ${featureId} not found`);
return;
}
// Update the status field
feature.status = status;
// Update the summary field if provided
if (summary) {
feature.summary = summary;
}
// Update the error field (set or clear)
if (error) {
feature.error = error;
if (error !== undefined) {
updates.error = error;
} else {
// Clear any previous error when status changes without error
delete feature.error;
// Clear error if not provided
const feature = await this.get(projectPath, featureId);
if (feature && feature.error) {
updates.error = undefined;
}
}
// Save back to file
const toSave = features.map((f) => {
const featureData = {
id: f.id,
category: f.category,
description: f.description,
steps: f.steps,
status: f.status,
};
// Preserve optional fields if they exist
if (f.skipTests !== undefined) {
featureData.skipTests = f.skipTests;
}
if (f.images !== undefined) {
featureData.images = f.images;
}
if (f.imagePaths !== undefined) {
featureData.imagePaths = f.imagePaths;
}
if (f.startedAt !== undefined) {
featureData.startedAt = f.startedAt;
}
if (f.summary !== undefined) {
featureData.summary = f.summary;
}
if (f.model !== undefined) {
featureData.model = f.model;
}
if (f.thinkingLevel !== undefined) {
featureData.thinkingLevel = f.thinkingLevel;
}
if (f.error !== undefined) {
featureData.error = f.error;
}
// Preserve worktree info
if (f.worktreePath !== undefined) {
featureData.worktreePath = f.worktreePath;
}
if (f.branchName !== undefined) {
featureData.branchName = f.branchName;
}
return featureData;
});
// 🛡️ FINAL VALIDATION: Ensure we're not writing an empty array
if (!Array.isArray(toSave) || toSave.length === 0) {
throw new Error("CRITICAL: Attempted to save empty feature list - aborting to prevent data loss");
}
await fs.writeFile(featuresPath, JSON.stringify(toSave, null, 2), "utf-8");
console.log(`[FeatureLoader] Updated feature ${featureId}: status=${status}${summary ? `, summary="${summary}"` : ""}`);
console.log(`[FeatureLoader] Successfully saved ${toSave.length} features to feature_list.json`);
await this.update(projectPath, featureId, updates);
console.log(
`[FeatureLoader] Updated feature ${featureId}: status=${status}${
summary ? `, summary="${summary}"` : ""
}`
);
}
/**
@@ -168,70 +375,38 @@ class FeatureLoader {
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");
return features.find(
(f) => f.status !== "verified" && f.status !== "waiting_approval"
);
}
/**
* Update worktree info for a feature
* 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 featuresPath = path.join(
projectPath,
".automaker",
"feature_list.json"
);
const features = await this.loadFeatures(projectPath);
if (!Array.isArray(features) || features.length === 0) {
console.error("[FeatureLoader] Cannot update worktree: feature list is empty");
return;
}
const feature = features.find((f) => f.id === featureId);
if (!feature) {
console.error(`[FeatureLoader] Feature ${featureId} not found`);
return;
}
// Update or clear worktree info
async updateFeatureWorktree(
featureId,
projectPath,
worktreePath,
branchName
) {
const updates = {};
if (worktreePath) {
feature.worktreePath = worktreePath;
feature.branchName = branchName;
updates.worktreePath = worktreePath;
updates.branchName = branchName;
} else {
delete feature.worktreePath;
delete feature.branchName;
updates.worktreePath = null;
updates.branchName = null;
}
// Save back to file (reuse the same mapping logic)
const toSave = features.map((f) => {
const featureData = {
id: f.id,
category: f.category,
description: f.description,
steps: f.steps,
status: f.status,
};
if (f.skipTests !== undefined) featureData.skipTests = f.skipTests;
if (f.images !== undefined) featureData.images = f.images;
if (f.imagePaths !== undefined) featureData.imagePaths = f.imagePaths;
if (f.startedAt !== undefined) featureData.startedAt = f.startedAt;
if (f.summary !== undefined) featureData.summary = f.summary;
if (f.model !== undefined) featureData.model = f.model;
if (f.thinkingLevel !== undefined) featureData.thinkingLevel = f.thinkingLevel;
if (f.error !== undefined) featureData.error = f.error;
if (f.worktreePath !== undefined) featureData.worktreePath = f.worktreePath;
if (f.branchName !== undefined) featureData.branchName = f.branchName;
return featureData;
});
await fs.writeFile(featuresPath, JSON.stringify(toSave, null, 2), "utf-8");
console.log(`[FeatureLoader] Updated feature ${featureId}: worktreePath=${worktreePath}, branchName=${branchName}`);
await this.update(projectPath, featureId, updates);
console.log(
`[FeatureLoader] Updated feature ${featureId}: worktreePath=${worktreePath}, branchName=${branchName}`
);
}
}

View File

@@ -11,10 +11,14 @@ class FeatureSuggestionsService {
/**
* 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) {
async generateSuggestions(projectPath, sendToRenderer, execution, suggestionType = "features") {
console.log(
`[FeatureSuggestions] Generating suggestions for: ${projectPath}`
`[FeatureSuggestions] Generating ${suggestionType} suggestions for: ${projectPath}`
);
try {
@@ -23,7 +27,7 @@ class FeatureSuggestionsService {
const options = {
model: "claude-sonnet-4-20250514",
systemPrompt: this.getSystemPrompt(),
systemPrompt: this.getSystemPrompt(suggestionType),
maxTurns: 50,
cwd: projectPath,
allowedTools: ["Read", "Glob", "Grep", "Bash"],
@@ -35,7 +39,7 @@ class FeatureSuggestionsService {
abortController: abortController,
};
const prompt = this.buildAnalysisPrompt();
const prompt = this.buildAnalysisPrompt(suggestionType);
sendToRenderer({
type: "suggestions_progress",
@@ -163,36 +167,102 @@ class FeatureSuggestionsService {
/**
* Get the system prompt for feature suggestion analysis
* @param {string} suggestionType - Type of suggestions: "features", "refactoring", "security", "performance"
*/
getSystemPrompt() {
return `You are an expert software architect and product manager. Your job is to analyze a codebase and suggest missing features that would improve the application.
getSystemPrompt(suggestionType = "features") {
const basePrompt = `You are an expert software architect. Your job is to analyze a codebase and provide actionable suggestions.
You should:
1. Thoroughly analyze the project structure, code, and any existing documentation
2. Identify what the application does and what features it currently has (look at the .automaker/app_spec.txt file as well if it exists)
3. Generate a comprehensive list of missing features that would be valuable to users
4. Prioritize features by impact and complexity
5. Provide clear, actionable descriptions and implementation steps
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 features and their implementation patterns
- Common patterns in similar applications
- User experience improvements
- Developer experience improvements
- Performance optimizations
- Security enhancements
- Existing code patterns and implementation styles`;
You have access to file reading and search tools. Use them to understand the codebase.`;
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() {
return `Analyze this project and generate a list of suggested features that are missing or would improve the application.
buildAnalysisPrompt(suggestionType = "features") {
const commonIntro = `Analyze this project and generate a list of actionable suggestions.
**Your Task:**
@@ -200,13 +270,89 @@ You have access to file reading and search tools. Use them to understand the cod
- 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 features and how they're implemented
- Look at existing code and how it's implemented
2. Identify what the application does:
- What is the main purpose?
- What features are already implemented?
- 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
@@ -214,45 +360,9 @@ You have access to file reading and search tools. Use them to understand the cod
- Think about performance, security, and reliability
- Consider testing and documentation improvements
4. **CRITICAL: Output your suggestions as a JSON array** at the end of your response, formatted like this:
\`\`\`json
[
{
"category": "User Experience",
"description": "Add dark mode support with system preference detection",
"steps": [
"Create a ThemeProvider context to manage theme state",
"Add a toggle component in the settings or header",
"Implement CSS variables for theme colors",
"Add localStorage persistence for user preference"
],
"priority": 1,
"reasoning": "Dark mode is a standard feature that improves accessibility and user comfort"
},
{
"category": "Performance",
"description": "Implement lazy loading for heavy components",
"steps": [
"Identify components that are heavy or rarely used",
"Use React.lazy() and Suspense for code splitting",
"Add loading states for lazy-loaded components"
],
"priority": 2,
"reasoning": "Improves initial load time and reduces bundle size"
}
]
\`\`\`
**Important Guidelines:**
- Generate at least 10-20 feature suggestions
- Order them by priority (1 = highest priority)
- Each feature should have clear, actionable steps
- Categories should be meaningful (e.g., "User Experience", "Performance", "Security", "Testing", "Documentation", "Developer Experience", "Accessibility", etc.)
- Be specific about what files might need to be created or modified
- Consider the existing tech stack and patterns when suggesting implementation steps
Begin by exploring the project structure.`;
Categories to use: "User Experience", "Performance", "Security", "Testing", "Documentation", "Developer Experience", "Accessibility", etc.
${commonOutput}`;
}
}
/**

View File

@@ -9,8 +9,8 @@ class McpServerFactory {
/**
* Create a custom MCP server with the UpdateFeatureStatus tool
* This tool allows Claude Code to safely update feature status without
* directly modifying the feature_list.json file, preventing race conditions
* and accidental state restoration.
* directly modifying feature files, preventing race conditions
* and accidental state corruption.
*/
createFeatureToolsServer(updateFeatureStatusCallback, projectPath) {
return createSdkMcpServer({
@@ -19,7 +19,7 @@ class McpServerFactory {
tools: [
tool(
"UpdateFeatureStatus",
"Update the status of a feature in the feature list. Use this tool instead of directly modifying feature_list.json 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.",
"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.",
{
featureId: z.string().describe("The ID of the feature to update"),
status: z.enum(["backlog", "in_progress", "verified"]).describe("The new status for the feature. Note: If skipTests=true, verified will be converted to waiting_approval automatically."),

View File

@@ -144,7 +144,7 @@ async function handleToolsList(params, id) {
tools: [
{
name: 'UpdateFeatureStatus',
description: 'Update the status of a feature in the feature list. Use this tool instead of directly modifying feature_list.json 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.',
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: {
@@ -345,3 +345,5 @@ process.on('SIGINT', () => {
console.error('[McpServerStdio] Starting MCP server for automaker-tools');
console.error(`[McpServerStdio] Project path: ${projectPath}`);
console.error(`[McpServerStdio] IPC channel: ${ipcChannel}`);

View File

@@ -251,7 +251,7 @@ class ClaudeProvider extends ModelProvider {
async detectInstallation() {
const claudeCliDetector = require('./claude-cli-detector');
return claudeCliDetector.getInstallationInfo();
return claudeCliDetector.getFullStatus();
}
getAvailableModels() {

View File

@@ -69,7 +69,7 @@ ${
}
${
feature.skipTests ? "4" : "6"
}. **CRITICAL: Use the UpdateFeatureStatus tool to mark this feature as verified** - DO NOT manually edit .automaker/feature_list.json
}. **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"
@@ -83,7 +83,7 @@ When you have completed the feature${
}, 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 the .automaker/feature_list.json file** - this can cause race conditions
- **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
@@ -113,7 +113,7 @@ ${
? "- 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_list.json directly**
- **CRITICAL: Use UpdateFeatureStatus tool instead of editing feature files directly**
- **CRITICAL: Always include a summary when marking feature as verified**
${
feature.skipTests
@@ -223,7 +223,7 @@ ${
}
${
feature.skipTests ? "4" : "8"
}. **CRITICAL: Use the UpdateFeatureStatus tool to mark this feature as verified** - DO NOT manually edit .automaker/feature_list.json
}. **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"
@@ -237,7 +237,7 @@ When you have completed the feature${
}, 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 the .automaker/feature_list.json file** - this can cause race conditions
- **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
@@ -275,7 +275,7 @@ ${
? "- 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_list.json directly**
- **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.`;
@@ -358,7 +358,7 @@ ${
}
${
feature.skipTests ? "4" : "6"
}. **CRITICAL: Use the UpdateFeatureStatus tool to mark this feature as verified** - DO NOT manually edit .automaker/feature_list.json
}. **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"
@@ -372,7 +372,7 @@ When you have completed the feature${
}, 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 the .automaker/feature_list.json file** - this can cause race conditions
- **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
@@ -402,7 +402,7 @@ ${
? "- 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_list.json directly**
- **CRITICAL: Use UpdateFeatureStatus tool instead of editing feature files directly**
- **CRITICAL: Always include a summary when marking feature as verified**
${
feature.skipTests
@@ -491,38 +491,16 @@ Analyze this project's codebase and update the .automaker/app_spec.txt file with
</project_specification>
\`\`\`
4. **IMPORTANT - Generate Feature List:**
After writing the app_spec.txt, you MUST update .automaker/feature_list.json with features from the implementation_roadmap section:
- Read the app_spec.txt you just created
- For EVERY feature in each phase of the implementation_roadmap, create an entry
- Write ALL features to .automaker/feature_list.json
4. Ensure .automaker/context/ directory exists
The feature_list.json format should be:
\`\`\`json
[
{
"id": "feature-<timestamp>-<index>",
"category": "<phase name, e.g., 'Phase 1: Foundation'>",
"description": "<feature description>",
"status": "backlog",
"steps": ["Step 1", "Step 2", "..."],
"skipTests": true
}
]
\`\`\`
Generate unique IDs using the current timestamp and index (e.g., "feature-1234567890-0", "feature-1234567890-1", etc.)
5. Ensure .automaker/context/ directory exists
6. Ensure .automaker/agents-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
- Include EVERY feature from the roadmap in feature_list.json - do not skip any
- Features are stored in .automaker/features/{id}/feature.json - each feature gets its own folder
Begin by exploring the project structure.`;
}
@@ -563,27 +541,12 @@ You are implementing features for manual user review. This means:
${modeHeader}
${memoryContent}
**🚨 CRITICAL FILE PROTECTION - READ THIS FIRST 🚨**
THE FOLLOWING FILE IS ABSOLUTELY FORBIDDEN FROM DIRECT MODIFICATION:
- .automaker/feature_list.json
**YOU MUST NEVER:**
- Use the Write tool on feature_list.json
- Use the Edit tool on feature_list.json
- Use any Bash command that writes to feature_list.json (echo, sed, awk, etc.)
- Attempt to read and rewrite feature_list.json
- UNDER ANY CIRCUMSTANCES touch this file directly
**CATASTROPHIC CONSEQUENCES:**
Directly modifying feature_list.json can:
- Erase all project features permanently
- Corrupt the project state beyond recovery
- Destroy hours/days of planning work
- This is a FIREABLE OFFENSE - you will be terminated if you do this
**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}
@@ -594,7 +557,7 @@ Your role is to:
- 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_list.json
- **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
@@ -609,7 +572,7 @@ If a feature has skipTests=true:
**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 .automaker/feature_list.json** - this can cause race conditions and restore old state
- **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
@@ -704,27 +667,12 @@ You are completing features for manual user review. This means:
${modeHeader}
${memoryContent}
**🚨 CRITICAL FILE PROTECTION - READ THIS FIRST 🚨**
THE FOLLOWING FILE IS ABSOLUTELY FORBIDDEN FROM DIRECT MODIFICATION:
- .automaker/feature_list.json
**YOU MUST NEVER:**
- Use the Write tool on feature_list.json
- Use the Edit tool on feature_list.json
- Use any Bash command that writes to feature_list.json (echo, sed, awk, etc.)
- Attempt to read and rewrite feature_list.json
- UNDER ANY CIRCUMSTANCES touch this file directly
**CATASTROPHIC CONSEQUENCES:**
Directly modifying feature_list.json can:
- Erase all project features permanently
- Corrupt the project state beyond recovery
- Destroy hours/days of planning work
- This is a FIREABLE OFFENSE - you will be terminated if you do this
**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}
@@ -737,7 +685,7 @@ Your role is to:
- 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_list.json
- **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)
@@ -752,7 +700,7 @@ If a feature has skipTests=true:
**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 .automaker/feature_list.json** - this can cause race conditions and restore old state
- **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
@@ -820,7 +768,6 @@ Your goal is to:
- Identify programming languages, frameworks, and libraries
- Detect existing features and capabilities
- Update the .automaker/app_spec.txt with accurate information
- Generate a feature list in .automaker/feature_list.json based on the implementation roadmap
- Ensure all required .automaker files and directories exist
Be efficient - don't read every file, focus on:
@@ -829,11 +776,9 @@ Be efficient - don't read every file, focus on:
- Directory structure
- README and documentation
**CRITICAL - Feature List Generation:**
After creating/updating the app_spec.txt, you MUST also update .automaker/feature_list.json:
1. Read the app_spec.txt you just wrote
2. Extract all features from the implementation_roadmap section
3. Write them to .automaker/feature_list.json in the correct format
**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.`;
}

View File

@@ -92,7 +92,7 @@ class SpecRegenerationService {
* @param {string} projectOverview - User's project description
* @param {Function} sendToRenderer - Function to send events to renderer
* @param {Object} execution - Execution context with abort controller
* @param {boolean} generateFeatures - Whether to generate feature_list.json entries
* @param {boolean} generateFeatures - Whether to generate feature entries in features folder
*/
async createInitialSpec(projectPath, projectOverview, sendToRenderer, execution, generateFeatures = true) {
console.log(`[SpecRegeneration] Creating initial spec for: ${projectPath}, generateFeatures: ${generateFeatures}`);
@@ -187,43 +187,6 @@ class SpecRegenerationService {
* @param {boolean} generateFeatures - Whether features should be generated
*/
getInitialCreationSystemPrompt(generateFeatures = true) {
const featureListInstructions = generateFeatures
? `
**FEATURE LIST GENERATION**
After creating the app_spec.txt, you MUST also update the .automaker/feature_list.json file with all features from the implementation_roadmap section.
For EACH feature in each phase of the implementation_roadmap:
1. Read the app_spec.txt you just created
2. Extract every single feature from each phase (phase_1, phase_2, phase_3, phase_4, etc.)
3. Write ALL features to .automaker/feature_list.json in order
The feature_list.json format should be:
\`\`\`json
[
{
"id": "feature-<timestamp>-<index>",
"category": "<phase name, e.g., 'Phase 1: Foundation'>",
"description": "<feature description>",
"status": "backlog",
"steps": ["Step 1", "Step 2", "..."],
"skipTests": true
}
]
\`\`\`
IMPORTANT: Include EVERY feature from the implementation_roadmap. Do not skip any.`
: `
**CRITICAL FILE PROTECTION**
THE FOLLOWING FILE IS ABSOLUTELY FORBIDDEN FROM DIRECT MODIFICATION:
- .automaker/feature_list.json
**YOU MUST NEVER:**
- Use the Write tool on .automaker/feature_list.json
- Use the Edit tool on .automaker/feature_list.json
- Use any Bash command that writes to .automaker/feature_list.json`;
return `You are an expert software architect and product manager. Your job is to analyze an existing codebase and generate a comprehensive application specification based on a user's project overview.
You should:
@@ -241,10 +204,13 @@ When analyzing, look at:
- Framework-specific patterns (Next.js, React, Django, etc.)
- Database configurations and schemas
- API structures and patterns
${featureListInstructions}
**Feature Storage:**
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
Do NOT manually create feature files. Use the UpdateFeatureStatus tool to manage features.
You CAN and SHOULD modify:
- .automaker/app_spec.txt (this is your primary target)${generateFeatures ? '\n- .automaker/feature_list.json (to populate features from implementation_roadmap)' : ''}
- .automaker/app_spec.txt (this is your primary target)
You have access to file reading, writing, and search tools. Use them to understand the codebase and write the new spec.`;
}
@@ -252,20 +218,9 @@ You have access to file reading, writing, and search tools. Use them to understa
/**
* Build the prompt for initial spec creation
* @param {string} projectOverview - User's project description
* @param {boolean} generateFeatures - Whether to generate feature_list.json entries
* @param {boolean} generateFeatures - Whether to generate feature entries in features folder
*/
buildInitialCreationPrompt(projectOverview, generateFeatures = true) {
const featureGenerationStep = generateFeatures
? `
5. **IMPORTANT - GENERATE FEATURE LIST**: After writing the app_spec.txt:
- Read back the app_spec.txt file you just created
- Look at the implementation_roadmap section
- For EVERY feature listed in each phase (phase_1, phase_2, phase_3, phase_4, etc.), create an entry
- Write ALL these features to \`.automaker/feature_list.json\` in the order they appear
- Each feature should have: id (feature-timestamp-index), category (phase name), description, status: "backlog", steps array, and skipTests: true
- Do NOT skip any features - include every single one from the roadmap`
: '';
return `I need you to create an initial application specification for my project. I haven't set up an app_spec.txt yet, so this will be the first one.
**My Project Overview:**
@@ -295,7 +250,6 @@ ${APP_SPEC_XML_TEMPLATE}
- **implementation_roadmap**: Break down the features into phases - be VERY detailed here, listing every feature that needs to be built
4. **IMPORTANT**: Write the complete specification to the file \`.automaker/app_spec.txt\`
${featureGenerationStep}
**Guidelines:**
- Be comprehensive! Include ALL features needed for a complete application
@@ -420,15 +374,9 @@ When analyzing, look at:
- Database configurations and schemas
- API structures and patterns
**CRITICAL FILE PROTECTION**
THE FOLLOWING FILE IS ABSOLUTELY FORBIDDEN FROM DIRECT MODIFICATION:
- .automaker/feature_list.json
**YOU MUST NEVER:**
- Use the Write tool on .automaker/feature_list.json
- Use the Edit tool on .automaker/feature_list.json
- Use any Bash command that writes to .automaker/feature_list.json
**Feature Storage:**
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
Do NOT manually create feature files. Use the UpdateFeatureStatus tool to manage features.
You CAN and SHOULD modify:
- .automaker/app_spec.txt (this is your primary target)

View File

@@ -176,15 +176,8 @@ class WorktreeManager {
try {
await fs.mkdir(automakerDst, { recursive: true });
// Copy feature_list.json
const featureListSrc = path.join(automakerSrc, "feature_list.json");
const featureListDst = path.join(automakerDst, "feature_list.json");
try {
const content = await fs.readFile(featureListSrc, "utf-8");
await fs.writeFile(featureListDst, content, "utf-8");
} catch {
// Feature list might not exist yet
}
// 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");

73
app/package-lock.json generated
View File

@@ -17,6 +17,7 @@
"@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",
@@ -24,6 +25,7 @@
"@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",
@@ -2955,6 +2957,61 @@
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
@@ -5737,6 +5794,22 @@
"node": ">=6"
}
},
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",

View File

@@ -1,6 +1,12 @@
{
"name": "automaker",
"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",
"author": {
"name": "Cody Seibert",
"email": "webdevcody@gmail.com"
},
"private": true,
"license": "Unlicense",
"main": "electron/main.js",
@@ -13,7 +19,9 @@
"start": "next start",
"lint": "eslint",
"test": "playwright test",
"test:headed": "playwright test --headed"
"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",
@@ -24,6 +32,7 @@
"@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",
@@ -31,6 +40,7 @@
"@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",
@@ -97,7 +107,7 @@
]
}
],
"icon": "public/logo.png"
"icon": "public/logo_larger.png"
},
"win": {
"target": [
@@ -108,7 +118,7 @@
]
}
],
"icon": "public/logo.png"
"icon": "public/logo_larger.png"
},
"linux": {
"target": [
@@ -126,7 +136,8 @@
}
],
"category": "Development",
"icon": "public/logo.png"
"icon": "public/logo_larger.png",
"maintainer": "webdevcody@gmail.com"
},
"nsis": {
"oneClick": false,

BIN
app/public/logo_larger.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

BIN
app/public/sounds/ding.mp3 Normal file

Binary file not shown.

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
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";
@@ -12,6 +12,7 @@ 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";
@@ -20,6 +21,45 @@ 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
@@ -139,6 +179,8 @@ export default function Home() {
return <ContextView />;
case "profiles":
return <ProfilesView />;
case "running-agents":
return <RunningAgentsView />;
default:
return <WelcomeView />;
}
@@ -162,7 +204,9 @@ export default function Home() {
return (
<main className="flex h-screen overflow-hidden" data-testid="app-container">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">{renderView()}</div>
<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() && (
@@ -170,6 +214,13 @@ export default function Home() {
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>
);
}

View File

@@ -2,7 +2,7 @@
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/store/app-store";
import { useAppStore, formatShortcut } from "@/store/app-store";
import {
FolderOpen,
Plus,
@@ -40,6 +40,8 @@ import {
Radio,
Monitor,
Search,
Bug,
Activity,
} from "lucide-react";
import {
DropdownMenu,
@@ -393,7 +395,9 @@ export function Sidebar() {
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
const name = path.split("/").pop() || "Untitled Project";
// Extract folder name from path (works on both Windows and Mac/Linux)
const name =
path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
try {
// Check if this is a brand new project (no .automaker directory)
@@ -571,7 +575,10 @@ export function Sidebar() {
// Handle selecting the currently highlighted project
const selectHighlightedProject = useCallback(() => {
if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) {
if (
filteredProjects.length > 0 &&
selectedProjectIndex < filteredProjects.length
) {
setCurrentProject(filteredProjects[selectedProjectIndex]);
setIsProjectPickerOpen(false);
}
@@ -595,7 +602,11 @@ export function Sidebar() {
} else if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedProjectIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (event.key.toLowerCase() === "p" && !event.metaKey && !event.ctrlKey) {
} else if (
event.key.toLowerCase() === "p" &&
!event.metaKey &&
!event.ctrlKey
) {
// Toggle off when P is pressed (not with modifiers) while dropdown is open
// Only if not typing in the search input
if (document.activeElement !== projectSearchInputRef.current) {
@@ -722,7 +733,7 @@ export function Sidebar() {
className="ml-1 px-1 py-0.5 bg-brand-500/10 border border-brand-500/30 rounded text-[10px] font-mono text-brand-400/70"
data-testid="sidebar-toggle-shortcut"
>
{shortcuts.toggleSidebar}
{formatShortcut(shortcuts.toggleSidebar, true)}
</span>
</div>
</button>
@@ -731,7 +742,7 @@ export function Sidebar() {
{/* Logo */}
<div
className={cn(
"h-20 pt-8 flex items-center justify-center border-b border-sidebar-border shrink-0 titlebar-drag-region",
"h-20 pt-8 flex items-center justify-between border-b border-sidebar-border shrink-0 titlebar-drag-region",
sidebarOpen ? "px-3 lg:px-6" : "px-3"
)}
>
@@ -756,6 +767,18 @@ export function Sidebar() {
Auto<span className="text-brand-500">maker</span>
</span>
</div>
{/* Bug Report Button */}
<button
onClick={() => {
const api = getElectronAPI();
api.openExternalLink("https://github.com/AutoMaker-Org/automaker/issues");
}}
className="titlebar-no-drag p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 transition-all"
title="Report Bug / Feature Request"
data-testid="bug-report-link"
>
<Bug className="w-4 h-4" />
</button>
</div>
{/* Project Actions - Moved above project selector */}
@@ -779,8 +802,8 @@ export function Sidebar() {
data-testid="open-project-button"
>
<FolderOpen className="w-4 h-4 shrink-0" />
<span className="hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70 ml-2">
{shortcuts.openProject}
<span className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70 ml-2">
{formatShortcut(shortcuts.openProject, true)}
</span>
</button>
<button
@@ -819,10 +842,10 @@ export function Sidebar() {
</div>
<div className="flex items-center gap-1">
<span
className="hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
data-testid="project-picker-shortcut"
>
{shortcuts.projectPicker}
{formatShortcut(shortcuts.projectPicker, true)}
</span>
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
@@ -912,7 +935,10 @@ export function Sidebar() {
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-48" data-testid="project-theme-menu">
<DropdownMenuSubContent
className="w-48"
data-testid="project-theme-menu"
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Select theme for this project
</DropdownMenuLabel>
@@ -921,7 +947,10 @@ export function Sidebar() {
value={currentProject.theme || ""}
onValueChange={(value) => {
if (currentProject) {
setProjectTheme(currentProject.id, value === "" ? null : value as any);
setProjectTheme(
currentProject.id,
value === "" ? null : (value as any)
);
}
}}
>
@@ -931,7 +960,9 @@ export function Sidebar() {
<DropdownMenuRadioItem
key={option.value}
value={option.value}
data-testid={`project-theme-${option.value || 'global'}`}
data-testid={`project-theme-${
option.value || "global"
}`}
>
<Icon className="w-4 h-4 mr-2" />
<span>{option.label}</span>
@@ -954,21 +985,30 @@ export function Sidebar() {
<DropdownMenuLabel className="text-xs text-muted-foreground">
Project History
</DropdownMenuLabel>
<DropdownMenuItem onClick={cyclePrevProject} data-testid="cycle-prev-project">
<DropdownMenuItem
onClick={cyclePrevProject}
data-testid="cycle-prev-project"
>
<Undo2 className="w-4 h-4 mr-2" />
<span className="flex-1">Previous</span>
<span className="text-[10px] font-mono text-muted-foreground ml-2">
{shortcuts.cyclePrevProject}
{formatShortcut(shortcuts.cyclePrevProject, true)}
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={cycleNextProject} data-testid="cycle-next-project">
<DropdownMenuItem
onClick={cycleNextProject}
data-testid="cycle-next-project"
>
<Redo2 className="w-4 h-4 mr-2" />
<span className="flex-1">Next</span>
<span className="text-[10px] font-mono text-muted-foreground ml-2">
{shortcuts.cycleNextProject}
{formatShortcut(shortcuts.cycleNextProject, true)}
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={clearProjectHistory} data-testid="clear-project-history">
<DropdownMenuItem
onClick={clearProjectHistory}
data-testid="clear-project-history"
>
<RotateCcw className="w-4 h-4 mr-2" />
<span>Clear history</span>
</DropdownMenuItem>
@@ -1049,13 +1089,13 @@ export function Sidebar() {
{item.shortcut && sidebarOpen && (
<span
className={cn(
"hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70",
"hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70",
isActive &&
"bg-brand-500/20 border-brand-500/50 text-brand-400"
)}
data-testid={`shortcut-${item.id}`}
>
{item.shortcut}
{formatShortcut(item.shortcut, true)}
</span>
)}
{/* Tooltip for collapsed state */}
@@ -1077,8 +1117,48 @@ export function Sidebar() {
</nav>
</div>
{/* Bottom Section - User / Settings */}
{/* Bottom Section - Running Agents / Bug Report / Settings */}
<div className="border-t border-sidebar-border bg-sidebar-accent/10 shrink-0">
{/* Running Agents Link */}
<div className="p-2 pb-0">
<button
onClick={() => setCurrentView("running-agents")}
className={cn(
"group flex items-center w-full px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all titlebar-no-drag",
isActiveRoute("running-agents")
? "bg-sidebar-accent/50 text-foreground border border-sidebar-border"
: "text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50",
sidebarOpen ? "justify-start" : "justify-center"
)}
title={!sidebarOpen ? "Running Agents" : undefined}
data-testid="running-agents-link"
>
{isActiveRoute("running-agents") && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
)}
<Activity
className={cn(
"w-4 h-4 shrink-0 transition-colors",
isActiveRoute("running-agents")
? "text-brand-500"
: "group-hover:text-brand-400"
)}
/>
<span
className={cn(
"ml-2.5 font-medium text-sm flex-1 text-left",
sidebarOpen ? "hidden lg:block" : "hidden"
)}
>
Running Agents
</span>
{!sidebarOpen && (
<span className="absolute left-full ml-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-border">
Running Agents
</span>
)}
</button>
</div>
{/* Settings Link */}
<div className="p-2">
<button
@@ -1115,13 +1195,13 @@ export function Sidebar() {
{sidebarOpen && (
<span
className={cn(
"hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70",
"hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70",
isActiveRoute("settings") &&
"bg-brand-500/20 border-brand-500/50 text-brand-400"
)}
data-testid="shortcut-settings"
>
{shortcuts.settings}
{formatShortcut(shortcuts.settings, true)}
</span>
)}
{!sidebarOpen && (
@@ -1271,8 +1351,8 @@ export function Sidebar() {
Generate feature list
</label>
<p className="text-xs text-muted-foreground">
Automatically populate feature_list.json with all features
from the implementation roadmap after the spec is generated.
Automatically create features in the features folder from the
implementation roadmap after the spec is generated.
</p>
</div>
</div>

View File

@@ -1,11 +1,23 @@
"use client";
import * as React from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Input } from "./input";
import { Check, ChevronDown } from "lucide-react";
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;
@@ -26,225 +38,54 @@ export function CategoryAutocomplete({
disabled = false,
"data-testid": testId,
}: CategoryAutocompleteProps) {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState(value);
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// Update internal state when value prop changes
useEffect(() => {
setInputValue(value);
}, [value]);
// Filter suggestions based on input
useEffect(() => {
const searchTerm = inputValue.toLowerCase().trim();
if (searchTerm === "") {
setFilteredSuggestions(suggestions);
} else {
const filtered = suggestions.filter((s) =>
s.toLowerCase().includes(searchTerm)
);
setFilteredSuggestions(filtered);
}
setHighlightedIndex(-1);
}, [inputValue, suggestions]);
// Update dropdown position when open and handle scroll/resize
useEffect(() => {
const updatePosition = () => {
if (isOpen && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX,
width: rect.width,
});
}
};
updatePosition();
if (isOpen) {
window.addEventListener("scroll", updatePosition, true);
window.addEventListener("resize", updatePosition);
return () => {
window.removeEventListener("scroll", updatePosition, true);
window.removeEventListener("resize", updatePosition);
};
}
}, [isOpen]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node) &&
listRef.current &&
!listRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Scroll highlighted item into view
useEffect(() => {
if (highlightedIndex >= 0 && listRef.current) {
const items = listRef.current.querySelectorAll("li");
const highlightedItem = items[highlightedIndex];
if (highlightedItem) {
highlightedItem.scrollIntoView({ block: "nearest" });
}
}
}, [highlightedIndex]);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInputValue(newValue);
onChange(newValue);
setIsOpen(true);
},
[onChange]
);
const handleSelect = useCallback(
(suggestion: string) => {
setInputValue(suggestion);
onChange(suggestion);
setIsOpen(false);
inputRef.current?.focus();
},
[onChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!isOpen) {
if (e.key === "ArrowDown" || e.key === "Enter") {
e.preventDefault();
setIsOpen(true);
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightedIndex((prev) =>
prev < filteredSuggestions.length - 1 ? prev + 1 : prev
);
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : -1));
break;
case "Enter":
e.preventDefault();
if (highlightedIndex >= 0 && filteredSuggestions[highlightedIndex]) {
handleSelect(filteredSuggestions[highlightedIndex]);
} else {
setIsOpen(false);
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
break;
case "Tab":
setIsOpen(false);
break;
}
},
[isOpen, highlightedIndex, filteredSuggestions, handleSelect]
);
const handleFocus = useCallback(() => {
setIsOpen(true);
}, []);
const [open, setOpen] = React.useState(false);
return (
<div ref={containerRef} className={cn("relative", className)}>
<div className="relative">
<Input
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
placeholder={placeholder}
<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}
className="pr-8"
/>
<button
type="button"
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors",
disabled && "pointer-events-none opacity-50"
)}
onClick={() => setIsOpen(!isOpen)}
tabIndex={-1}
>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</button>
</div>
{isOpen && filteredSuggestions.length > 0 && typeof document !== "undefined" &&
createPortal(
<ul
ref={listRef}
className="fixed z-[9999] max-h-60 overflow-auto rounded-md border bg-background p-1 shadow-md animate-in fade-in-0 zoom-in-95"
role="listbox"
data-testid="category-autocomplete-list"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
}}
>
{filteredSuggestions.map((suggestion, index) => (
<li
key={suggestion}
role="option"
aria-selected={highlightedIndex === index}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors",
highlightedIndex === index && "bg-accent text-accent-foreground",
inputValue === suggestion && "font-medium"
)}
onMouseDown={(e) => {
e.preventDefault();
handleSelect(suggestion);
}}
onMouseEnter={() => setHighlightedIndex(index)}
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
>
{inputValue === suggestion && (
<Check className="mr-2 h-4 w-4 text-primary" />
)}
<span className={cn(inputValue !== suggestion && "ml-6")}>
{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}
</span>
</li>
))}
</ul>,
document.body
)}
</div>
<Check
className={cn(
"ml-auto",
value === suggestion ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

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

@@ -82,7 +82,7 @@ function DialogContent({
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-2" : "top-4 right-4"
compact ? "top-2 right-3" : "top-3 right-5"
)}
>
<XIcon />

View File

@@ -56,7 +56,10 @@ function parseHotkeyConfig(hotkey: string | HotkeyConfig): HotkeyConfig {
/**
* Generate the display label for the hotkey
*/
function getHotkeyDisplayLabel(config: HotkeyConfig, isMac: boolean): React.ReactNode {
function getHotkeyDisplayLabel(
config: HotkeyConfig,
isMac: boolean
): React.ReactNode {
if (config.label) {
return config.label;
}
@@ -73,7 +76,10 @@ function getHotkeyDisplayLabel(config: HotkeyConfig, isMac: boolean): React.Reac
if (config.shift) {
parts.push(
<span key="shift" className="leading-none flex items-center justify-center">
<span
key="shift"
className="leading-none flex items-center justify-center"
>
</span>
);
@@ -134,11 +140,7 @@ function getHotkeyDisplayLabel(config: HotkeyConfig, isMac: boolean): React.Reac
</span>
);
return (
<span className="inline-flex items-center gap-1.5">
{parts}
</span>
);
return <span className="inline-flex items-center gap-1.5">{parts}</span>;
}
/**
@@ -205,7 +207,11 @@ export function HotkeyButton({
// 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)) {
if (
!scopeRef &&
!config.cmdCtrl &&
isInputElement(document.activeElement)
) {
return;
}
@@ -228,7 +234,8 @@ export function HotkeyButton({
// If scoped, check that the scope element is visible
if (scopeRef && scopeRef.current) {
const scopeEl = scopeRef.current;
const isVisible = scopeEl.offsetParent !== null ||
const isVisible =
scopeEl.offsetParent !== null ||
getComputedStyle(scopeEl).display !== "none";
if (!isVisible) return;
}
@@ -259,14 +266,15 @@ export function HotkeyButton({
}, [config, hotkeyActive, handleKeyDown]);
// Render the hotkey indicator
const hotkeyIndicator = config && showHotkeyIndicator ? (
<span
className="ml-3 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;
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

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

@@ -10,7 +10,7 @@ interface MarkdownProps {
/**
* Reusable Markdown component for rendering markdown content
* Styled for dark mode with proper typography
* Theme-aware styling that adapts to all predefined themes
*/
export function Markdown({ children, className }: MarkdownProps) {
return (
@@ -18,27 +18,27 @@ export function Markdown({ children, className }: MarkdownProps) {
className={cn(
"prose prose-sm prose-invert max-w-none",
// Headings
"[&_h1]:text-xl [&_h1]:text-zinc-200 [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2",
"[&_h2]:text-lg [&_h2]:text-zinc-200 [&_h2]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2",
"[&_h3]:text-base [&_h3]:text-zinc-200 [&_h3]:font-semibold [&_h3]:mt-3 [&_h3]:mb-2",
"[&_h4]:text-sm [&_h4]:text-zinc-200 [&_h4]:font-semibold [&_h4]:mt-2 [&_h4]:mb-1",
"[&_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-zinc-300 [&_p]:leading-relaxed [&_p]:my-2",
"[&_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-zinc-300 [&_li]:my-0.5",
"[&_li]:text-foreground-secondary [&_li]:my-0.5",
// Code
"[&_code]:text-cyan-400 [&_code]:bg-zinc-800/50 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm",
"[&_pre]:bg-zinc-900/80 [&_pre]:border [&_pre]:border-white/10 [&_pre]:rounded-lg [&_pre]:my-2 [&_pre]:p-3 [&_pre]:overflow-x-auto",
"[&_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-zinc-200 [&_strong]:font-semibold",
"[&_strong]:text-foreground [&_strong]:font-semibold",
// Links
"[&_a]:text-blue-400 [&_a]:no-underline hover:[&_a]:underline",
"[&_a]:text-brand-500 [&_a]:no-underline hover:[&_a]:underline",
// Blockquotes
"[&_blockquote]:border-l-2 [&_blockquote]:border-zinc-600 [&_blockquote]:pl-4 [&_blockquote]:text-zinc-400 [&_blockquote]:italic [&_blockquote]:my-2",
"[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground [&_blockquote]:italic [&_blockquote]:my-2",
// Horizontal rules
"[&_hr]:border-zinc-700 [&_hr]:my-4",
"[&_hr]:border-border [&_hr]:my-4",
className
)}
>

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

@@ -20,6 +20,8 @@ interface AgentOutputModalProps {
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;
}
@@ -31,6 +33,7 @@ export function AgentOutputModal({
onClose,
featureDescription,
featureId,
featureStatus,
onNumberKeyPress,
}: AgentOutputModalProps) {
const [output, setOutput] = useState<string>("");
@@ -70,16 +73,18 @@ export function AgentOutputModal({
projectPathRef.current = currentProject.path;
setProjectPath(currentProject.path);
// Ensure context directory exists
const contextDir = `${currentProject.path}/.automaker/agents-context`;
await api.mkdir(contextDir);
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(
currentProject.path,
featureId
);
// Try to read existing output file
const outputPath = `${contextDir}/${featureId}.md`;
const result = await api.readFile(outputPath);
if (result.success && result.content) {
setOutput(result.content);
if (result.success) {
setOutput(result.content || "");
} else {
setOutput("");
}
} else {
setOutput("");
}
@@ -102,9 +107,10 @@ export function AgentOutputModal({
if (!api) return;
try {
const contextDir = `${projectPathRef.current}/.automaker/agents-context`;
const outputPath = `${contextDir}/${featureId}.md`;
// 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);
@@ -250,7 +256,10 @@ export function AgentOutputModal({
<DialogHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
<Loader2 className="w-5 h-5 text-primary animate-spin" />
{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">

View File

@@ -155,7 +155,7 @@ export function AgentToolsView() {
// In mock mode, simulate terminal output
// In real Electron mode, this would use child_process
const mockOutputs: Record<string, string> = {
ls: "app_spec.txt\nfeature_list.json\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json",
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",

View File

@@ -592,13 +592,13 @@ export function AgentView() {
</div>
<Card
className={cn(
"max-w-[80%]",
"max-w-[80%] py-0",
message.role === "user"
? "bg-primary text-primary-foreground"
? "bg-transparent border border-primary text-foreground"
: "border-l-4 border-primary bg-card"
)}
>
<CardContent className="p-3">
<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}
@@ -610,9 +610,9 @@ export function AgentView() {
)}
<p
className={cn(
"text-xs mt-2",
"text-xs mt-1",
message.role === "user"
? "text-primary-foreground/70"
? "text-muted-foreground"
: "text-primary/70"
)}
>
@@ -628,7 +628,7 @@ export function AgentView() {
<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">
<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" />

View File

@@ -1,7 +1,12 @@
"use client";
import { useCallback, useState } from "react";
import { useAppStore, FileTreeNode, ProjectAnalysis } from "@/store/app-store";
import {
useAppStore,
FileTreeNode,
ProjectAnalysis,
Feature,
} from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import {
Card,
@@ -357,7 +362,10 @@ ${Object.entries(projectAnalysis.filesByExtension)
)
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
.slice(0, 5)
.map(([ext, count]: [string, number]) => ` <language ext=".${ext}" count="${count}" />`)
.map(
([ext, count]: [string, number]) =>
` <language ext=".${ext}" count="${count}" />`
)
.join("\n")}
</languages>
<frameworks>
@@ -409,7 +417,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
}
}, [currentProject, projectAnalysis]);
// Generate .automaker/feature_list.json from analysis
// Generate features from analysis and save to .automaker/features folder
const generateFeatureList = useCallback(async () => {
if (!currentProject || !projectAnalysis) return;
@@ -755,23 +763,22 @@ ${Object.entries(projectAnalysis.filesByExtension)
});
}
// Generate the feature list content
const featureListContent = JSON.stringify(detectedFeatures, null, 2);
// Write the feature list file
const featureListPath = `${currentProject.path}/feature_list.json`;
const writeResult = await api.writeFile(
featureListPath,
featureListContent
);
if (writeResult.success) {
setFeatureListGenerated(true);
} else {
setFeatureListError(
writeResult.error || "Failed to write feature list file"
);
// Create each feature using the features API
if (!api.features) {
throw new Error("Features API not available");
}
for (const detectedFeature of detectedFeatures) {
await api.features.create(currentProject.path, {
id: crypto.randomUUID(),
category: detectedFeature.category,
description: detectedFeature.description,
steps: detectedFeature.steps,
status: "backlog",
});
}
setFeatureListGenerated(true);
} catch (error) {
console.error("Failed to generate feature list:", error);
setFeatureListError(
@@ -840,7 +847,9 @@ ${Object.entries(projectAnalysis.filesByExtension)
</div>
{node.isDirectory && isExpanded && node.children && (
<div>
{node.children.map((child: FileTreeNode) => renderNode(child, depth + 1))}
{node.children.map((child: FileTreeNode) =>
renderNode(child, depth + 1)
)}
</div>
)}
</div>
@@ -964,7 +973,10 @@ ${Object.entries(projectAnalysis.filesByExtension)
<CardContent>
<div className="space-y-2">
{Object.entries(projectAnalysis.filesByExtension)
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
.sort(
(a: [string, number], b: [string, number]) =>
b[1] - a[1]
)
.slice(0, 15)
.map(([ext, count]: [string, number]) => (
<div key={ext} className="flex justify-between text-sm">
@@ -1041,7 +1053,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
Generate Feature List
</CardTitle>
<CardDescription>
Create .automaker/feature_list.json from analysis
Create features from analysis
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
@@ -1074,7 +1086,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
data-testid="feature-list-generated-success"
>
<CheckCircle className="w-4 h-4" />
<span>feature_list.json created successfully!</span>
<span>Features created successfully!</span>
</div>
)}
{featureListError && (
@@ -1107,7 +1119,9 @@ ${Object.entries(projectAnalysis.filesByExtension)
data-testid="analysis-file-tree"
>
<div className="p-2">
{projectAnalysis.fileTree.map((node: FileTreeNode) => renderNode(node))}
{projectAnalysis.fileTree.map((node: FileTreeNode) =>
renderNode(node)
)}
</div>
</CardContent>
</Card>

View File

@@ -85,6 +85,7 @@ import {
Minimize2,
Square,
Maximize2,
Shuffle,
} from "lucide-react";
import { toast } from "sonner";
import { Slider } from "@/components/ui/slider";
@@ -242,6 +243,8 @@ export function BoardView() {
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(
() => new Map()
);
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
useState<ImagePreviewMap>(() => new Map());
// Local state to temporarily show advanced options when profiles-only mode is enabled
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
@@ -390,7 +393,7 @@ export function BoardView() {
return rectIntersection(args);
}, []);
// Load features from file
// Load features using features API
const loadFeatures = useCallback(async () => {
if (!currentProject) return;
@@ -419,21 +422,25 @@ export function BoardView() {
try {
const api = getElectronAPI();
const result = await api.readFile(
`${currentProject.path}/.automaker/feature_list.json`
);
if (!api.features) {
console.error("[BoardView] Features API not available");
return;
}
if (result.success && result.content) {
const parsed = JSON.parse(result.content);
const featuresWithIds = parsed.map((f: any, index: number) => ({
...f,
id: f.id || `feature-${index}-${Date.now()}`,
status: f.status || "backlog",
startedAt: f.startedAt, // Preserve startedAt timestamp
// Ensure model and thinkingLevel are set for backward compatibility
model: f.model || "opus",
thinkingLevel: f.thinkingLevel || "none",
}));
const result = await api.features.getAll(currentProject.path);
if (result.success && result.features) {
const featuresWithIds = result.features.map(
(f: any, index: number) => ({
...f,
id: f.id || `feature-${index}-${Date.now()}`,
status: f.status || "backlog",
startedAt: f.startedAt, // Preserve startedAt timestamp
// Ensure model and thinkingLevel are set for backward compatibility
model: f.model || "opus",
thinkingLevel: f.thinkingLevel || "none",
})
);
setFeatures(featuresWithIds);
}
} catch (error) {
@@ -522,13 +529,22 @@ export function BoardView() {
const projectId = currentProject.id;
const unsubscribe = api.autoMode.onEvent((event) => {
// Use event's projectId if available, otherwise use current project
const eventProjectId = event.projectId || projectId;
// Use event's projectPath or projectId if available, otherwise use current project
// Board view only reacts to events for the currently selected project
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
if (event.type === "auto_mode_feature_complete") {
// Reload features when a feature is completed
console.log("[Board] Feature completed, reloading features...");
loadFeatures();
// Play ding sound when feature is done (unless muted)
const { muteDoneSound } = useAppStore.getState();
if (!muteDoneSound) {
const audio = new Audio("/sounds/ding.mp3");
audio
.play()
.catch((err) => console.warn("Could not play ding sound:", err));
}
} else if (event.type === "auto_mode_error") {
// Reload features when an error occurs (feature moved to waiting_approval)
console.log(
@@ -570,22 +586,36 @@ export function BoardView() {
const api = getElectronAPI();
if (!api?.autoMode?.status) return;
const status = await api.autoMode.status();
if (status.success && status.runningFeatures) {
console.log(
"[Board] Syncing running tasks from backend:",
status.runningFeatures
);
// Clear existing running tasks for this project and add the actual running ones
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
const status = await api.autoMode.status(currentProject.path);
if (status.success) {
const projectId = currentProject.id;
clearRunningTasks(projectId);
const { clearRunningTasks, addRunningTask, setAutoModeRunning } =
useAppStore.getState();
// Add each running feature to the store
status.runningFeatures.forEach((featureId: string) => {
addRunningTask(projectId, featureId);
});
// Sync running features if available
if (status.runningFeatures) {
console.log(
"[Board] Syncing running tasks from backend:",
status.runningFeatures
);
// Clear existing running tasks for this project and add the actual running ones
clearRunningTasks(projectId);
// Add each running feature to the store
status.runningFeatures.forEach((featureId: string) => {
addRunningTask(projectId, featureId);
});
}
// Sync auto mode running state (backend returns autoLoopRunning, mock returns isRunning)
const isAutoModeRunning =
status.autoLoopRunning ?? status.isRunning ?? false;
console.log(
"[Board] Syncing auto mode running state:",
isAutoModeRunning
);
setAutoModeRunning(projectId, isAutoModeRunning);
}
} catch (error) {
console.error("[Board] Failed to sync running tasks:", error);
@@ -627,41 +657,75 @@ export function BoardView() {
}
}, [features, isLoading]);
// Save features to file
const saveFeatures = useCallback(async () => {
if (!currentProject) return;
// Persist feature update to API (replaces saveFeatures)
const persistFeatureUpdate = useCallback(
async (featureId: string, updates: Partial<Feature>) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
const toSave = features.map((f) => ({
id: f.id,
category: f.category,
description: f.description,
steps: f.steps,
status: f.status,
startedAt: f.startedAt,
imagePaths: f.imagePaths,
skipTests: f.skipTests,
summary: f.summary,
model: f.model,
thinkingLevel: f.thinkingLevel,
error: f.error,
}));
await api.writeFile(
`${currentProject.path}/.automaker/feature_list.json`,
JSON.stringify(toSave, null, 2)
);
} catch (error) {
console.error("Failed to save features:", error);
}
}, [currentProject, features]);
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
return;
}
// Save when features change (after initial load is complete)
useEffect(() => {
if (!isLoading && !isSwitchingProjectRef.current) {
saveFeatures();
}
}, [features, saveFeatures, isLoading]);
const result = await api.features.update(
currentProject.path,
featureId,
updates
);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);
}
} catch (error) {
console.error("Failed to persist feature update:", error);
}
},
[currentProject, updateFeature]
);
// Persist feature creation to API
const persistFeatureCreate = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
return;
}
const result = await api.features.create(currentProject.path, feature);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);
}
} catch (error) {
console.error("Failed to persist feature creation:", error);
}
},
[currentProject, updateFeature]
);
// Persist feature deletion to API
const persistFeatureDelete = useCallback(
async (featureId: string) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
return;
}
await api.features.delete(currentProject.path, featureId);
} catch (error) {
console.error("Failed to persist feature deletion:", error);
}
},
[currentProject]
);
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
@@ -690,13 +754,15 @@ export function BoardView() {
// Determine if dragging is allowed based on status and skipTests
// - Backlog items can always be dragged
// - waiting_approval items can always be dragged (to allow manual verification via drag)
// - verified items can always be dragged (to allow moving back to waiting_approval)
// - skipTests (non-TDD) items can be dragged between in_progress and verified
// - Non-skipTests (TDD) items that are in progress or verified cannot be dragged
// - Non-skipTests (TDD) items that are in progress cannot be dragged (they are running)
if (
draggedFeature.status !== "backlog" &&
draggedFeature.status !== "waiting_approval"
draggedFeature.status !== "waiting_approval" &&
draggedFeature.status !== "verified"
) {
// Only allow dragging in_progress/verified if it's a skipTests feature and not currently running
// Only allow dragging in_progress if it's a skipTests feature and not currently running
if (!draggedFeature.skipTests || isRunningTask) {
console.log(
"[Board] Cannot drag feature - TDD feature or currently running"
@@ -744,14 +810,17 @@ export function BoardView() {
// From backlog
if (targetStatus === "in_progress") {
// Update with startedAt timestamp
updateFeature(featureId, {
const updates = {
status: targetStatus,
startedAt: new Date().toISOString(),
});
};
updateFeature(featureId, updates);
persistFeatureUpdate(featureId, updates);
console.log("[Board] Feature moved to in_progress, starting agent...");
await handleRunFeature(draggedFeature);
} else {
moveFeature(featureId, targetStatus);
persistFeatureUpdate(featureId, { status: targetStatus });
}
} else if (draggedFeature.status === "waiting_approval") {
// waiting_approval features can be dragged to verified for manual verification
@@ -759,6 +828,7 @@ export function BoardView() {
// features often have skipTests=true, and we want status-based handling first
if (targetStatus === "verified") {
moveFeature(featureId, "verified");
persistFeatureUpdate(featureId, { status: "verified" });
toast.success("Feature verified", {
description: `Manually verified: ${draggedFeature.description.slice(
0,
@@ -768,6 +838,7 @@ export function BoardView() {
} else if (targetStatus === "backlog") {
// Allow moving waiting_approval cards back to backlog
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
@@ -783,6 +854,7 @@ export function BoardView() {
) {
// Manual verify via drag
moveFeature(featureId, "verified");
persistFeatureUpdate(featureId, { status: "verified" });
toast.success("Feature verified", {
description: `Marked as verified: ${draggedFeature.description.slice(
0,
@@ -790,16 +862,14 @@ export function BoardView() {
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
} else if (
targetStatus === "in_progress" &&
targetStatus === "waiting_approval" &&
draggedFeature.status === "verified"
) {
// Move back to in_progress
updateFeature(featureId, {
status: "in_progress",
startedAt: new Date().toISOString(),
});
// Move verified feature back to waiting_approval
moveFeature(featureId, "waiting_approval");
persistFeatureUpdate(featureId, { status: "waiting_approval" });
toast.info("Feature moved back", {
description: `Moved back to In Progress: ${draggedFeature.description.slice(
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
@@ -807,6 +877,30 @@ export function BoardView() {
} else if (targetStatus === "backlog") {
// Allow moving skipTests cards back to backlog
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
}
} else if (draggedFeature.status === "verified") {
// Handle verified TDD (non-skipTests) features being moved back
if (targetStatus === "waiting_approval") {
// Move verified feature back to waiting_approval
moveFeature(featureId, "waiting_approval");
persistFeatureUpdate(featureId, { status: "waiting_approval" });
toast.info("Feature moved back", {
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
} else if (targetStatus === "backlog") {
// Allow moving verified cards back to backlog
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
@@ -828,17 +922,19 @@ export function BoardView() {
const normalizedThinking = modelSupportsThinking(selectedModel)
? newFeature.thinkingLevel
: "none";
addFeature({
const newFeatureData = {
category,
description: newFeature.description,
steps: newFeature.steps.filter((s) => s.trim()),
status: "backlog",
status: "backlog" as const,
images: newFeature.images,
imagePaths: newFeature.imagePaths,
skipTests: newFeature.skipTests,
model: selectedModel,
thinkingLevel: normalizedThinking,
});
};
const createdFeature = addFeature(newFeatureData);
persistFeatureCreate(createdFeature);
// Persist the category
saveCategory(category);
setNewFeature({
@@ -864,14 +960,19 @@ export function BoardView() {
? editingFeature.thinkingLevel
: "none";
updateFeature(editingFeature.id, {
const updates = {
category: editingFeature.category,
description: editingFeature.description,
steps: editingFeature.steps,
skipTests: editingFeature.skipTests,
model: selectedModel,
thinkingLevel: normalizedThinking,
});
imagePaths: editingFeature.imagePaths,
};
updateFeature(editingFeature.id, updates);
persistFeatureUpdate(editingFeature.id, updates);
// Clear the preview map after saving
setEditFeaturePreviewMap(new Map());
// Persist the category if it's new
if (editingFeature.category) {
saveCategory(editingFeature.category);
@@ -904,13 +1005,14 @@ export function BoardView() {
}
}
// Delete agent context file if it exists
// Note: Agent context file will be deleted automatically when feature folder is deleted
// via persistFeatureDelete, so no manual deletion needed
if (currentProject) {
try {
const api = getElectronAPI();
const contextPath = `${currentProject.path}/.automaker/agents-context/${featureId}.md`;
await api.deleteFile(contextPath);
console.log(`[Board] Deleted agent context for feature ${featureId}`);
// Feature folder deletion handles agent-output.md automatically
console.log(
`[Board] Feature ${featureId} will be deleted (including agent context)`
);
} catch (error) {
// Context file might not exist, which is fine
console.log(
@@ -944,6 +1046,7 @@ export function BoardView() {
// Remove the feature immediately without confirmation
removeFeature(featureId);
persistFeatureDelete(featureId);
};
const handleRunFeature = async (feature: Feature) => {
@@ -1056,6 +1159,7 @@ export function BoardView() {
description: feature.description,
});
moveFeature(feature.id, "verified");
persistFeatureUpdate(feature.id, { status: "verified" });
toast.success("Feature verified", {
description: `Marked as verified: ${feature.description.slice(0, 50)}${
feature.description.length > 50 ? "..." : ""
@@ -1069,10 +1173,12 @@ export function BoardView() {
id: feature.id,
description: feature.description,
});
updateFeature(feature.id, {
status: "in_progress",
const updates = {
status: "in_progress" as const,
startedAt: new Date().toISOString(),
});
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.info("Feature moved back", {
description: `Moved back to In Progress: ${feature.description.slice(
0,
@@ -1119,10 +1225,12 @@ export function BoardView() {
}
// Move feature back to in_progress before sending follow-up
updateFeature(featureId, {
status: "in_progress",
const updates = {
status: "in_progress" as const,
startedAt: new Date().toISOString(),
});
};
updateFeature(featureId, updates);
persistFeatureUpdate(featureId, updates);
// Reset follow-up state immediately (close dialog, clear form)
setShowFollowUpDialog(false);
@@ -1181,6 +1289,7 @@ export function BoardView() {
console.log("[Board] Feature committed successfully");
// Move to verified status
moveFeature(feature.id, "verified");
persistFeatureUpdate(feature.id, { status: "verified" });
toast.success("Feature committed", {
description: `Committed and verified: ${feature.description.slice(
0,
@@ -1210,7 +1319,9 @@ export function BoardView() {
id: feature.id,
description: feature.description,
});
updateFeature(feature.id, { status: "waiting_approval" });
const updates = { status: "waiting_approval" as const };
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.info("Feature ready for review", {
description: `Ready for approval: ${feature.description.slice(0, 50)}${
feature.description.length > 50 ? "..." : ""
@@ -1426,6 +1537,7 @@ export function BoardView() {
if (targetStatus !== feature.status) {
moveFeature(feature.id, targetStatus);
persistFeatureUpdate(feature.id, { status: targetStatus });
}
toast.success("Agent stopped", {
@@ -1473,10 +1585,12 @@ export function BoardView() {
for (const feature of featuresToStart) {
// Update the feature status with startedAt timestamp
updateFeature(feature.id, {
status: "in_progress",
const updates = {
status: "in_progress" as const,
startedAt: new Date().toISOString(),
});
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
// Start the agent for this feature
await handleRunFeature(feature);
}
@@ -1805,7 +1919,7 @@ export function BoardView() {
data-testid="start-next-button"
>
<FastForward className="w-3 h-3 mr-1" />
Start Next
Pull Top
</HotkeyButton>
)}
</div>
@@ -1885,7 +1999,24 @@ export function BoardView() {
}
}}
>
<DialogContent compact={!isMaximized} data-testid="add-feature-dialog">
<DialogContent
compact={!isMaximized}
data-testid="add-feature-dialog"
onPointerDownOutside={(e) => {
// Prevent dialog from closing when clicking on category autocomplete dropdown
const target = e.target as HTMLElement;
if (target.closest('[data-testid="category-autocomplete-list"]')) {
e.preventDefault();
}
}}
onInteractOutside={(e) => {
// Prevent dialog from closing when clicking on category autocomplete dropdown
const target = e.target as HTMLElement;
if (target.closest('[data-testid="category-autocomplete-list"]')) {
e.preventDefault();
}
}}
>
<DialogHeader>
<DialogTitle>Add New Feature</DialogTitle>
<DialogDescription>
@@ -2276,10 +2407,28 @@ export function BoardView() {
if (!open) {
setEditingFeature(null);
setShowEditAdvancedOptions(false);
setEditFeaturePreviewMap(new Map());
}
}}
>
<DialogContent compact={!isMaximized} data-testid="edit-feature-dialog">
<DialogContent
compact={!isMaximized}
data-testid="edit-feature-dialog"
onPointerDownOutside={(e) => {
// Prevent dialog from closing when clicking on category autocomplete dropdown
const target = e.target as HTMLElement;
if (target.closest('[data-testid="category-autocomplete-list"]')) {
e.preventDefault();
}
}}
onInteractOutside={(e) => {
// Prevent dialog from closing when clicking on category autocomplete dropdown
const target = e.target as HTMLElement;
if (target.closest('[data-testid="category-autocomplete-list"]')) {
e.preventDefault();
}
}}
>
<DialogHeader>
<DialogTitle>Edit Feature</DialogTitle>
<DialogDescription>Modify the feature details.</DialogDescription>
@@ -2308,16 +2457,24 @@ export function BoardView() {
<TabsContent value="prompt" className="space-y-4 overflow-y-auto">
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<Textarea
id="edit-description"
placeholder="Describe the feature..."
<DescriptionImageDropZone
value={editingFeature.description}
onChange={(e) =>
onChange={(value) =>
setEditingFeature({
...editingFeature,
description: e.target.value,
description: value,
})
}
images={editingFeature.imagePaths ?? []}
onImagesChange={(images) =>
setEditingFeature({
...editingFeature,
imagePaths: images,
})
}
placeholder="Describe the feature..."
previewMap={editFeaturePreviewMap}
onPreviewMapChange={setEditFeaturePreviewMap}
data-testid="edit-feature-description"
/>
</div>
@@ -2669,6 +2826,7 @@ export function BoardView() {
onClose={() => setShowOutputModal(false)}
featureDescription={outputFeature?.description || ""}
featureId={outputFeature?.id || ""}
featureStatus={outputFeature?.status}
onNumberKeyPress={handleOutputModalNumberKeyPress}
/>
@@ -2720,12 +2878,12 @@ export function BoardView() {
}
}
// Delete agent context file if it exists
// Note: Agent context file will be deleted automatically when feature folder is deleted
// via persistFeatureDelete, so no manual deletion needed
try {
const contextPath = `${currentProject.path}/.automaker/agents-context/${feature.id}.md`;
await api.deleteFile(contextPath);
// Feature folder deletion handles agent-output.md automatically
console.log(
`[Board] Deleted agent context for feature ${feature.id}`
`[Board] Feature ${feature.id} will be deleted (including agent context)`
);
} catch (error) {
// Context file might not exist, which is fine
@@ -2737,6 +2895,7 @@ export function BoardView() {
// Remove the feature
removeFeature(feature.id);
persistFeatureDelete(feature.id);
}
setShowDeleteAllVerifiedDialog(false);

View File

@@ -20,8 +20,11 @@ import {
StopCircle,
ChevronDown,
ChevronRight,
RefreshCw,
Shield,
Zap,
} from "lucide-react";
import { getElectronAPI, FeatureSuggestion, SuggestionsEvent } from "@/lib/electron";
import { getElectronAPI, FeatureSuggestion, SuggestionsEvent, SuggestionType } from "@/lib/electron";
import { useAppStore, Feature } from "@/store/app-store";
import { toast } from "sonner";
@@ -36,6 +39,39 @@ interface FeatureSuggestionsDialogProps {
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,
@@ -49,6 +85,7 @@ export function FeatureSuggestionsDialog({
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);
@@ -87,7 +124,8 @@ export function FeatureSuggestionsDialog({
setSuggestions(event.suggestions);
// Select all by default
setSelectedIds(new Set(event.suggestions.map((s) => s.id)));
toast.success(`Generated ${event.suggestions.length} feature suggestions!`);
const typeLabel = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType].label.toLowerCase() : "suggestions";
toast.success(`Generated ${event.suggestions.length} ${typeLabel}!`);
} else {
toast.info("No suggestions generated. Try again.");
}
@@ -100,10 +138,10 @@ export function FeatureSuggestionsDialog({
return () => {
unsubscribe();
};
}, [open, setSuggestions, setIsGenerating]);
}, [open, setSuggestions, setIsGenerating, currentSuggestionType]);
// Start generating suggestions
const handleGenerate = useCallback(async () => {
// 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");
@@ -114,9 +152,10 @@ export function FeatureSuggestionsDialog({
setProgress([]);
setSuggestions([]);
setSelectedIds(new Set());
setCurrentSuggestionType(suggestionType);
try {
const result = await api.suggestions.generate(projectPath);
const result = await api.suggestions.generate(projectPath, suggestionType);
if (!result.success) {
toast.error(result.error || "Failed to start generation");
setIsGenerating(false);
@@ -202,12 +241,15 @@ export function FeatureSuggestionsDialog({
skipTests: true, // As specified, testing mode true
}));
// Merge with existing features
const updatedFeatures = [...features, ...newFeatures];
// Create each new feature using the features API
if (api.features) {
for (const feature of newFeatures) {
await api.features.create(projectPath, feature);
}
}
// Save to file
const featureListPath = `${projectPath}/.automaker/feature_list.json`;
await api.writeFile(featureListPath, JSON.stringify(updatedFeatures, null, 2));
// Merge with existing features for store update
const updatedFeatures = [...features, ...newFeatures];
// Update store
setFeatures(updatedFeatures);
@@ -218,6 +260,7 @@ export function FeatureSuggestionsDialog({
setSuggestions([]);
setSelectedIds(new Set());
setProgress([]);
setCurrentSuggestionType(null);
onClose();
} catch (error) {
@@ -237,16 +280,17 @@ export function FeatureSuggestionsDialog({
autoScrollRef.current = isAtBottom;
};
// Reset state when dialog closes
useEffect(() => {
if (!open) {
// Don't reset immediately - allow re-open to see results
// Only reset if explicitly closed without importing
}
}, [open]);
// 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}>
@@ -256,31 +300,56 @@ export function FeatureSuggestionsDialog({
>
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<Lightbulb className="w-5 h-5 text-yellow-500" />
Feature Suggestions
{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>
Analyze your project to discover missing features and improvements.
The AI will scan your codebase and suggest features ordered by priority.
{currentConfig
? currentConfig.description
: "Analyze your project to discover improvements. Choose a suggestion type below."}
</DialogDescription>
</DialogHeader>
{!hasStarted ? (
// Initial state - show explanation and generate button
<div className="flex-1 flex flex-col items-center justify-center py-8 text-center">
<Lightbulb className="w-16 h-16 text-yellow-500/50 mb-4" />
<h3 className="text-lg font-semibold mb-2">
Discover Missing Features
</h3>
<p className="text-muted-foreground max-w-md mb-6">
Our AI will analyze your project structure, code patterns, and
existing features to generate a prioritized list of suggestions
for new features you could add.
// 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>
<Button onClick={handleGenerate} size="lg">
<Lightbulb className="w-4 h-4 mr-2" />
Generate Suggestions
</Button>
<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
@@ -409,20 +478,34 @@ export function FeatureSuggestionsDialog({
<p className="text-muted-foreground mb-4">
No suggestions were generated. Try running the analysis again.
</p>
<Button onClick={handleGenerate}>
<Lightbulb className="w-4 h-4 mr-2" />
Try Again
</Button>
<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">
<Button variant="outline" onClick={handleGenerate}>
<Lightbulb className="w-4 h-4 mr-2" />
Regenerate
</Button>
<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

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { useAppStore } from "@/store/app-store";
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";
@@ -17,6 +17,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron";
import { Markdown } from "@/components/ui/markdown";
interface InterviewMessage {
id: string;
@@ -99,12 +100,23 @@ export function InterviewView() {
// Auto-scroll to bottom when messages change
useEffect(() => {
let timeoutId: NodeJS.Timeout | undefined;
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTo({
top: messagesContainerRef.current.scrollHeight,
behavior: "smooth",
});
// 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
@@ -300,26 +312,24 @@ export function InterviewView() {
generatedSpec
);
// Create initial .automaker/feature_list.json
await api.writeFile(
`${fullProjectPath}/.automaker/feature_list.json`,
JSON.stringify(
[
{
category: "Core",
description: "Initial project setup",
steps: [
"Step 1: Review app_spec.txt",
"Step 2: Set up development environment",
"Step 3: Start implementing features",
],
passes: false,
},
],
null,
2
)
);
// 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()}`,
@@ -353,7 +363,7 @@ export function InterviewView() {
return (
<div
className="flex-1 flex flex-col content-bg"
className="flex-1 flex flex-col content-bg min-h-0"
data-testid="interview-view"
>
{/* Header */}
@@ -432,20 +442,25 @@ export function InterviewView() {
className={cn(
"max-w-[80%]",
message.role === "user"
? "bg-primary text-primary-foreground"
? "bg-transparent border border-primary text-foreground"
: "border-l-4 border-primary bg-card"
)}
>
<CardContent className="p-3">
<p className={cn(
"text-sm whitespace-pre-wrap",
message.role === "assistant" && "text-primary"
)}>{message.content}</p>
<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-2",
"text-xs mt-1",
message.role === "user"
? "text-primary-foreground/70"
? "text-muted-foreground"
: "text-primary/70"
)}
>

View File

@@ -12,6 +12,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import {
Dialog,
DialogContent,
@@ -52,6 +53,8 @@ import {
GitBranch,
Undo2,
GitMerge,
ChevronDown,
ChevronUp,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { getElectronAPI } from "@/lib/electron";
@@ -116,6 +119,7 @@ export const KanbanCard = memo(function KanbanCard({
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
@@ -149,12 +153,26 @@ export const KanbanCard = memo(function KanbanCard({
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) return;
const contextPath = `${currentProject.path}/.automaker/agents-context/${feature.id}.md`;
// 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
@@ -216,32 +234,26 @@ export const KanbanCard = memo(function KanbanCard({
ref={setNodeRef}
style={style}
className={cn(
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative kanban-card-content",
"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"
"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 : {})}
>
{/* Shortcut key badge for in-progress cards */}
{shortcutKey && (
<div
className="absolute top-2 left-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70 z-10"
data-testid={`shortcut-key-${feature.id}`}
>
{shortcutKey}
</div>
)}
{/* 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",
shortcutKey ? "top-2 left-10" : "top-2 left-2",
"top-2 left-2",
"bg-orange-500/20 border border-orange-500/50 text-orange-400"
)}
data-testid={`skip-tests-badge-${feature.id}`}
@@ -256,7 +268,7 @@ export const KanbanCard = memo(function KanbanCard({
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
shortcutKey ? "top-2 left-10" : "top-2 left-2",
"top-2 left-2",
"bg-red-500/20 border border-red-500/50 text-red-400"
)}
data-testid={`error-badge-${feature.id}`}
@@ -278,9 +290,7 @@ export const KanbanCard = memo(function KanbanCard({
// Position below error badge if present, otherwise use normal position
feature.error || feature.skipTests
? "top-8 left-2"
: shortcutKey
? "top-2 left-10"
: "top-2 left-2"
: "top-2 left-2"
)}
data-testid={`branch-badge-${feature.id}`}
>
@@ -298,7 +308,7 @@ export const KanbanCard = memo(function KanbanCard({
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 || shortcutKey) && "pt-10",
(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"
)}
@@ -323,6 +333,7 @@ export const KanbanCard = memo(function KanbanCard({
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" />
@@ -369,17 +380,45 @@ export const KanbanCard = memo(function KanbanCard({
<div className="flex items-start gap-2">
{isDraggable && (
<div
{...listeners}
className="mt-0.5 touch-none cursor-grab"
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="text-sm leading-tight break-words hyphens-auto line-clamp-3 overflow-hidden">
<CardTitle
className={cn(
"text-sm leading-tight break-words hyphens-auto overflow-hidden",
!isDescriptionExpanded && "line-clamp-3"
)}
>
{feature.description}
</CardTitle>
{/* Show More/Less toggle - only show when description is likely truncated */}
{feature.description.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>
@@ -504,6 +543,7 @@ export const KanbanCard = memo(function KanbanCard({
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}`}
@@ -557,10 +597,19 @@ export const KanbanCard = memo(function KanbanCard({
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 && (
@@ -572,6 +621,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onForceStop();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`force-stop-${feature.id}`}
>
<StopCircle className="w-3 h-3 mr-1" />
@@ -592,6 +642,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onManualVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`manual-verify-${feature.id}`}
>
<CheckCircle2 className="w-3 h-3 mr-1" />
@@ -606,6 +657,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onResume();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`resume-feature-${feature.id}`}
>
<RotateCcw className="w-3 h-3 mr-1" />
@@ -620,6 +672,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`verify-feature-${feature.id}`}
>
<PlayCircle className="w-3 h-3 mr-1" />
@@ -635,6 +688,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-inprogress-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
@@ -655,6 +709,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-verified-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
@@ -678,6 +733,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
setIsRevertDialogOpen(true);
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`revert-${feature.id}`}
>
<Undo2 className="w-3.5 h-3.5" />
@@ -699,6 +755,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onFollowUp();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`follow-up-${feature.id}`}
>
<MessageSquare className="w-3 h-3 mr-1 shrink-0" />
@@ -715,6 +772,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onMerge();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`merge-${feature.id}`}
title="Merge changes into main branch"
>
@@ -732,6 +790,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onCommit();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`commit-${feature.id}`}
>
<GitCommit className="w-3 h-3 mr-1" />
@@ -753,7 +812,7 @@ export const KanbanCard = memo(function KanbanCard({
be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogFooter className="mt-6">
<Button
variant="ghost"
onClick={handleCancelDelete}
@@ -761,13 +820,15 @@ export const KanbanCard = memo(function KanbanCard({
>
Cancel
</Button>
<Button
<HotkeyButton
variant="destructive"
onClick={handleConfirmDelete}
data-testid="confirm-delete-button"
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={isDeleteDialogOpen}
>
Delete
</Button>
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>

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>
);
}

File diff suppressed because it is too large Load Diff

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>
);
}

View File

@@ -0,0 +1,61 @@
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Palette } from "lucide-react";
import { themeOptions } from "@/config/theme-options";
import type { Theme, Project } from "../shared/types";
interface AppearanceSectionProps {
effectiveTheme: Theme;
currentProject: Project | null;
onThemeChange: (theme: Theme) => void;
}
export function AppearanceSection({
effectiveTheme,
currentProject,
onThemeChange,
}: AppearanceSectionProps) {
return (
<div
id="appearance"
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">
<Palette className="w-5 h-5 text-brand-500" />
<h2 className="text-lg font-semibold text-foreground">Appearance</h2>
</div>
<p className="text-sm text-muted-foreground">
Customize the look and feel of your application.
</p>
</div>
<div className="p-6 space-y-4">
<div className="space-y-3">
<Label className="text-foreground">
Theme{" "}
{currentProject ? `(for ${currentProject.name})` : "(Global)"}
</Label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{themeOptions.map(({ value, label, Icon, testId }) => {
const isActive = effectiveTheme === value;
return (
<Button
key={value}
variant={isActive ? "secondary" : "outline"}
onClick={() => onThemeChange(value)}
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
isActive ? "border-brand-500 ring-1 ring-brand-500/50" : ""
}`}
data-testid={testId}
>
<Icon className="w-4 h-4" />
<span className="font-medium text-sm">{label}</span>
</Button>
);
})}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { Button } from "@/components/ui/button";
import {
Terminal,
CheckCircle2,
AlertCircle,
RefreshCw,
} from "lucide-react";
import type { CliStatus } from "../shared/types";
interface CliStatusProps {
status: CliStatus | null;
isChecking: boolean;
onRefresh: () => void;
}
export function ClaudeCliStatus({
status,
isChecking,
onRefresh,
}: CliStatusProps) {
if (!status) return null;
return (
<div
id="claude"
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 justify-between mb-2">
<div className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-brand-500" />
<h2 className="text-lg font-semibold text-foreground">
Claude Code CLI
</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={isChecking}
data-testid="refresh-claude-cli"
title="Refresh Claude CLI detection"
>
<RefreshCw
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
/>
</Button>
</div>
<p className="text-sm text-muted-foreground">
Claude Code CLI provides better performance for long-running tasks,
especially with ultrathink.
</p>
</div>
<div className="p-6 space-y-4">
{status.success && status.status === "installed" ? (
<div className="space-y-3">
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-green-400">
Claude Code CLI Installed
</p>
<div className="text-xs text-green-400/80 mt-1 space-y-1">
{status.method && (
<p>
Method: <span className="font-mono">{status.method}</span>
</p>
)}
{status.version && (
<p>
Version:{" "}
<span className="font-mono">{status.version}</span>
</p>
)}
{status.path && (
<p className="truncate" title={status.path}>
Path:{" "}
<span className="font-mono text-[10px]">
{status.path}
</span>
</p>
)}
</div>
</div>
</div>
{status.recommendation && (
<p className="text-xs text-muted-foreground">
{status.recommendation}
</p>
)}
</div>
) : (
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 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="flex-1">
<p className="text-sm font-medium text-yellow-400">
Claude Code CLI Not Detected
</p>
<p className="text-xs text-yellow-400/80 mt-1">
{status.recommendation ||
"Consider installing Claude Code CLI for optimal performance with ultrathink."}
</p>
</div>
</div>
{status.installCommands && (
<div className="space-y-2">
<p className="text-xs font-medium text-foreground-secondary">
Installation Commands:
</p>
<div className="space-y-1">
{status.installCommands.npm && (
<div className="p-2 rounded bg-background border border-border-glass">
<p className="text-xs text-muted-foreground mb-1">npm:</p>
<code className="text-xs text-foreground-secondary font-mono break-all">
{status.installCommands.npm}
</code>
</div>
)}
{status.installCommands.macos && (
<div className="p-2 rounded bg-background border border-border-glass">
<p className="text-xs text-muted-foreground mb-1">
macOS/Linux:
</p>
<code className="text-xs text-foreground-secondary font-mono break-all">
{status.installCommands.macos}
</code>
</div>
)}
{status.installCommands.windows && (
<div className="p-2 rounded bg-background border border-border-glass">
<p className="text-xs text-muted-foreground mb-1">
Windows (PowerShell):
</p>
<code className="text-xs text-foreground-secondary font-mono break-all">
{status.installCommands.windows}
</code>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
import { Button } from "@/components/ui/button";
import {
Terminal,
CheckCircle2,
AlertCircle,
RefreshCw,
} from "lucide-react";
import type { CliStatus } from "../shared/types";
interface CliStatusProps {
status: CliStatus | null;
isChecking: boolean;
onRefresh: () => void;
}
export function CodexCliStatus({
status,
isChecking,
onRefresh,
}: CliStatusProps) {
if (!status) return null;
return (
<div
id="codex"
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 justify-between mb-2">
<div className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-green-500" />
<h2 className="text-lg font-semibold text-foreground">
OpenAI Codex CLI
</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={isChecking}
data-testid="refresh-codex-cli"
title="Refresh Codex CLI detection"
>
<RefreshCw
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
/>
</Button>
</div>
<p className="text-sm text-muted-foreground">
Codex CLI enables GPT-5.1 Codex models for autonomous coding tasks.
</p>
</div>
<div className="p-6 space-y-4">
{status.success && status.status === "installed" ? (
<div className="space-y-3">
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-green-400">
Codex CLI Installed
</p>
<div className="text-xs text-green-400/80 mt-1 space-y-1">
{status.method && (
<p>
Method: <span className="font-mono">{status.method}</span>
</p>
)}
{status.version && (
<p>
Version:{" "}
<span className="font-mono">{status.version}</span>
</p>
)}
{status.path && (
<p className="truncate" title={status.path}>
Path:{" "}
<span className="font-mono text-[10px]">
{status.path}
</span>
</p>
)}
</div>
</div>
</div>
{status.recommendation && (
<p className="text-xs text-muted-foreground">
{status.recommendation}
</p>
)}
</div>
) : status.status === "api_key_only" ? (
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
<AlertCircle className="w-5 h-5 text-blue-500 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-blue-400">
API Key Detected - CLI Not Installed
</p>
<p className="text-xs text-blue-400/80 mt-1">
{status.recommendation ||
"OPENAI_API_KEY found but Codex CLI not installed. Install the CLI for full agentic capabilities."}
</p>
</div>
</div>
{status.installCommands && (
<div className="space-y-2">
<p className="text-xs font-medium text-foreground-secondary">
Installation Commands:
</p>
<div className="space-y-1">
{status.installCommands.npm && (
<div className="p-2 rounded bg-background border border-border-glass">
<p className="text-xs text-muted-foreground mb-1">npm:</p>
<code className="text-xs text-foreground-secondary font-mono break-all">
{status.installCommands.npm}
</code>
</div>
)}
</div>
</div>
)}
</div>
) : (
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 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="flex-1">
<p className="text-sm font-medium text-yellow-400">
Codex CLI Not Detected
</p>
<p className="text-xs text-yellow-400/80 mt-1">
{status.recommendation ||
"Install OpenAI Codex CLI to use GPT-5.1 Codex models for autonomous coding."}
</p>
</div>
</div>
{status.installCommands && (
<div className="space-y-2">
<p className="text-xs font-medium text-foreground-secondary">
Installation Commands:
</p>
<div className="space-y-1">
{status.installCommands.npm && (
<div className="p-2 rounded bg-background border border-border-glass">
<p className="text-xs text-muted-foreground mb-1">npm:</p>
<code className="text-xs text-foreground-secondary font-mono break-all">
{status.installCommands.npm}
</code>
</div>
)}
{status.installCommands.macos && (
<div className="p-2 rounded bg-background border border-border-glass">
<p className="text-xs text-muted-foreground mb-1">
macOS (Homebrew):
</p>
<code className="text-xs text-foreground-secondary font-mono break-all">
{status.installCommands.macos}
</code>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { Trash2, Folder } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import type { Project } from "@/lib/electron";
interface DeleteProjectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
project: Project | null;
onConfirm: (projectId: string) => void;
}
export function DeleteProjectDialog({
open,
onOpenChange,
project,
onConfirm,
}: DeleteProjectDialogProps) {
const handleConfirm = () => {
if (project) {
onConfirm(project.id);
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="w-5 h-5 text-destructive" />
Delete Project
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Are you sure you want to move this project to Trash?
</DialogDescription>
</DialogHeader>
{project && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
<div className="w-10 h-10 rounded-lg bg-sidebar-accent/20 border border-sidebar-border flex items-center justify-center shrink-0">
<Folder className="w-5 h-5 text-brand-500" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
{project.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{project.path}
</p>
</div>
</div>
)}
<p className="text-sm text-muted-foreground">
The folder will remain on disk until you permanently delete it from
Trash.
</p>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
data-testid="confirm-delete-project"
>
<Trash2 className="w-4 h-4 mr-2" />
Move to Trash
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,46 @@
import { Keyboard } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { KeyboardMap, ShortcutReferencePanel } from "@/components/ui/keyboard-map";
interface KeyboardMapDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function KeyboardMapDialog({ open, onOpenChange }: KeyboardMapDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Keyboard className="w-5 h-5 text-brand-500" />
Keyboard Shortcut Map
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Visual overview of all keyboard shortcuts. Keys in color are bound to
shortcuts. Click on any shortcut below to edit it.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-6 py-4 pl-3 pr-6 pb-6">
{/* Visual Keyboard Map */}
<KeyboardMap />
{/* Shortcut Reference - Editable */}
<div className="border-t border-border pt-4">
<h3 className="text-sm font-semibold text-foreground mb-4">
All Shortcuts Reference (Click to Edit)
</h3>
<ShortcutReferencePanel editable />
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,27 @@
import { Settings } from "lucide-react";
interface SettingsHeaderProps {
title?: string;
description?: string;
}
export function SettingsHeader({
title = "Settings",
description = "Configure your API keys and preferences",
}: SettingsHeaderProps) {
return (
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
<div className="px-8 py-6">
<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">
<Settings className="w-5 h-5 text-primary-foreground" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { cn } from "@/lib/utils";
import type { Project } from "@/lib/electron";
import type { NavigationItem } from "../config/navigation";
interface SettingsNavigationProps {
navItems: NavigationItem[];
activeSection: string;
currentProject: Project | null;
onNavigate: (sectionId: string) => void;
}
export function SettingsNavigation({
navItems,
activeSection,
currentProject,
onNavigate,
}: SettingsNavigationProps) {
return (
<nav className="hidden lg:block w-48 shrink-0 border-r border-border bg-card/50 backdrop-blur-sm">
<div className="sticky top-0 p-4 space-y-1">
{navItems
.filter((item) => item.id !== "danger" || currentProject)
.map((item) => {
const Icon = item.icon;
const isActive = activeSection === item.id;
return (
<button
key={item.id}
onClick={() => onNavigate(item.id)}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all text-left",
isActive
? "bg-brand-500/10 text-brand-500 border border-brand-500/20"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
<Icon
className={cn(
"w-4 h-4 shrink-0",
isActive ? "text-brand-500" : ""
)}
/>
<span className="truncate">{item.label}</span>
</button>
);
})}
</div>
</nav>
);
}

View File

@@ -0,0 +1,29 @@
import type { LucideIcon } from "lucide-react";
import {
Key,
Terminal,
Atom,
Palette,
LayoutGrid,
Settings2,
FlaskConical,
Trash2,
} from "lucide-react";
export interface NavigationItem {
id: string;
label: string;
icon: LucideIcon;
}
// Navigation items for the settings side panel
export const NAV_ITEMS: NavigationItem[] = [
{ 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: "kanban", label: "Kanban Display", icon: LayoutGrid },
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
{ id: "danger", label: "Danger Zone", icon: Trash2 },
];

View File

@@ -0,0 +1,57 @@
import { Button } from "@/components/ui/button";
import { Trash2, Folder } from "lucide-react";
import type { Project } from "../shared/types";
interface DangerZoneSectionProps {
project: Project | null;
onDeleteClick: () => void;
}
export function DangerZoneSection({
project,
onDeleteClick,
}: DangerZoneSectionProps) {
if (!project) return null;
return (
<div
id="danger"
className="rounded-xl border border-destructive/30 bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
>
<div className="p-6 border-b border-destructive/30">
<div className="flex items-center gap-2 mb-2">
<Trash2 className="w-5 h-5 text-destructive" />
<h2 className="text-lg font-semibold text-foreground">Danger Zone</h2>
</div>
<p className="text-sm text-muted-foreground">
Permanently remove this project from Automaker.
</p>
</div>
<div className="p-6">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-lg bg-sidebar-accent/20 border border-sidebar-border flex items-center justify-center shrink-0">
<Folder className="w-5 h-5 text-brand-500" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
{project.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{project.path}
</p>
</div>
</div>
<Button
variant="destructive"
onClick={onDeleteClick}
data-testid="delete-project-button"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Project
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { FlaskConical, Settings2, TestTube, GitBranch } from "lucide-react";
interface FeatureDefaultsSectionProps {
showProfilesOnly: boolean;
defaultSkipTests: boolean;
useWorktrees: boolean;
onShowProfilesOnlyChange: (value: boolean) => void;
onDefaultSkipTestsChange: (value: boolean) => void;
onUseWorktreesChange: (value: boolean) => void;
}
export function FeatureDefaultsSection({
showProfilesOnly,
defaultSkipTests,
useWorktrees,
onShowProfilesOnlyChange,
onDefaultSkipTestsChange,
onUseWorktreesChange,
}: FeatureDefaultsSectionProps) {
return (
<div
id="defaults"
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">
<FlaskConical className="w-5 h-5 text-brand-500" />
<h2 className="text-lg font-semibold text-foreground">
Feature Defaults
</h2>
</div>
<p className="text-sm text-muted-foreground">
Configure default settings for new features.
</p>
</div>
<div className="p-6 space-y-4">
{/* Profiles Only Setting */}
<div className="space-y-3">
<div className="flex items-start space-x-3">
<Checkbox
id="show-profiles-only"
checked={showProfilesOnly}
onCheckedChange={(checked) =>
onShowProfilesOnlyChange(checked === true)
}
className="mt-0.5"
data-testid="show-profiles-only-checkbox"
/>
<div className="space-y-1">
<Label
htmlFor="show-profiles-only"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Settings2 className="w-4 h-4 text-brand-500" />
Show profiles only by default
</Label>
<p className="text-xs text-muted-foreground">
When enabled, the Add Feature dialog will show only AI profiles
and hide advanced model tweaking options (Claude SDK, thinking
levels, and OpenAI Codex CLI). This creates a cleaner, less
overwhelming UI. You can always disable this to access advanced
settings.
</p>
</div>
</div>
</div>
{/* Separator */}
<div className="border-t border-border" />
{/* Skip Tests Setting */}
<div className="space-y-3">
<div className="flex items-start space-x-3">
<Checkbox
id="default-skip-tests"
checked={defaultSkipTests}
onCheckedChange={(checked) =>
onDefaultSkipTestsChange(checked === true)
}
className="mt-0.5"
data-testid="default-skip-tests-checkbox"
/>
<div className="space-y-1">
<Label
htmlFor="default-skip-tests"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<TestTube className="w-4 h-4 text-brand-500" />
Skip automated testing by default
</Label>
<p className="text-xs text-muted-foreground">
When enabled, new features will default to manual verification
instead of TDD (test-driven development). You can still override
this for individual features.
</p>
</div>
</div>
</div>
{/* Worktree Isolation Setting */}
<div className="space-y-3 pt-2 border-t border-border">
<div className="flex items-start space-x-3">
<Checkbox
id="use-worktrees"
checked={useWorktrees}
onCheckedChange={(checked) =>
onUseWorktreesChange(checked === true)
}
className="mt-0.5"
data-testid="use-worktrees-checkbox"
/>
<div className="space-y-1">
<Label
htmlFor="use-worktrees"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Enable Git Worktree Isolation (experimental)
</Label>
<p className="text-xs text-muted-foreground">
Creates isolated git branches for each feature. When disabled,
agents work directly in the main project directory. This feature
is experimental and may require additional setup like branch
selection and merge configuration.
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
import { useState, useEffect, useCallback } from "react";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI } from "@/lib/electron";
interface CliStatusResult {
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
recommendation?: string;
installCommands?: {
macos?: string;
windows?: string;
linux?: string;
npm?: string;
};
error?: string;
}
interface CodexCliStatusResult extends CliStatusResult {
hasApiKey?: boolean;
}
/**
* Custom hook for managing Claude and Codex CLI status
* Handles checking CLI installation, authentication, and refresh functionality
*/
export function useCliStatus() {
const { setClaudeAuthStatus, setCodexAuthStatus } = useSetupStore();
const [claudeCliStatus, setClaudeCliStatus] =
useState<CliStatusResult | null>(null);
const [codexCliStatus, setCodexCliStatus] =
useState<CodexCliStatusResult | null>(null);
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false);
// Check CLI status on mount
useEffect(() => {
const checkCliStatus = async () => {
const api = getElectronAPI();
// Check Claude CLI
if (api?.checkClaudeCli) {
try {
const status = await api.checkClaudeCli();
setClaudeCliStatus(status);
} catch (error) {
console.error("Failed to check Claude CLI status:", error);
}
}
// Check Codex CLI
if (api?.checkCodexCli) {
try {
const status = await api.checkCodexCli();
setCodexCliStatus(status);
} catch (error) {
console.error("Failed to check Codex CLI status:", error);
}
}
// Check Claude auth status (re-fetch on mount to ensure persistence)
if (api?.setup?.getClaudeStatus) {
try {
const result = await api.setup.getClaudeStatus();
if (result.success && result.auth) {
const auth = result.auth;
// Validate method is one of the expected values, default to "none"
const validMethods = ["oauth_token_env", "oauth_token", "api_key", "api_key_env", "none"] as const;
type AuthMethod = typeof validMethods[number];
const method: AuthMethod = validMethods.includes(auth.method as AuthMethod)
? (auth.method as AuthMethod)
: "none";
const authStatus = {
authenticated: auth.authenticated,
method,
hasCredentialsFile: auth.hasCredentialsFile ?? false,
oauthTokenValid: auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
apiKeyValid: auth.hasStoredApiKey || auth.hasEnvApiKey,
hasEnvOAuthToken: auth.hasEnvOAuthToken,
hasEnvApiKey: auth.hasEnvApiKey,
};
setClaudeAuthStatus(authStatus);
}
} catch (error) {
console.error("Failed to check Claude auth status:", error);
}
}
// Check Codex auth status (re-fetch on mount to ensure persistence)
if (api?.setup?.getCodexStatus) {
try {
const result = await api.setup.getCodexStatus();
if (result.success && result.auth) {
const auth = result.auth;
// Determine method - prioritize cli_verified and cli_tokens over auth_file
const method =
auth.method === "cli_verified" || auth.method === "cli_tokens"
? auth.method === "cli_verified"
? ("cli_verified" as const)
: ("cli_tokens" as const)
: auth.method === "auth_file"
? ("api_key" as const)
: auth.method === "env_var"
? ("env" as const)
: ("none" as const);
const authStatus = {
authenticated: auth.authenticated,
method,
// Only set apiKeyValid for actual API key methods, not CLI login
apiKeyValid:
method === "cli_verified" || method === "cli_tokens"
? undefined
: auth.hasAuthFile || auth.hasEnvKey,
};
setCodexAuthStatus(authStatus);
}
} catch (error) {
console.error("Failed to check Codex auth status:", error);
}
}
};
checkCliStatus();
}, [setClaudeAuthStatus, setCodexAuthStatus]);
// Refresh Claude CLI status
const handleRefreshClaudeCli = useCallback(async () => {
setIsCheckingClaudeCli(true);
try {
const api = getElectronAPI();
if (api?.checkClaudeCli) {
const status = await api.checkClaudeCli();
setClaudeCliStatus(status);
}
} catch (error) {
console.error("Failed to refresh Claude CLI status:", error);
} finally {
setIsCheckingClaudeCli(false);
}
}, []);
// Refresh Codex CLI status
const handleRefreshCodexCli = useCallback(async () => {
setIsCheckingCodexCli(true);
try {
const api = getElectronAPI();
if (api?.checkCodexCli) {
const status = await api.checkCodexCli();
setCodexCliStatus(status);
}
} catch (error) {
console.error("Failed to refresh Codex CLI status:", error);
} finally {
setIsCheckingCodexCli(false);
}
}, []);
return {
claudeCliStatus,
codexCliStatus,
isCheckingClaudeCli,
isCheckingCodexCli,
handleRefreshClaudeCli,
handleRefreshCodexCli,
};
}

View File

@@ -0,0 +1,96 @@
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { LayoutGrid, Minimize2, Square, Maximize2 } from "lucide-react";
import type { KanbanDetailLevel } from "../shared/types";
interface KanbanDisplaySectionProps {
detailLevel: KanbanDetailLevel;
onChange: (level: KanbanDetailLevel) => void;
}
export function KanbanDisplaySection({
detailLevel,
onChange,
}: KanbanDisplaySectionProps) {
return (
<div
id="kanban"
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">
<LayoutGrid className="w-5 h-5 text-brand-500" />
<h2 className="text-lg font-semibold text-foreground">
Kanban Card Display
</h2>
</div>
<p className="text-sm text-muted-foreground">
Control how much information is displayed on Kanban cards.
</p>
</div>
<div className="p-6 space-y-4">
<div className="space-y-3">
<Label className="text-foreground">Detail Level</Label>
<div className="grid grid-cols-3 gap-3">
<Button
variant={detailLevel === "minimal" ? "secondary" : "outline"}
onClick={() => onChange("minimal")}
className={`flex flex-col items-center justify-center gap-2 px-4 py-4 h-auto ${
detailLevel === "minimal"
? "border-brand-500 ring-1 ring-brand-500/50"
: ""
}`}
data-testid="kanban-detail-minimal"
>
<Minimize2 className="w-5 h-5" />
<span className="font-medium text-sm">Minimal</span>
<span className="text-xs text-muted-foreground text-center">
Title & category only
</span>
</Button>
<Button
variant={detailLevel === "standard" ? "secondary" : "outline"}
onClick={() => onChange("standard")}
className={`flex flex-col items-center justify-center gap-2 px-4 py-4 h-auto ${
detailLevel === "standard"
? "border-brand-500 ring-1 ring-brand-500/50"
: ""
}`}
data-testid="kanban-detail-standard"
>
<Square className="w-5 h-5" />
<span className="font-medium text-sm">Standard</span>
<span className="text-xs text-muted-foreground text-center">
Steps & progress
</span>
</Button>
<Button
variant={detailLevel === "detailed" ? "secondary" : "outline"}
onClick={() => onChange("detailed")}
className={`flex flex-col items-center justify-center gap-2 px-4 py-4 h-auto ${
detailLevel === "detailed"
? "border-brand-500 ring-1 ring-brand-500/50"
: ""
}`}
data-testid="kanban-detail-detailed"
>
<Maximize2 className="w-5 h-5" />
<span className="font-medium text-sm">Detailed</span>
<span className="text-xs text-muted-foreground text-center">
Model, tools & tasks
</span>
</Button>
</div>
<p className="text-xs text-muted-foreground">
<strong>Minimal:</strong> Shows only title and category
<br />
<strong>Standard:</strong> Adds steps preview and progress bar
<br />
<strong>Detailed:</strong> Shows all info including model, tool
calls, task list, and summaries
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { Button } from "@/components/ui/button";
import { Settings2, Keyboard } from "lucide-react";
interface KeyboardShortcutsSectionProps {
onOpenKeyboardMap: () => void;
}
export function KeyboardShortcutsSection({
onOpenKeyboardMap,
}: KeyboardShortcutsSectionProps) {
return (
<div
id="keyboard"
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">
<Settings2 className="w-5 h-5 text-brand-500" />
<h2 className="text-lg font-semibold text-foreground">
Keyboard Shortcuts
</h2>
</div>
<p className="text-sm text-muted-foreground">
Customize keyboard shortcuts for navigation and actions using the
visual keyboard map.
</p>
</div>
<div className="p-6">
{/* Centered message directing to keyboard map */}
<div className="flex flex-col items-center justify-center py-16 text-center space-y-4">
<div className="relative">
<Keyboard className="w-16 h-16 text-brand-500/30" />
<div className="absolute inset-0 bg-brand-500/10 blur-xl rounded-full" />
</div>
<div className="space-y-2 max-w-md">
<h3 className="text-lg font-semibold text-foreground">
Use the Visual Keyboard Map
</h3>
<p className="text-sm text-muted-foreground">
Click the &quot;View Keyboard Map&quot; button above to customize
your keyboard shortcuts. The visual interface shows all available
keys and lets you easily edit shortcuts with single-modifier
restrictions.
</p>
</div>
<Button
variant="default"
size="lg"
onClick={onOpenKeyboardMap}
className="gap-2 mt-4"
>
<Keyboard className="w-5 h-5" />
Open Keyboard Map
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
// Shared TypeScript types for settings view components
export interface CliStatus {
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
hasApiKey?: boolean;
recommendation?: string;
installCommands?: {
macos?: string;
windows?: string;
linux?: string;
npm?: string;
};
error?: string;
}
export type Theme =
| "dark"
| "light"
| "retro"
| "dracula"
| "nord"
| "monokai"
| "tokyonight"
| "solarized"
| "gruvbox"
| "catppuccin"
| "onedark"
| "synthwave";
export type KanbanDetailLevel = "minimal" | "standard" | "detailed";
export interface Project {
id: string;
name: string;
path: string;
theme?: Theme;
}
export interface ApiKeys {
anthropic: string;
google: string;
openai: string;
}

View File

@@ -11,7 +11,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useSetupStore } from "@/store/setup-store";
import { useSetupStore, type CodexAuthStatus } from "@/store/setup-store";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import {
@@ -61,7 +61,12 @@ function StatusBadge({
status,
label,
}: {
status: "installed" | "not_installed" | "checking" | "authenticated" | "not_authenticated";
status:
| "installed"
| "not_installed"
| "checking"
| "authenticated"
| "not_authenticated";
label: string;
}) {
const getStatusConfig = () => {
@@ -128,8 +133,8 @@ function WelcomeStep({ onNext }: { onNext: () => void }) {
Welcome to Automaker
</h2>
<p className="text-muted-foreground max-w-md mx-auto">
Let&apos;s set up your development environment. We&apos;ll check for required
CLI tools and help you configure them.
Let&apos;s set up your development environment. We&apos;ll check for
required CLI tools and help you configure them.
</p>
</div>
@@ -143,7 +148,8 @@ function WelcomeStep({ onNext }: { onNext: () => void }) {
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Anthropic&apos;s powerful AI assistant for code generation and analysis
Anthropic&apos;s powerful AI assistant for code generation and
analysis
</p>
</CardContent>
</Card>
@@ -200,7 +206,9 @@ function ClaudeSetupStep({
const [isChecking, setIsChecking] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const [authMethod, setAuthMethod] = useState<"token" | "api_key" | null>(null);
const [authMethod, setAuthMethod] = useState<"token" | "api_key" | null>(
null
);
const [oauthToken, setOAuthToken] = useState("");
const [apiKey, setApiKey] = useState("");
const [isSaving, setIsSaving] = useState(false);
@@ -213,9 +221,18 @@ function ClaudeSetupStep({
const setupApi = api.setup;
// Debug: Check what's available
console.log("[Claude Setup] isElectron:", typeof window !== "undefined" && (window as any).isElectron);
console.log("[Claude Setup] electronAPI exists:", typeof window !== "undefined" && !!(window as any).electronAPI);
console.log("[Claude Setup] electronAPI.setup exists:", typeof window !== "undefined" && !!(window as any).electronAPI?.setup);
console.log(
"[Claude Setup] isElectron:",
typeof window !== "undefined" && (window as any).isElectron
);
console.log(
"[Claude Setup] electronAPI exists:",
typeof window !== "undefined" && !!(window as any).electronAPI
);
console.log(
"[Claude Setup] electronAPI.setup exists:",
typeof window !== "undefined" && !!(window as any).electronAPI?.setup
);
console.log("[Claude Setup] Setup API available:", !!setupApi);
if (setupApi?.getClaudeStatus) {
@@ -224,7 +241,7 @@ function ClaudeSetupStep({
if (result.success) {
const cliStatus = {
installed: result.installed || result.status === "installed",
installed: result.status === "installed",
path: result.path || null,
version: result.version || null,
method: result.method || "none",
@@ -233,19 +250,32 @@ function ClaudeSetupStep({
setClaudeCliStatus(cliStatus);
if (result.auth) {
// Validate method is one of the expected values, default to "none"
const validMethods = [
"oauth_token_env",
"oauth_token",
"api_key",
"api_key_env",
"none",
] as const;
type AuthMethod = (typeof validMethods)[number];
const method: AuthMethod = validMethods.includes(
result.auth.method as AuthMethod
)
? (result.auth.method as AuthMethod)
: "none";
const authStatus = {
authenticated: result.auth.authenticated,
method: result.auth.method === "oauth_token"
? "oauth"
: result.auth.method?.includes("api_key")
? "api_key"
: "none",
method,
hasCredentialsFile: false,
oauthTokenValid: result.auth.hasStoredOAuthToken,
apiKeyValid: result.auth.hasStoredApiKey || result.auth.hasEnvApiKey,
oauthTokenValid:
result.auth.hasStoredOAuthToken || result.auth.hasEnvOAuthToken,
apiKeyValid:
result.auth.hasStoredApiKey || result.auth.hasEnvApiKey,
hasEnvOAuthToken: result.auth.hasEnvOAuthToken,
hasEnvApiKey: result.auth.hasEnvApiKey,
};
console.log("[Claude Setup] Auth Status:", authStatus);
setClaudeAuthStatus(authStatus as any);
setClaudeAuthStatus(authStatus);
}
}
}
@@ -274,13 +304,18 @@ function ClaudeSetupStep({
const setupApi = api.setup;
if (setupApi?.installClaude) {
const unsubscribe = setupApi.onInstallProgress?.((progress: { cli?: string; data?: string; type?: string }) => {
if (progress.cli === "claude") {
setClaudeInstallProgress({
output: [...claudeInstallProgress.output, progress.data || progress.type || ""],
});
const unsubscribe = setupApi.onInstallProgress?.(
(progress: { cli?: string; data?: string; type?: string }) => {
if (progress.cli === "claude") {
setClaudeInstallProgress({
output: [
...claudeInstallProgress.output,
progress.data || progress.type || "",
],
});
}
}
});
);
const result = await setupApi.installClaude();
unsubscribe?.();
@@ -290,17 +325,17 @@ function ClaudeSetupStep({
// Wait a bit for installation to complete and PATH to update, then retry status check
let retries = 5;
let detected = false;
// Initial delay to let the installation script finish setting up
await new Promise(resolve => setTimeout(resolve, 1500));
await new Promise((resolve) => setTimeout(resolve, 1500));
for (let i = 0; i < retries; i++) {
// Check status
await checkStatus();
// Small delay to let state update
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 300));
// Check if CLI is now detected by re-reading from store
const currentStatus = useSetupStore.getState().claudeCliStatus;
if (currentStatus?.installed) {
@@ -308,18 +343,21 @@ function ClaudeSetupStep({
toast.success("Claude CLI installed and detected successfully");
break;
}
// Wait before next retry (longer delays for later retries)
if (i < retries - 1) {
await new Promise(resolve => setTimeout(resolve, 2000 + (i * 500)));
await new Promise((resolve) =>
setTimeout(resolve, 2000 + i * 500)
);
}
}
// Show appropriate message based on detection
if (!detected) {
// Installation completed but CLI not detected - this is common if PATH wasn't updated in current process
toast.success("Claude CLI installation completed", {
description: "The CLI was installed but may need a terminal restart to be detected. You can continue with authentication if you have a token.",
description:
"The CLI was installed but may need a terminal restart to be detected. You can continue with authentication if you have a token.",
duration: 7000,
});
}
@@ -349,13 +387,16 @@ function ClaudeSetupStep({
const setupApi = api.setup;
if (setupApi?.storeApiKey) {
const result = await setupApi.storeApiKey("anthropic_oauth_token", oauthToken);
const result = await setupApi.storeApiKey(
"anthropic_oauth_token",
oauthToken
);
console.log("[Claude Setup] Store OAuth token result:", result);
if (result.success) {
setClaudeAuthStatus({
authenticated: true,
method: "oauth",
method: "oauth_token",
hasCredentialsFile: false,
oauthTokenValid: true,
});
@@ -433,8 +474,17 @@ function ClaudeSetupStep({
const getAuthMethodLabel = () => {
if (!isAuthenticated) return null;
if (claudeAuthStatus?.method === "oauth") return "Subscription Token";
if (apiKeys.anthropic || claudeAuthStatus?.method === "api_key") return "API Key";
if (
claudeAuthStatus?.method === "oauth_token_env" ||
claudeAuthStatus?.method === "oauth_token"
)
return "Subscription Token";
if (
apiKeys.anthropic ||
claudeAuthStatus?.method === "api_key" ||
claudeAuthStatus?.method === "api_key_env"
)
return "API Key";
return "Authenticated";
};
@@ -457,8 +507,15 @@ function ClaudeSetupStep({
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Status</CardTitle>
<Button variant="ghost" size="sm" onClick={checkStatus} disabled={isChecking}>
<RefreshCw className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`} />
<Button
variant="ghost"
size="sm"
onClick={checkStatus}
disabled={isChecking}
>
<RefreshCw
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
/>
</Button>
</div>
</CardHeader>
@@ -477,7 +534,9 @@ function ClaudeSetupStep({
{claudeCliStatus?.version && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Version</span>
<span className="text-sm font-mono text-foreground">{claudeCliStatus.version}</span>
<span className="text-sm font-mono text-foreground">
{claudeCliStatus.version}
</span>
</div>
)}
@@ -487,11 +546,16 @@ function ClaudeSetupStep({
<div className="flex items-center gap-2">
<StatusBadge status="authenticated" label="Authenticated" />
{getAuthMethodLabel() && (
<span className="text-xs text-muted-foreground">({getAuthMethodLabel()})</span>
<span className="text-xs text-muted-foreground">
({getAuthMethodLabel()})
</span>
)}
</div>
) : (
<StatusBadge status="not_authenticated" label="Not Authenticated" />
<StatusBadge
status="not_authenticated"
label="Not Authenticated"
/>
)}
</div>
</CardContent>
@@ -505,16 +569,28 @@ function ClaudeSetupStep({
<Download className="w-5 h-5" />
Install Claude CLI
</CardTitle>
<CardDescription>Required for subscription-based authentication</CardDescription>
<CardDescription>
Required for subscription-based authentication
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">macOS / Linux</Label>
<Label className="text-sm text-muted-foreground">
macOS / Linux
</Label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
curl -fsSL https://claude.ai/install.sh | bash
</code>
<Button variant="ghost" size="icon" onClick={() => copyCommand("curl -fsSL https://claude.ai/install.sh | bash")}>
<Button
variant="ghost"
size="icon"
onClick={() =>
copyCommand(
"curl -fsSL https://claude.ai/install.sh | bash"
)
}
>
<Copy className="w-4 h-4" />
</Button>
</div>
@@ -526,13 +602,21 @@ function ClaudeSetupStep({
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
irm https://claude.ai/install.ps1 | iex
</code>
<Button variant="ghost" size="icon" onClick={() => copyCommand("irm https://claude.ai/install.ps1 | iex")}>
<Button
variant="ghost"
size="icon"
onClick={() =>
copyCommand("irm https://claude.ai/install.ps1 | iex")
}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
{claudeInstallProgress.isInstalling && <TerminalOutput lines={claudeInstallProgress.output} />}
{claudeInstallProgress.isInstalling && (
<TerminalOutput lines={claudeInstallProgress.output} />
)}
<Button
onClick={handleInstall}
@@ -573,25 +657,37 @@ function ClaudeSetupStep({
<div className="flex items-start gap-3">
<Shield className="w-5 h-5 text-brand-500 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">Subscription Token</p>
<p className="text-sm text-muted-foreground mb-3">Use your Claude subscription (no API charges)</p>
<p className="font-medium text-foreground">
Subscription Token
</p>
<p className="text-sm text-muted-foreground mb-3">
Use your Claude subscription (no API charges)
</p>
{claudeCliStatus?.installed ? (
<>
<div className="mb-3">
<p className="text-sm text-muted-foreground mb-2">1. Run this command in your terminal:</p>
<p className="text-sm text-muted-foreground mb-2">
1. Run this command in your terminal:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
claude setup-token
</code>
<Button variant="ghost" size="icon" onClick={() => copyCommand("claude setup-token")}>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("claude setup-token")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<Label className="text-foreground">2. Paste the token here:</Label>
<Label className="text-foreground">
2. Paste the token here:
</Label>
<Input
type="password"
placeholder="Paste token from claude setup-token..."
@@ -603,7 +699,11 @@ function ClaudeSetupStep({
</div>
<div className="flex gap-2 mt-3">
<Button variant="outline" onClick={() => setAuthMethod(null)} className="border-border">
<Button
variant="outline"
onClick={() => setAuthMethod(null)}
className="border-border"
>
Cancel
</Button>
<Button
@@ -612,7 +712,11 @@ function ClaudeSetupStep({
className="flex-1 bg-brand-500 hover:bg-brand-600 text-white"
data-testid="save-oauth-token-button"
>
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : "Save Token"}
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Save Token"
)}
</Button>
</div>
</>
@@ -620,7 +724,8 @@ function ClaudeSetupStep({
<div className="p-3 rounded bg-yellow-500/10 border border-yellow-500/20">
<p className="text-sm text-yellow-600">
<AlertCircle className="w-4 h-4 inline mr-1" />
Install Claude CLI first to use subscription authentication
Install Claude CLI first to use subscription
authentication
</p>
</div>
)}
@@ -634,10 +739,17 @@ function ClaudeSetupStep({
<Key className="w-5 h-5 text-green-500 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">API Key</p>
<p className="text-sm text-muted-foreground mb-3">Pay-per-use with your Anthropic API key</p>
<p className="text-sm text-muted-foreground mb-3">
Pay-per-use with your Anthropic API key
</p>
<div className="space-y-2">
<Label htmlFor="anthropic-key" className="text-foreground">Anthropic API Key</Label>
<Label
htmlFor="anthropic-key"
className="text-foreground"
>
Anthropic API Key
</Label>
<Input
id="anthropic-key"
type="password"
@@ -662,7 +774,11 @@ function ClaudeSetupStep({
</div>
<div className="flex gap-2 mt-3">
<Button variant="outline" onClick={() => setAuthMethod(null)} className="border-border">
<Button
variant="outline"
onClick={() => setAuthMethod(null)}
className="border-border"
>
Cancel
</Button>
<Button
@@ -671,7 +787,11 @@ function ClaudeSetupStep({
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
data-testid="save-anthropic-key-button"
>
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : "Save API Key"}
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Save API Key"
)}
</Button>
</div>
</div>
@@ -688,9 +808,15 @@ function ClaudeSetupStep({
<div className="flex items-start gap-3">
<Shield className="w-6 h-6 text-brand-500" />
<div>
<p className="font-medium text-foreground">Subscription</p>
<p className="text-sm text-muted-foreground mt-1">Use your Claude subscription</p>
<p className="text-xs text-brand-500 mt-2">No API charges</p>
<p className="font-medium text-foreground">
Subscription
</p>
<p className="text-sm text-muted-foreground mt-1">
Use your Claude subscription
</p>
<p className="text-xs text-brand-500 mt-2">
No API charges
</p>
</div>
</div>
</button>
@@ -704,7 +830,9 @@ function ClaudeSetupStep({
<Key className="w-6 h-6 text-green-500" />
<div>
<p className="font-medium text-foreground">API Key</p>
<p className="text-sm text-muted-foreground mt-1">Use Anthropic API key</p>
<p className="text-sm text-muted-foreground mt-1">
Use Anthropic API key
</p>
<p className="text-xs text-green-500 mt-2">Pay-per-use</p>
</div>
</div>
@@ -724,9 +852,12 @@ function ClaudeSetupStep({
<CheckCircle2 className="w-6 h-6 text-green-500" />
</div>
<div>
<p className="font-medium text-foreground">Claude is ready to use!</p>
<p className="font-medium text-foreground">
Claude is ready to use!
</p>
<p className="text-sm text-muted-foreground">
{getAuthMethodLabel() && `Using ${getAuthMethodLabel()}. `}You can proceed to the next step
{getAuthMethodLabel() && `Using ${getAuthMethodLabel()}. `}You
can proceed to the next step
</p>
</div>
</div>
@@ -736,15 +867,27 @@ function ClaudeSetupStep({
{/* Navigation */}
<div className="flex justify-between pt-4">
<Button variant="ghost" onClick={onBack} className="text-muted-foreground">
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<div className="flex gap-2">
<Button variant="ghost" onClick={onSkip} className="text-muted-foreground">
<Button
variant="ghost"
onClick={onSkip}
className="text-muted-foreground"
>
Skip for now
</Button>
<Button onClick={onNext} className="bg-brand-500 hover:bg-brand-600 text-white" data-testid="claude-next-button">
<Button
onClick={onNext}
className="bg-brand-500 hover:bg-brand-600 text-white"
data-testid="claude-next-button"
>
Continue
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
@@ -780,6 +923,22 @@ function CodexSetupStep({
const [apiKey, setApiKey] = useState("");
const [isSavingKey, setIsSavingKey] = useState(false);
// Normalize CLI auth method strings to our store-friendly values
const mapAuthMethod = (method?: string): CodexAuthStatus["method"] => {
switch (method) {
case "cli_verified":
return "cli_verified";
case "cli_tokens":
return "cli_tokens";
case "auth_file":
return "api_key";
case "env_var":
return "env";
default:
return "none";
}
};
const checkStatus = useCallback(async () => {
console.log("[Codex Setup] Starting status check...");
setIsChecking(true);
@@ -788,7 +947,10 @@ function CodexSetupStep({
const setupApi = api.setup;
console.log("[Codex Setup] Setup API available:", !!setupApi);
console.log("[Codex Setup] getCodexStatus available:", !!setupApi?.getCodexStatus);
console.log(
"[Codex Setup] getCodexStatus available:",
!!setupApi?.getCodexStatus
);
if (setupApi?.getCodexStatus) {
const result = await setupApi.getCodexStatus();
@@ -805,13 +967,19 @@ function CodexSetupStep({
setCodexCliStatus(cliStatus);
if (result.auth) {
const authStatus = {
const method = mapAuthMethod(result.auth.method);
const authStatus: CodexAuthStatus = {
authenticated: result.auth.authenticated,
method: result.auth.method === "auth_file" ? "api_key" : result.auth.method === "env_var" ? "env" : "none",
apiKeyValid: result.auth.authenticated,
method,
// Only set apiKeyValid for actual API key methods, not CLI login
apiKeyValid:
method === "cli_verified" || method === "cli_tokens"
? undefined
: result.auth.authenticated,
};
console.log("[Codex Setup] Auth Status:", authStatus);
setCodexAuthStatus(authStatus as any);
setCodexAuthStatus(authStatus);
} else {
console.log("[Codex Setup] No auth info in result");
}
@@ -847,16 +1015,18 @@ function CodexSetupStep({
const setupApi = api.setup;
if (setupApi?.installCodex) {
const unsubscribe = setupApi.onInstallProgress?.((progress: { cli?: string; data?: string; type?: string }) => {
if (progress.cli === "codex") {
setCodexInstallProgress({
output: [
...codexInstallProgress.output,
progress.data || progress.type || "",
],
});
const unsubscribe = setupApi.onInstallProgress?.(
(progress: { cli?: string; data?: string; type?: string }) => {
if (progress.cli === "codex") {
setCodexInstallProgress({
output: [
...codexInstallProgress.output,
progress.data || progress.type || "",
],
});
}
}
});
);
const result = await setupApi.installCodex();
@@ -893,7 +1063,10 @@ function CodexSetupStep({
const api = getElectronAPI();
const setupApi = api.setup;
console.log("[Codex Setup] storeApiKey available:", !!setupApi?.storeApiKey);
console.log(
"[Codex Setup] storeApiKey available:",
!!setupApi?.storeApiKey
);
if (setupApi?.storeApiKey) {
console.log("[Codex Setup] Calling storeApiKey for openai...");
@@ -901,7 +1074,9 @@ function CodexSetupStep({
console.log("[Codex Setup] storeApiKey result:", result);
if (result.success) {
console.log("[Codex Setup] API key stored successfully, updating state...");
console.log(
"[Codex Setup] API key stored successfully, updating state..."
);
setApiKeys({ ...apiKeys, openai: apiKey });
setCodexAuthStatus({
authenticated: true,
@@ -914,7 +1089,9 @@ function CodexSetupStep({
console.log("[Codex Setup] Failed to store API key:", result.error);
}
} else {
console.log("[Codex Setup] Web mode - storing API key in app state only");
console.log(
"[Codex Setup] Web mode - storing API key in app state only"
);
setApiKeys({ ...apiKeys, openai: apiKey });
setCodexAuthStatus({
authenticated: true,
@@ -938,13 +1115,14 @@ function CodexSetupStep({
};
const isAuthenticated = codexAuthStatus?.authenticated || apiKeys.openai;
const getAuthMethodLabel = () => {
if (!isAuthenticated) return null;
if (apiKeys.openai) return "API Key (Manual)";
if (codexAuthStatus?.method === "api_key") return "API Key (Auth File)";
if (codexAuthStatus?.method === "env") return "API Key (Environment)";
if (codexAuthStatus?.method === "cli_verified") return "CLI Login (ChatGPT)";
if (codexAuthStatus?.method === "cli_verified")
return "CLI Login (ChatGPT)";
return "Authenticated";
};
@@ -1012,7 +1190,10 @@ function CodexSetupStep({
)}
</div>
) : (
<StatusBadge status="not_authenticated" label="Not Authenticated" />
<StatusBadge
status="not_authenticated"
label="Not Authenticated"
/>
)}
</div>
</CardContent>
@@ -1095,9 +1276,7 @@ function CodexSetupStep({
<Key className="w-5 h-5" />
Authentication
</CardTitle>
<CardDescription>
Codex requires an OpenAI API key
</CardDescription>
<CardDescription>Codex requires an OpenAI API key</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{codexCliStatus?.installed && (
@@ -1217,7 +1396,8 @@ function CodexSetupStep({
Codex is ready to use!
</p>
<p className="text-sm text-muted-foreground">
{getAuthMethodLabel() && `Authenticated via ${getAuthMethodLabel()}. `}
{getAuthMethodLabel() &&
`Authenticated via ${getAuthMethodLabel()}. `}
You can proceed to complete setup
</p>
</div>
@@ -1362,22 +1542,34 @@ function CompleteStep({ onFinish }: { onFinish: () => void }) {
// Main Setup View
export function SetupView() {
const { currentStep, setCurrentStep, completeSetup, setSkipClaudeSetup, setSkipCodexSetup } =
useSetupStore();
const {
currentStep,
setCurrentStep,
completeSetup,
setSkipClaudeSetup,
setSkipCodexSetup,
} = useSetupStore();
const { setCurrentView } = useAppStore();
const steps = ["welcome", "claude", "codex", "complete"] as const;
type StepName = typeof steps[number];
type StepName = (typeof steps)[number];
const getStepName = (): StepName => {
if (currentStep === "claude_detect" || currentStep === "claude_auth") return "claude";
if (currentStep === "codex_detect" || currentStep === "codex_auth") return "codex";
if (currentStep === "claude_detect" || currentStep === "claude_auth")
return "claude";
if (currentStep === "codex_detect" || currentStep === "codex_auth")
return "codex";
if (currentStep === "welcome") return "welcome";
return "complete";
};
const currentIndex = steps.indexOf(getStepName());
const handleNext = (from: string) => {
console.log("[Setup Flow] handleNext called from:", from, "currentStep:", currentStep);
console.log(
"[Setup Flow] handleNext called from:",
from,
"currentStep:",
currentStep
);
switch (from) {
case "welcome":
console.log("[Setup Flow] Moving to claude_detect step");
@@ -1426,10 +1618,7 @@ export function SetupView() {
};
return (
<div
className="h-full flex flex-col content-bg"
data-testid="setup-view"
>
<div className="h-full flex flex-col content-bg" data-testid="setup-view">
{/* Header */}
<div className="flex-shrink-0 border-b border-border bg-glass backdrop-blur-md titlebar-drag-region">
<div className="px-8 py-4">
@@ -1448,7 +1637,10 @@ export function SetupView() {
<div className="p-8">
<div className="w-full max-w-2xl mx-auto">
<div className="mb-8">
<StepIndicator currentStep={currentIndex} totalSteps={steps.length} />
<StepIndicator
currentStep={currentIndex}
totalSteps={steps.length}
/>
</div>
<div className="py-8">

View File

@@ -287,7 +287,7 @@ export function SpecView() {
Generate feature list
</label>
<p className="text-xs text-muted-foreground">
Automatically populate feature_list.json with all features from the
Automatically create features in the features folder from the
implementation roadmap after the spec is generated.
</p>
</div>

View File

@@ -157,7 +157,8 @@ export function WelcomeView() {
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
const name = path.split("/").pop() || "Untitled Project";
// Extract folder name from path (works on both Windows and Mac/Linux)
const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
await initializeAndOpenProject(path, name);
}
}, [initializeAndOpenProject]);
@@ -309,9 +310,9 @@ export function WelcomeView() {
data-testid="new-project-card"
>
<div className="absolute inset-0 bg-gradient-to-br from-brand-500/5 to-purple-600/5 opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div className="relative p-6">
<div className="flex items-start gap-4 mb-4">
<div className="w-12 h-12 rounded-lg bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center group-hover:scale-110 transition-transform">
<div className="relative p-6 h-full flex flex-col">
<div className="flex items-start gap-4 flex-1">
<div className="w-12 h-12 rounded-lg bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center group-hover:scale-110 transition-transform shrink-0">
<Plus className="w-6 h-6 text-white" />
</div>
<div className="flex-1">
@@ -327,7 +328,7 @@ export function WelcomeView() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
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"
className="w-full mt-4 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="create-new-project"
>
<Plus className="w-4 h-4 mr-2" />
@@ -361,9 +362,9 @@ export function WelcomeView() {
data-testid="open-project-card"
>
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 to-cyan-600/5 opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div className="relative p-6">
<div className="flex items-start gap-4 mb-4">
<div className="w-12 h-12 rounded-lg bg-muted border border-border flex items-center justify-center group-hover:scale-110 transition-transform">
<div className="relative p-6 h-full flex flex-col">
<div className="flex items-start gap-4 flex-1">
<div className="w-12 h-12 rounded-lg bg-muted border border-border flex items-center justify-center group-hover:scale-110 transition-transform shrink-0">
<FolderOpen className="w-6 h-6 text-muted-foreground group-hover:text-foreground transition-colors" />
</div>
<div className="flex-1">
@@ -377,7 +378,7 @@ export function WelcomeView() {
</div>
<Button
variant="secondary"
className="w-full bg-secondary hover:bg-secondary/80 text-foreground border border-border hover:border-border-glass"
className="w-full mt-4 bg-secondary hover:bg-secondary/80 text-foreground border border-border hover:border-border-glass"
data-testid="open-existing-project"
>
<FolderOpen className="w-4 h-4 mr-2" />

View File

@@ -0,0 +1,149 @@
import type { Dispatch, SetStateAction } from "react";
import type { LucideIcon } from "lucide-react";
import type { ApiKeys } from "@/store/app-store";
export type ProviderKey = "anthropic" | "google" | "openai";
export interface ProviderConfig {
key: ProviderKey;
label: string;
inputId: string;
placeholder: string;
value: string;
setValue: Dispatch<SetStateAction<string>>;
showValue: boolean;
setShowValue: Dispatch<SetStateAction<boolean>>;
hasStoredKey: string | null | undefined;
inputTestId: string;
toggleTestId: string;
testButton: {
onClick: () => Promise<void> | void;
disabled: boolean;
loading: boolean;
testId: string;
};
result: { success: boolean; message: string } | null;
resultTestId: string;
resultMessageTestId: string;
descriptionPrefix: string;
descriptionLinkHref: string;
descriptionLinkText: string;
descriptionSuffix?: string;
}
export interface ProviderConfigParams {
apiKeys: ApiKeys;
anthropic: {
value: string;
setValue: Dispatch<SetStateAction<string>>;
show: boolean;
setShow: Dispatch<SetStateAction<boolean>>;
testing: boolean;
onTest: () => Promise<void>;
result: { success: boolean; message: string } | null;
};
google: {
value: string;
setValue: Dispatch<SetStateAction<string>>;
show: boolean;
setShow: Dispatch<SetStateAction<boolean>>;
testing: boolean;
onTest: () => Promise<void>;
result: { success: boolean; message: string } | null;
};
openai: {
value: string;
setValue: Dispatch<SetStateAction<string>>;
show: boolean;
setShow: Dispatch<SetStateAction<boolean>>;
testing: boolean;
onTest: () => Promise<void>;
result: { success: boolean; message: string } | null;
};
}
export const buildProviderConfigs = ({
apiKeys,
anthropic,
google,
openai,
}: ProviderConfigParams): ProviderConfig[] => [
{
key: "anthropic",
label: "Anthropic API Key (Claude)",
inputId: "anthropic-key",
placeholder: "sk-ant-...",
value: anthropic.value,
setValue: anthropic.setValue,
showValue: anthropic.show,
setShowValue: anthropic.setShow,
hasStoredKey: apiKeys.anthropic,
inputTestId: "anthropic-api-key-input",
toggleTestId: "toggle-anthropic-visibility",
testButton: {
onClick: anthropic.onTest,
disabled: !anthropic.value || anthropic.testing,
loading: anthropic.testing,
testId: "test-claude-connection",
},
result: anthropic.result,
resultTestId: "test-connection-result",
resultMessageTestId: "test-connection-message",
descriptionPrefix: "Used for Claude AI features. Get your key at",
descriptionLinkHref: "https://console.anthropic.com/account/keys",
descriptionLinkText: "console.anthropic.com",
descriptionSuffix:
". Alternatively, the CLAUDE_CODE_OAUTH_TOKEN environment variable can be used.",
},
{
key: "google",
label: "Google API Key (Gemini)",
inputId: "google-key",
placeholder: "AIza...",
value: google.value,
setValue: google.setValue,
showValue: google.show,
setShowValue: google.setShow,
hasStoredKey: apiKeys.google,
inputTestId: "google-api-key-input",
toggleTestId: "toggle-google-visibility",
testButton: {
onClick: google.onTest,
disabled: !google.value || google.testing,
loading: google.testing,
testId: "test-gemini-connection",
},
result: google.result,
resultTestId: "gemini-test-connection-result",
resultMessageTestId: "gemini-test-connection-message",
descriptionPrefix:
"Used for Gemini AI features (including image/design prompts). Get your key at",
descriptionLinkHref: "https://makersuite.google.com/app/apikey",
descriptionLinkText: "makersuite.google.com",
},
{
key: "openai",
label: "OpenAI API Key (Codex/GPT)",
inputId: "openai-key",
placeholder: "sk-...",
value: openai.value,
setValue: openai.setValue,
showValue: openai.show,
setShowValue: openai.setShow,
hasStoredKey: apiKeys.openai,
inputTestId: "openai-api-key-input",
toggleTestId: "toggle-openai-visibility",
testButton: {
onClick: openai.onTest,
disabled: !openai.value || openai.testing,
loading: openai.testing,
testId: "test-openai-connection",
},
result: openai.result,
resultTestId: "openai-test-connection-result",
resultMessageTestId: "openai-test-connection-message",
descriptionPrefix: "Used for OpenAI Codex CLI and GPT models. Get your key at",
descriptionLinkHref: "https://platform.openai.com/api-keys",
descriptionLinkText: "platform.openai.com",
},
];

View File

@@ -0,0 +1,88 @@
import {
type LucideIcon,
Atom,
Cat,
Eclipse,
Flame,
Ghost,
Moon,
Radio,
Snowflake,
Sparkles,
Sun,
Terminal,
Trees,
} from "lucide-react";
import { Theme } from "@/components/views/settings-view/shared/types";
export interface ThemeOption {
value: Theme;
label: string;
Icon: LucideIcon;
testId: string;
}
export const themeOptions: ReadonlyArray<ThemeOption> = [
{ value: "dark", label: "Dark", Icon: Moon, testId: "dark-mode-button" },
{ value: "light", label: "Light", Icon: Sun, testId: "light-mode-button" },
{
value: "retro",
label: "Retro",
Icon: Terminal,
testId: "retro-mode-button",
},
{
value: "dracula",
label: "Dracula",
Icon: Ghost,
testId: "dracula-mode-button",
},
{
value: "nord",
label: "Nord",
Icon: Snowflake,
testId: "nord-mode-button",
},
{
value: "monokai",
label: "Monokai",
Icon: Flame,
testId: "monokai-mode-button",
},
{
value: "tokyonight",
label: "Tokyo Night",
Icon: Sparkles,
testId: "tokyonight-mode-button",
},
{
value: "solarized",
label: "Solarized",
Icon: Eclipse,
testId: "solarized-mode-button",
},
{
value: "gruvbox",
label: "Gruvbox",
Icon: Trees,
testId: "gruvbox-mode-button",
},
{
value: "catppuccin",
label: "Catppuccin",
Icon: Cat,
testId: "catppuccin-mode-button",
},
{
value: "onedark",
label: "One Dark",
Icon: Atom,
testId: "onedark-mode-button",
},
{
value: "synthwave",
label: "Synthwave",
Icon: Radio,
testId: "synthwave-mode-button",
},
];

View File

@@ -1,7 +1,8 @@
import { useEffect, useCallback, useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI, type AutoModeEvent } from "@/lib/electron";
import { getElectronAPI } from "@/lib/electron";
import type { AutoModeEvent } from "@/types/electron";
/**
* Hook for managing auto mode (scoped per project)
@@ -16,6 +17,7 @@ export function useAutoMode() {
currentProject,
addAutoModeActivity,
maxConcurrency,
projects,
} = useAppStore(
useShallow((state) => ({
autoModeByProject: state.autoModeByProject,
@@ -26,9 +28,16 @@ export function useAutoMode() {
currentProject: state.currentProject,
addAutoModeActivity: state.addAutoModeActivity,
maxConcurrency: state.maxConcurrency,
projects: state.projects,
}))
);
// Helper to look up project ID from path
const getProjectIdFromPath = useCallback((path: string): string | undefined => {
const project = projects.find(p => p.path === path);
return project?.id;
}, [projects]);
// Get project-specific auto mode state
const projectId = currentProject?.id;
const projectAutoModeState = useMemo(() => {
@@ -42,17 +51,32 @@ export function useAutoMode() {
// Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
// Handle auto mode events
// Handle auto mode events - listen globally for all projects
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode || !projectId) return;
if (!api?.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
console.log("[AutoMode Event]", event);
// Events include projectId from backend, use it to scope updates
// Events include projectPath from backend - use it to look up project ID
// Fall back to current projectId if not provided in event
const eventProjectId = event.projectId ?? projectId;
let eventProjectId: string | undefined;
if ('projectPath' in event && event.projectPath) {
eventProjectId = getProjectIdFromPath(event.projectPath);
}
if (!eventProjectId && 'projectId' in event && event.projectId) {
eventProjectId = event.projectId;
}
if (!eventProjectId) {
eventProjectId = projectId;
}
// Skip event if we couldn't determine the project
if (!eventProjectId) {
console.warn("[AutoMode] Could not determine project for event:", event);
return;
}
switch (event.type) {
case "auto_mode_feature_start":
@@ -153,8 +177,47 @@ export function useAutoMode() {
clearRunningTasks,
setAutoModeRunning,
addAutoModeActivity,
getProjectIdFromPath,
]);
// Restore auto mode for all projects that were running when app was closed
// This runs once on mount to restart auto loops for persisted running states
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode) return;
// Find all projects that have auto mode marked as running
const projectsToRestart: Array<{ projectId: string; projectPath: string }> = [];
for (const [projectId, state] of Object.entries(autoModeByProject)) {
if (state.isRunning) {
// Find the project path for this project ID
const project = projects.find(p => p.id === projectId);
if (project) {
projectsToRestart.push({ projectId, projectPath: project.path });
}
}
}
// Restart auto mode for each project
for (const { projectId, projectPath } of projectsToRestart) {
console.log(`[AutoMode] Restoring auto mode for project: ${projectPath}`);
api.autoMode.start(projectPath, maxConcurrency).then(result => {
if (!result.success) {
console.error(`[AutoMode] Failed to restore auto mode for ${projectPath}:`, result.error);
// Mark as not running if we couldn't restart
setAutoModeRunning(projectId, false);
} else {
console.log(`[AutoMode] Restored auto mode for ${projectPath}`);
}
}).catch(error => {
console.error(`[AutoMode] Error restoring auto mode for ${projectPath}:`, error);
setAutoModeRunning(projectId, false);
});
}
// Only run once on mount - intentionally empty dependency array
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Start auto mode
const start = useCallback(async () => {
if (!currentProject) {
@@ -199,7 +262,7 @@ export function useAutoMode() {
throw new Error("Auto mode API not available");
}
const result = await api.autoMode.stop();
const result = await api.autoMode.stop(currentProject.path);
if (result.success) {
setAutoModeRunning(currentProject.id, false);

View File

@@ -1,10 +1,10 @@
"use client";
import { useEffect, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { useAppStore, parseShortcut } from "@/store/app-store";
export interface KeyboardShortcut {
key: string;
key: string; // Can be simple "K" or with modifiers "Shift+N", "Cmd+K"
action: () => void;
description?: string;
}
@@ -59,9 +59,44 @@ function isInputFocused(): boolean {
return false;
}
/**
* Check if a keyboard event matches a shortcut definition
*/
function matchesShortcut(event: KeyboardEvent, shortcutStr: string): boolean {
const shortcut = parseShortcut(shortcutStr);
// Check if the key matches (case-insensitive)
if (event.key.toLowerCase() !== shortcut.key.toLowerCase()) {
return false;
}
// Check modifier keys
const cmdCtrlPressed = event.metaKey || event.ctrlKey;
const shiftPressed = event.shiftKey;
const altPressed = event.altKey;
// If shortcut requires cmdCtrl, it must be pressed
if (shortcut.cmdCtrl && !cmdCtrlPressed) return false;
// If shortcut doesn't require cmdCtrl, it shouldn't be pressed
if (!shortcut.cmdCtrl && cmdCtrlPressed) return false;
// If shortcut requires shift, it must be pressed
if (shortcut.shift && !shiftPressed) return false;
// If shortcut doesn't require shift, it shouldn't be pressed
if (!shortcut.shift && shiftPressed) return false;
// If shortcut requires alt, it must be pressed
if (shortcut.alt && !altPressed) return false;
// If shortcut doesn't require alt, it shouldn't be pressed
if (!shortcut.alt && altPressed) return false;
return true;
}
/**
* Hook to manage keyboard shortcuts
* Shortcuts won't fire when user is typing in inputs, textareas, or when dialogs are open
* Supports modifier keys: Shift, Cmd/Ctrl, Alt/Option
*/
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
const handleKeyDown = useCallback(
@@ -71,14 +106,9 @@ export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
return;
}
// Don't trigger if any modifier keys are pressed (except for specific combos we want)
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
}
// Find matching shortcut
const matchingShortcut = shortcuts.find(
(shortcut) => shortcut.key.toLowerCase() === event.key.toLowerCase()
(shortcut) => matchesShortcut(event, shortcut.key)
);
if (matchingShortcut) {

View File

@@ -0,0 +1,104 @@
import { useState, useEffect, useRef, useCallback } from "react";
interface ScrollTrackingItem {
id: string;
}
interface UseScrollTrackingOptions<T extends ScrollTrackingItem> {
/** Navigation items with at least an id property */
items: T[];
/** Optional filter function to determine which items should be tracked */
filterFn?: (item: T) => boolean;
/** Optional initial active section (defaults to first item's id) */
initialSection?: string;
/** Optional offset from top when scrolling to section (defaults to 24) */
scrollOffset?: number;
}
/**
* Generic custom hook for managing scroll-based navigation tracking
* Automatically highlights the active section based on scroll position
* and provides smooth scrolling to sections
*/
export function useScrollTracking<T extends ScrollTrackingItem>({
items,
filterFn = () => true,
initialSection,
scrollOffset = 24,
}: UseScrollTrackingOptions<T>) {
const [activeSection, setActiveSection] = useState(
initialSection || items[0]?.id || ""
);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Track scroll position to highlight active nav item
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const handleScroll = () => {
const sections = items
.filter(filterFn)
.map((item) => ({
id: item.id,
element: document.getElementById(item.id),
}))
.filter((s) => s.element);
const containerRect = container.getBoundingClientRect();
const scrollTop = container.scrollTop;
const scrollHeight = container.scrollHeight;
const clientHeight = container.clientHeight;
// Check if scrolled to bottom (within a small threshold)
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50;
if (isAtBottom && sections.length > 0) {
// If at bottom, highlight the last visible section
setActiveSection(sections[sections.length - 1].id);
return;
}
for (let i = sections.length - 1; i >= 0; i--) {
const section = sections[i];
if (section.element) {
const rect = section.element.getBoundingClientRect();
const relativeTop = rect.top - containerRect.top + scrollTop;
if (scrollTop >= relativeTop - 100) {
setActiveSection(section.id);
break;
}
}
}
};
container.addEventListener("scroll", handleScroll);
return () => container.removeEventListener("scroll", handleScroll);
}, [items, filterFn]);
// Scroll to a specific section with smooth animation
const scrollToSection = useCallback(
(sectionId: string) => {
const element = document.getElementById(sectionId);
if (element && scrollContainerRef.current) {
const container = scrollContainerRef.current;
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const relativeTop =
elementRect.top - containerRect.top + container.scrollTop;
container.scrollTo({
top: relativeTop - scrollOffset,
behavior: "smooth",
});
}
},
[scrollOffset]
);
return {
activeSection,
scrollToSection,
scrollContainerRef,
};
}

View File

@@ -52,3 +52,5 @@ export function useWindowState(): WindowState {
return windowState;
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,25 +15,21 @@ export interface ProjectInitResult {
existingFiles?: string[];
}
/**
* Default feature_list.json template for new projects
*/
const DEFAULT_FEATURE_LIST = JSON.stringify([], null, 2);
/**
* Required files and directories in the .automaker directory
* Note: app_spec.txt is NOT created automatically - user must set it up via the spec editor
*/
const REQUIRED_STRUCTURE = {
const REQUIRED_STRUCTURE: {
directories: string[];
files: Record<string, string>;
} = {
directories: [
".automaker",
".automaker/context",
".automaker/agents-context",
".automaker/features",
".automaker/images",
],
files: {
".automaker/feature_list.json": DEFAULT_FEATURE_LIST,
},
files: {},
};
/**
@@ -64,16 +60,16 @@ export async function initializeProject(
const exists = await api.exists(fullPath);
if (!exists) {
await api.writeFile(fullPath, defaultContent);
await api.writeFile(fullPath, defaultContent as string);
createdFiles.push(relativePath);
} else {
existingFiles.push(relativePath);
}
}
// Determine if this is a new project (all files were created)
// Determine if this is a new project (no files needed to be created since features/ is empty by default)
const isNewProject =
createdFiles.length === Object.keys(REQUIRED_STRUCTURE.files).length;
createdFiles.length === 0 && existingFiles.length === 0;
return {
success: true,
@@ -103,9 +99,9 @@ export async function isProjectInitialized(
const api = getElectronAPI();
try {
// Check all required files exist
for (const relativePath of Object.keys(REQUIRED_STRUCTURE.files)) {
const fullPath = `${projectPath}/${relativePath}`;
// Check all required directories exist (no files required - features/ folder is source of truth)
for (const dir of REQUIRED_STRUCTURE.directories) {
const fullPath = `${projectPath}/${dir}`;
const exists = await api.exists(fullPath);
if (!exists) {
return false;
@@ -138,13 +134,14 @@ export async function getProjectInitStatus(projectPath: string): Promise<{
const existingFiles: string[] = [];
try {
for (const relativePath of Object.keys(REQUIRED_STRUCTURE.files)) {
const fullPath = `${projectPath}/${relativePath}`;
// Check directories (no files required - features/ folder is source of truth)
for (const dir of REQUIRED_STRUCTURE.directories) {
const fullPath = `${projectPath}/${dir}`;
const exists = await api.exists(fullPath);
if (exists) {
existingFiles.push(relativePath);
existingFiles.push(dir);
} else {
missingFiles.push(relativePath);
missingFiles.push(dir);
}
}
@@ -157,7 +154,7 @@ export async function getProjectInitStatus(projectPath: string): Promise<{
console.error("[project-init] Error getting project status:", error);
return {
initialized: false,
missingFiles: Object.keys(REQUIRED_STRUCTURE.files),
missingFiles: REQUIRED_STRUCTURE.directories,
existingFiles: [],
};
}

View File

@@ -12,7 +12,8 @@ export type ViewMode =
| "tools"
| "interview"
| "context"
| "profiles";
| "profiles"
| "running-agents";
export type ThemeMode =
| "light"
@@ -37,7 +38,75 @@ export interface ApiKeys {
openai: string;
}
// Keyboard Shortcuts
// Keyboard Shortcut with optional modifiers
export interface ShortcutKey {
key: string; // The main key (e.g., "K", "N", "1")
shift?: boolean; // Shift key modifier
cmdCtrl?: boolean; // Cmd on Mac, Ctrl on Windows/Linux
alt?: boolean; // Alt/Option key modifier
}
// Helper to parse shortcut string to ShortcutKey object
export function parseShortcut(shortcut: string): ShortcutKey {
const parts = shortcut.split("+").map(p => p.trim());
const result: ShortcutKey = { key: parts[parts.length - 1] };
// Normalize common OS-specific modifiers (Cmd/Ctrl/Win/Super symbols) into cmdCtrl
for (let i = 0; i < parts.length - 1; i++) {
const modifier = parts[i].toLowerCase();
if (modifier === "shift") result.shift = true;
else if (modifier === "cmd" || modifier === "ctrl" || modifier === "win" || modifier === "super" || modifier === "⌘" || modifier === "^" || modifier === "⊞" || modifier === "◆") result.cmdCtrl = true;
else if (modifier === "alt" || modifier === "opt" || modifier === "option" || modifier === "⌥") result.alt = true;
}
return result;
}
// Helper to format ShortcutKey to display string
export function formatShortcut(shortcut: string, forDisplay = false): string {
const parsed = parseShortcut(shortcut);
const parts: string[] = [];
// Prefer User-Agent Client Hints when available; fall back to legacy
const platform: 'darwin' | 'win32' | 'linux' = (() => {
if (typeof navigator === 'undefined') return 'linux';
const uaPlatform = (navigator as Navigator & { userAgentData?: { platform?: string } })
.userAgentData?.platform?.toLowerCase?.();
const legacyPlatform = navigator.platform?.toLowerCase?.();
const platformString = uaPlatform || legacyPlatform || '';
if (platformString.includes('mac')) return 'darwin';
if (platformString.includes('win')) return 'win32';
return 'linux';
})();
// Primary modifier - OS-specific
if (parsed.cmdCtrl) {
if (forDisplay) {
parts.push(platform === 'darwin' ? '⌘' : platform === 'win32' ? '⊞' : '◆');
} else {
parts.push(platform === 'darwin' ? 'Cmd' : platform === 'win32' ? 'Win' : 'Super');
}
}
// Alt/Option
if (parsed.alt) {
parts.push(forDisplay ? (platform === 'darwin' ? '⌥' : 'Alt') : (platform === 'darwin' ? 'Opt' : 'Alt'));
}
// Shift
if (parsed.shift) {
parts.push(forDisplay ? '⇧' : 'Shift');
}
parts.push(parsed.key.toUpperCase());
// Add spacing when displaying symbols
return parts.join(forDisplay ? " " : "+");
}
// Keyboard Shortcuts - stored as strings like "K", "Shift+N", "Cmd+K"
export interface KeyboardShortcuts {
// Navigation shortcuts
board: string;
@@ -47,10 +116,10 @@ export interface KeyboardShortcuts {
tools: string;
settings: string;
profiles: string;
// UI shortcuts
toggleSidebar: string;
// Action shortcuts
addFeature: string;
addContextFile: string;
@@ -260,6 +329,9 @@ export interface AppState {
// Keyboard Shortcuts
keyboardShortcuts: KeyboardShortcuts; // User-defined keyboard shortcuts
// Audio Settings
muteDoneSound: boolean; // When true, mute the notification sound when agents complete (default: false)
// Project Analysis
projectAnalysis: ProjectAnalysis | null;
isAnalyzing: boolean;
@@ -312,7 +384,9 @@ export interface AppActions {
// Feature actions
setFeatures: (features: Feature[]) => void;
updateFeature: (id: string, updates: Partial<Feature>) => void;
addFeature: (feature: Omit<Feature, "id">) => void;
addFeature: (
feature: Omit<Feature, "id"> & Partial<Pick<Feature, "id">>
) => Feature;
removeFeature: (id: string) => void;
moveFeature: (id: string, newStatus: Feature["status"]) => void;
@@ -365,6 +439,9 @@ export interface AppActions {
setKeyboardShortcuts: (shortcuts: Partial<KeyboardShortcuts>) => void;
resetKeyboardShortcuts: () => void;
// Audio Settings actions
setMuteDoneSound: (muted: boolean) => void;
// AI Profile actions
addAIProfile: (profile: Omit<AIProfile, "id">) => void;
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
@@ -467,6 +544,7 @@ const initialState: AppState = {
useWorktrees: false, // Default to disabled (worktree feature is experimental)
showProfilesOnly: false, // Default to showing all options (not profiles only)
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
muteDoneSound: false, // Default to sound enabled (not muted)
aiProfiles: DEFAULT_AI_PROFILES,
projectAnalysis: null,
isAnalyzing: false,
@@ -744,10 +822,12 @@ export const useAppStore = create<AppState & AppActions>()(
},
addFeature: (feature) => {
const id = `feature-${Date.now()}-${Math.random()
.toString(36)
.substr(2, 9)}`;
set({ features: [...get().features, { ...feature, id }] });
const id =
feature.id ||
`feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const featureWithId = { ...feature, id } as Feature;
set({ features: [...get().features, featureWithId] });
return featureWithId;
},
removeFeature: (id) => {
@@ -993,6 +1073,9 @@ export const useAppStore = create<AppState & AppActions>()(
set({ keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS });
},
// Audio Settings actions
setMuteDoneSound: (muted) => set({ muteDoneSound: muted }),
// AI Profile actions
addAIProfile: (profile) => {
const id = `profile-${Date.now()}-${Math.random()
@@ -1067,11 +1150,13 @@ export const useAppStore = create<AppState & AppActions>()(
chatSessions: state.chatSessions,
chatHistoryOpen: state.chatHistoryOpen,
maxConcurrency: state.maxConcurrency,
autoModeByProject: state.autoModeByProject,
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
defaultSkipTests: state.defaultSkipTests,
useWorktrees: state.useWorktrees,
showProfilesOnly: state.showProfilesOnly,
keyboardShortcuts: state.keyboardShortcuts,
muteDoneSound: state.muteDoneSound,
aiProfiles: state.aiProfiles,
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
}),

View File

@@ -13,17 +13,19 @@ export interface CliStatus {
// Claude Auth Status
export interface ClaudeAuthStatus {
authenticated: boolean;
method: "oauth" | "api_key" | "none";
hasCredentialsFile: boolean;
method: "oauth_token_env" | "oauth_token" | "api_key" | "api_key_env" | "none";
hasCredentialsFile?: boolean;
oauthTokenValid?: boolean;
apiKeyValid?: boolean;
hasEnvOAuthToken?: boolean;
hasEnvApiKey?: boolean;
error?: string;
}
// Codex Auth Status
export interface CodexAuthStatus {
authenticated: boolean;
method: "api_key" | "env" | "none";
method: "api_key" | "env" | "cli_verified" | "cli_tokens" | "none";
apiKeyValid?: boolean;
mcpConfigured?: boolean;
error?: string;

View File

@@ -163,18 +163,21 @@ export type AutoModeEvent =
type: "auto_mode_feature_start";
featureId: string;
projectId?: string;
projectPath?: string;
feature: unknown;
}
| {
type: "auto_mode_progress";
featureId: string;
projectId?: string;
projectPath?: string;
content: string;
}
| {
type: "auto_mode_tool";
featureId: string;
projectId?: string;
projectPath?: string;
tool: string;
input: unknown;
}
@@ -182,6 +185,7 @@ export type AutoModeEvent =
type: "auto_mode_feature_complete";
featureId: string;
projectId?: string;
projectPath?: string;
passes: boolean;
message: string;
}
@@ -190,22 +194,26 @@ export type AutoModeEvent =
error: string;
featureId?: string;
projectId?: string;
projectPath?: string;
}
| {
type: "auto_mode_complete";
message: string;
projectId?: string;
projectPath?: string;
}
| {
type: "auto_mode_phase";
featureId: string;
projectId?: string;
projectPath?: string;
phase: "planning" | "action" | "verification";
message: string;
}
| {
type: "auto_mode_ultrathink_preparation";
featureId: string;
projectPath?: string;
warnings: string[];
recommendations: string[];
estimatedCost?: number;
@@ -264,14 +272,18 @@ export interface SpecRegenerationAPI {
}
export interface AutoModeAPI {
start: (projectPath: string) => Promise<{
start: (
projectPath: string,
maxConcurrency?: number
) => Promise<{
success: boolean;
error?: string;
}>;
stop: () => Promise<{
stop: (projectPath: string) => Promise<{
success: boolean;
error?: string;
runningFeatures?: number;
}>;
stopFeature: (featureId: string) => Promise<{
@@ -279,33 +291,49 @@ export interface AutoModeAPI {
error?: string;
}>;
status: () => Promise<{
status: (projectPath?: string) => Promise<{
success: boolean;
autoLoopRunning?: boolean;
isRunning?: boolean;
currentFeatureId?: string | null;
runningFeatures?: string[];
runningProjects?: string[];
runningCount?: number;
error?: string;
}>;
runFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) => Promise<{
runFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean
) => Promise<{
success: boolean;
passes?: boolean;
error?: string;
}>;
verifyFeature: (projectPath: string, featureId: string) => Promise<{
verifyFeature: (
projectPath: string,
featureId: string
) => Promise<{
success: boolean;
passes?: boolean;
error?: string;
}>;
resumeFeature: (projectPath: string, featureId: string) => Promise<{
resumeFeature: (
projectPath: string,
featureId: string
) => Promise<{
success: boolean;
passes?: boolean;
error?: string;
}>;
contextExists: (projectPath: string, featureId: string) => Promise<{
contextExists: (
projectPath: string,
featureId: string
) => Promise<{
success: boolean;
exists?: boolean;
error?: string;
@@ -317,13 +345,21 @@ export interface AutoModeAPI {
error?: string;
}>;
followUpFeature: (projectPath: string, featureId: string, prompt: string, imagePaths?: string[]) => Promise<{
followUpFeature: (
projectPath: string,
featureId: string,
prompt: string,
imagePaths?: string[]
) => Promise<{
success: boolean;
passes?: boolean;
error?: string;
}>;
commitFeature: (projectPath: string, featureId: string) => Promise<{
commitFeature: (
projectPath: string,
featureId: string
) => Promise<{
success: boolean;
error?: string;
}>;
@@ -333,6 +369,9 @@ export interface AutoModeAPI {
export interface ElectronAPI {
ping: () => Promise<string>;
openExternalLink: (
url: string
) => Promise<{ success: boolean; error?: string }>;
// Dialog APIs
openDirectory: () => Promise<{
@@ -350,7 +389,10 @@ export interface ElectronAPI {
content?: string;
error?: string;
}>;
writeFile: (filePath: string, content: string) => Promise<{
writeFile: (
filePath: string,
content: string
) => Promise<{
success: boolean;
error?: string;
}>;
@@ -513,25 +555,35 @@ export interface FileDiffResult {
export interface WorktreeAPI {
// Revert feature changes by removing the worktree
revertFeature: (projectPath: string, featureId: string) => Promise<{
revertFeature: (
projectPath: string,
featureId: string
) => Promise<{
success: boolean;
removedPath?: string;
error?: string;
}>;
// Merge feature worktree changes back to main branch
mergeFeature: (projectPath: string, featureId: string, options?: {
squash?: boolean;
commitMessage?: string;
squashMessage?: string;
}) => Promise<{
mergeFeature: (
projectPath: string,
featureId: string,
options?: {
squash?: boolean;
commitMessage?: string;
squashMessage?: string;
}
) => Promise<{
success: boolean;
mergedBranch?: string;
error?: string;
}>;
// Get worktree info for a feature
getInfo: (projectPath: string, featureId: string) => Promise<{
getInfo: (
projectPath: string,
featureId: string
) => Promise<{
success: boolean;
worktreePath?: string;
branchName?: string;
@@ -540,7 +592,10 @@ export interface WorktreeAPI {
}>;
// Get worktree status (changed files, commits)
getStatus: (projectPath: string, featureId: string) => Promise<WorktreeStatus>;
getStatus: (
projectPath: string,
featureId: string
) => Promise<WorktreeStatus>;
// List all feature worktrees
list: (projectPath: string) => Promise<{
@@ -550,10 +605,17 @@ export interface WorktreeAPI {
}>;
// Get file diffs for a feature worktree
getDiffs: (projectPath: string, featureId: string) => Promise<FileDiffsResult>;
getDiffs: (
projectPath: string,
featureId: string
) => Promise<FileDiffsResult>;
// Get diff for a specific file in a worktree
getFileDiff: (projectPath: string, featureId: string, filePath: string) => Promise<FileDiffResult>;
getFileDiff: (
projectPath: string,
featureId: string,
filePath: string
) => Promise<FileDiffResult>;
}
export interface GitAPI {
@@ -561,7 +623,10 @@ export interface GitAPI {
getDiffs: (projectPath: string) => Promise<FileDiffsResult>;
// Get diff for a specific file in the main project
getFileDiff: (projectPath: string, filePath: string) => Promise<FileDiffResult>;
getFileDiff: (
projectPath: string,
filePath: string
) => Promise<FileDiffResult>;
}
// Model definition type

View File

@@ -183,7 +183,9 @@ export async function dragKanbanCard(
): Promise<void> {
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
const targetColumn = page.locator(`[data-testid="kanban-column-${targetColumnId}"]`);
const targetColumn = page.locator(
`[data-testid="kanban-column-${targetColumnId}"]`
);
// Perform drag and drop
await dragHandle.dragTo(targetColumn);
@@ -433,7 +435,13 @@ export async function setupMockProjectAtConcurrencyLimit(
runningTasks: string[] = ["running-task-1"]
): Promise<void> {
await page.addInitScript(
({ maxConcurrency, runningTasks }: { maxConcurrency: number; runningTasks: string[] }) => {
({
maxConcurrency,
runningTasks,
}: {
maxConcurrency: number;
runningTasks: string[];
}) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
@@ -570,43 +578,40 @@ export async function setupMockProjectWithFeatures(
}>;
}
): Promise<void> {
await page.addInitScript(
(opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
await page.addInitScript((opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
const mockFeatures = opts?.features || [];
const mockFeatures = opts?.features || [];
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Also store features in a global variable that the mock electron API can use
// This is needed because the board-view loads features from the file system
(window as any).__mockFeatures = mockFeatures;
},
options
);
// Also store features in a global variable that the mock electron API can use
// This is needed because the board-view loads features from the file system
(window as any).__mockFeatures = mockFeatures;
}, options);
}
/**
@@ -619,7 +624,13 @@ export async function setupMockProjectWithContextFile(
contextContent: string = "# Agent Context\n\nPrevious implementation work..."
): Promise<void> {
await page.addInitScript(
({ featureId, contextContent }: { featureId: string; contextContent: string }) => {
({
featureId,
contextContent,
}: {
featureId: string;
contextContent: string;
}) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
@@ -645,9 +656,10 @@ export async function setupMockProjectWithContextFile(
// Set up mock file system with a context file for the feature
// This will be used by the mock electron API
// Now uses features/{id}/agent-output.md path
(window as any).__mockContextFile = {
featureId,
path: `/mock/test-project/.automaker/agents-context/${featureId}.md`,
path: `/mock/test-project/.automaker/features/${featureId}/agent-output.md`,
content: contextContent,
};
},
@@ -668,14 +680,18 @@ export async function getCategoryAutocompleteInput(
/**
* Get the category autocomplete dropdown list
*/
export async function getCategoryAutocompleteList(page: Page): Promise<Locator> {
export async function getCategoryAutocompleteList(
page: Page
): Promise<Locator> {
return page.locator('[data-testid="category-autocomplete-list"]');
}
/**
* Check if the category autocomplete dropdown is visible
*/
export async function isCategoryAutocompleteListVisible(page: Page): Promise<boolean> {
export async function isCategoryAutocompleteListVisible(
page: Page
): Promise<boolean> {
const list = page.locator('[data-testid="category-autocomplete-list"]');
return await list.isVisible();
}
@@ -707,7 +723,9 @@ export async function clickCategoryOption(
page: Page,
categoryName: string
): Promise<void> {
const optionTestId = `category-option-${categoryName.toLowerCase().replace(/\s+/g, "-")}`;
const optionTestId = `category-option-${categoryName
.toLowerCase()
.replace(/\s+/g, "-")}`;
const option = page.locator(`[data-testid="${optionTestId}"]`);
await option.click();
}
@@ -719,7 +737,9 @@ export async function getCategoryOption(
page: Page,
categoryName: string
): Promise<Locator> {
const optionTestId = `category-option-${categoryName.toLowerCase().replace(/\s+/g, "-")}`;
const optionTestId = `category-option-${categoryName
.toLowerCase()
.replace(/\s+/g, "-")}`;
return page.locator(`[data-testid="${optionTestId}"]`);
}
@@ -788,7 +808,9 @@ export async function clickArchiveSession(
/**
* Check if the no session placeholder is visible
*/
export async function isNoSessionPlaceholderVisible(page: Page): Promise<boolean> {
export async function isNoSessionPlaceholderVisible(
page: Page
): Promise<boolean> {
const placeholder = page.locator('[data-testid="no-session-placeholder"]');
return await placeholder.isVisible();
}
@@ -864,43 +886,40 @@ export async function setupMockProjectWithInProgressFeatures(
}>;
}
): Promise<void> {
await page.addInitScript(
(opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
await page.addInitScript((opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
const mockFeatures = opts?.features || [];
const mockFeatures = opts?.features || [];
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Also store features in a global variable that the mock electron API can use
// This is needed because the board-view loads features from the file system
(window as any).__mockFeatures = mockFeatures;
},
options
);
// Also store features in a global variable that the mock electron API can use
// This is needed because the board-view loads features from the file system
(window as any).__mockFeatures = mockFeatures;
}, options);
}
/**
@@ -1052,7 +1071,8 @@ export async function navigateToView(
page: Page,
viewId: string
): Promise<void> {
const navSelector = viewId === "settings" ? "settings-button" : `nav-${viewId}`;
const navSelector =
viewId === "settings" ? "settings-button" : `nav-${viewId}`;
await clickElement(page, navSelector);
await page.waitForTimeout(100);
}
@@ -1126,7 +1146,9 @@ export async function setupEmptyLocalStorage(page: Page): Promise<void> {
/**
* Set up mock projects in localStorage but with no current project (for recent projects list)
*/
export async function setupMockProjectsWithoutCurrent(page: Page): Promise<void> {
export async function setupMockProjectsWithoutCurrent(
page: Page
): Promise<void> {
await page.addInitScript(() => {
const mockProjects = [
{
@@ -1191,7 +1213,9 @@ export async function closeProjectInitDialog(page: Page): Promise<void> {
/**
* Check if the project opening overlay is visible
*/
export async function isProjectOpeningOverlayVisible(page: Page): Promise<boolean> {
export async function isProjectOpeningOverlayVisible(
page: Page
): Promise<boolean> {
const overlay = page.locator('[data-testid="project-opening-overlay"]');
return await overlay.isVisible();
}
@@ -1263,7 +1287,9 @@ export async function pressShortcut(page: Page, key: string): Promise<void> {
* Count the number of session items in the session list
*/
export async function countSessionItems(page: Page): Promise<number> {
const sessionList = page.locator('[data-testid="session-list"] [data-testid^="session-item-"]');
const sessionList = page.locator(
'[data-testid="session-list"] [data-testid^="session-item-"]'
);
return await sessionList.count();
}
@@ -1369,25 +1395,31 @@ export async function waitForProjectAnalysisComplete(
): Promise<void> {
// Wait for the analyzing text to disappear
const analyzingText = page.locator('p:has-text("AI agent is analyzing")');
await analyzingText.waitFor({
timeout: options?.timeout ?? 10000,
state: "hidden",
}).catch(() => {
// It may never have been visible, that's ok
});
await analyzingText
.waitFor({
timeout: options?.timeout ?? 10000,
state: "hidden",
})
.catch(() => {
// It may never have been visible, that's ok
});
}
/**
* Get the delete confirmation dialog
*/
export async function getDeleteConfirmationDialog(page: Page): Promise<Locator> {
export async function getDeleteConfirmationDialog(
page: Page
): Promise<Locator> {
return page.locator('[data-testid="delete-confirmation-dialog"]');
}
/**
* Check if the delete confirmation dialog is visible
*/
export async function isDeleteConfirmationDialogVisible(page: Page): Promise<boolean> {
export async function isDeleteConfirmationDialogVisible(
page: Page
): Promise<boolean> {
const dialog = page.locator('[data-testid="delete-confirmation-dialog"]');
return await dialog.isVisible().catch(() => false);
}
@@ -1469,14 +1501,18 @@ export async function waitForEditFeatureDialog(
/**
* Get the edit feature description input/textarea element
*/
export async function getEditFeatureDescriptionInput(page: Page): Promise<Locator> {
export async function getEditFeatureDescriptionInput(
page: Page
): Promise<Locator> {
return page.locator('[data-testid="edit-feature-description"]');
}
/**
* Check if the edit feature description field is a textarea
*/
export async function isEditFeatureDescriptionTextarea(page: Page): Promise<boolean> {
export async function isEditFeatureDescriptionTextarea(
page: Page
): Promise<boolean> {
const element = page.locator('[data-testid="edit-feature-description"]');
const tagName = await element.evaluate((el) => el.tagName.toLowerCase());
return tagName === "textarea";
@@ -1643,39 +1679,36 @@ export async function setupMockProjectWithSkipTestsFeatures(
}>;
}
): Promise<void> {
await page.addInitScript(
(opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
await page.addInitScript((opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
const mockFeatures = opts?.features || [];
const mockFeatures = opts?.features || [];
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
},
options
);
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
}, options);
}
/**
@@ -1688,24 +1721,34 @@ export async function pressNumberKey(page: Page, num: number): Promise<void> {
/**
* Get the modal title/description text to verify which feature's output is being shown
*/
export async function getAgentOutputModalDescription(page: Page): Promise<string | null> {
export async function getAgentOutputModalDescription(
page: Page
): Promise<string | null> {
const modal = page.locator('[data-testid="agent-output-modal"]');
const description = modal.locator('[id="radix-\\:r.+\\:-description"]').first();
const description = modal
.locator('[id="radix-\\:r.+\\:-description"]')
.first();
return await description.textContent().catch(() => null);
}
/**
* Check the dialog description content in the agent output modal
*/
export async function getOutputModalDescription(page: Page): Promise<string | null> {
const modalDescription = page.locator('[data-testid="agent-output-modal"] [data-slot="dialog-description"]');
export async function getOutputModalDescription(
page: Page
): Promise<string | null> {
const modalDescription = page.locator(
'[data-testid="agent-output-modal"] [data-slot="dialog-description"]'
);
return await modalDescription.textContent().catch(() => null);
}
/**
* Check if the project picker dropdown is open
*/
export async function isProjectPickerDropdownOpen(page: Page): Promise<boolean> {
export async function isProjectPickerDropdownOpen(
page: Page
): Promise<boolean> {
const dropdown = page.locator('[data-testid="project-picker-dropdown"]');
return await dropdown.isVisible().catch(() => false);
}
@@ -1733,14 +1776,20 @@ export async function waitForProjectPickerDropdownHidden(
/**
* Get a project hotkey indicator element by number (1-5)
*/
export async function getProjectHotkey(page: Page, num: number): Promise<Locator> {
export async function getProjectHotkey(
page: Page,
num: number
): Promise<Locator> {
return page.locator(`[data-testid="project-hotkey-${num}"]`);
}
/**
* Check if a project hotkey indicator is visible
*/
export async function isProjectHotkeyVisible(page: Page, num: number): Promise<boolean> {
export async function isProjectHotkeyVisible(
page: Page,
num: number
): Promise<boolean> {
const hotkey = page.locator(`[data-testid="project-hotkey-${num}"]`);
return await hotkey.isVisible().catch(() => false);
}
@@ -1792,7 +1841,9 @@ export async function setupMockMultipleProjects(
/**
* Get the description image dropzone element
*/
export async function getDescriptionImageDropzone(page: Page): Promise<Locator> {
export async function getDescriptionImageDropzone(
page: Page
): Promise<Locator> {
return page.locator('[data-testid="feature-description-input"]');
}
@@ -1806,7 +1857,9 @@ export async function getDescriptionImageInput(page: Page): Promise<Locator> {
/**
* Check if the description image previews section is visible
*/
export async function isDescriptionImagePreviewsVisible(page: Page): Promise<boolean> {
export async function isDescriptionImagePreviewsVisible(
page: Page
): Promise<boolean> {
const previews = page.locator('[data-testid="description-image-previews"]');
return await previews.isVisible().catch(() => false);
}
@@ -1814,7 +1867,9 @@ export async function isDescriptionImagePreviewsVisible(page: Page): Promise<boo
/**
* Get the number of description image previews
*/
export async function getDescriptionImagePreviewCount(page: Page): Promise<number> {
export async function getDescriptionImagePreviewCount(
page: Page
): Promise<number> {
const previews = page.locator('[data-testid^="description-image-preview-"]');
return await previews.count();
}
@@ -1845,7 +1900,9 @@ export async function waitForDescriptionImagePreview(
page: Page,
options?: { timeout?: number }
): Promise<Locator> {
const preview = page.locator('[data-testid^="description-image-preview-"]').first();
const preview = page
.locator('[data-testid^="description-image-preview-"]')
.first();
await preview.waitFor({
timeout: options?.timeout ?? 5000,
state: "visible",
@@ -1913,7 +1970,9 @@ export async function scrollToBottom(locator: Locator): Promise<void> {
/**
* Get the scroll position of an element
*/
export async function getScrollPosition(locator: Locator): Promise<{ scrollTop: number; scrollHeight: number; clientHeight: number }> {
export async function getScrollPosition(
locator: Locator
): Promise<{ scrollTop: number; scrollHeight: number; clientHeight: number }> {
return await locator.evaluate((el) => ({
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
@@ -2088,7 +2147,13 @@ export async function setupMockProjectWithAgentOutput(
outputContent: string
): Promise<void> {
await page.addInitScript(
({ featureId, outputContent }: { featureId: string; outputContent: string }) => {
({
featureId,
outputContent,
}: {
featureId: string;
outputContent: string;
}) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
@@ -2113,9 +2178,10 @@ export async function setupMockProjectWithAgentOutput(
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Set up mock file system with output content for the feature
// Now uses features/{id}/agent-output.md path
(window as any).__mockContextFile = {
featureId,
path: `/mock/test-project/.automaker/agents-context/${featureId}.md`,
path: `/mock/test-project/.automaker/features/${featureId}/agent-output.md`,
content: outputContent,
};
},
@@ -2234,7 +2300,9 @@ export async function getWaitingApprovalColumn(page: Page): Promise<Locator> {
/**
* Check if the waiting_approval column is visible
*/
export async function isWaitingApprovalColumnVisible(page: Page): Promise<boolean> {
export async function isWaitingApprovalColumnVisible(
page: Page
): Promise<boolean> {
const column = page.locator('[data-testid="kanban-column-waiting_approval"]');
return await column.isVisible().catch(() => false);
}
@@ -2242,14 +2310,18 @@ export async function isWaitingApprovalColumnVisible(page: Page): Promise<boolea
/**
* Get the agent output modal description element
*/
export async function getAgentOutputModalDescriptionElement(page: Page): Promise<Locator> {
export async function getAgentOutputModalDescriptionElement(
page: Page
): Promise<Locator> {
return page.locator('[data-testid="agent-output-description"]');
}
/**
* Check if the agent output modal description is scrollable
*/
export async function isAgentOutputDescriptionScrollable(page: Page): Promise<boolean> {
export async function isAgentOutputDescriptionScrollable(
page: Page
): Promise<boolean> {
const description = page.locator('[data-testid="agent-output-description"]');
const scrollInfo = await description.evaluate((el) => {
return {
@@ -2264,7 +2336,9 @@ export async function isAgentOutputDescriptionScrollable(page: Page): Promise<bo
/**
* Get scroll dimensions of the agent output modal description
*/
export async function getAgentOutputDescriptionScrollDimensions(page: Page): Promise<{
export async function getAgentOutputDescriptionScrollDimensions(
page: Page
): Promise<{
scrollHeight: number;
clientHeight: number;
maxHeight: string;
@@ -2301,42 +2375,39 @@ export async function setupMockProjectWithWaitingApprovalFeatures(
}>;
}
): Promise<void> {
await page.addInitScript(
(opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
await page.addInitScript((opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
const mockFeatures = opts?.features || [];
const mockFeatures = opts?.features || [];
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Also store features in a global variable that the mock electron API can use
(window as any).__mockFeatures = mockFeatures;
},
options
);
// Also store features in a global variable that the mock electron API can use
(window as any).__mockFeatures = mockFeatures;
}, options);
}
// ============================================================================
@@ -2478,7 +2549,10 @@ export async function clickSetupFinish(page: Page): Promise<void> {
/**
* Enter Anthropic API key in setup
*/
export async function enterAnthropicApiKey(page: Page, apiKey: string): Promise<void> {
export async function enterAnthropicApiKey(
page: Page,
apiKey: string
): Promise<void> {
// Click "Use Anthropic API Key Instead" button
const useApiKeyButton = await getByTestId(page, "use-api-key-button");
await useApiKeyButton.click();
@@ -2495,7 +2569,10 @@ export async function enterAnthropicApiKey(page: Page, apiKey: string): Promise<
/**
* Enter OpenAI API key in setup
*/
export async function enterOpenAIApiKey(page: Page, apiKey: string): Promise<void> {
export async function enterOpenAIApiKey(
page: Page,
apiKey: string
): Promise<void> {
// Click "Enter OpenAI API Key" button
const useApiKeyButton = await getByTestId(page, "use-openai-key-button");
await useApiKeyButton.click();

10
marketing/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM nginx:alpine
# Copy the public directory to nginx's html directory
COPY public/ /usr/share/nginx/html/
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

412
marketing/public/index.html Normal file
View File

@@ -0,0 +1,412 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Automaker - Autonomous AI Development Studio</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #6366f1;
--primary-dark: #4f46e5;
--secondary: #8b5cf6;
--accent: #ec4899;
--dark: #0f172a;
--dark-light: #1e293b;
--text: #e2e8f0;
--text-muted: #94a3b8;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
color: var(--text);
line-height: 1.6;
overflow-x: hidden;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
/* Header */
header {
padding: 2rem 0;
position: sticky;
top: 0;
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(10px);
z-index: 100;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
nav {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-links {
display: flex;
gap: 2rem;
list-style: none;
}
.nav-links a {
color: var(--text-muted);
text-decoration: none;
transition: color 0.3s;
}
.nav-links a:hover {
color: var(--text);
}
/* Hero Section */
.hero {
padding: 6rem 0;
text-align: center;
position: relative;
}
.hero::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(99, 102, 241, 0.1) 0%, transparent 70%);
border-radius: 50%;
z-index: -1;
}
.hero h1 {
font-size: 4rem;
font-weight: 800;
margin-bottom: 1.5rem;
background: linear-gradient(135deg, #ffffff 0%, var(--text-muted) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1.2;
}
.hero p {
font-size: 1.5rem;
color: var(--text-muted);
margin-bottom: 2rem;
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
.cta-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 1rem 2rem;
border-radius: 0.5rem;
text-decoration: none;
font-weight: 600;
transition: all 0.3s;
display: inline-block;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), var(--secondary));
color: white;
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.6);
}
.btn-secondary {
background: rgba(148, 163, 184, 0.1);
color: var(--text);
border: 1px solid rgba(148, 163, 184, 0.2);
}
.btn-secondary:hover {
background: rgba(148, 163, 184, 0.2);
transform: translateY(-2px);
}
/* Features Section */
.features {
padding: 6rem 0;
}
.section-title {
text-align: center;
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 1rem;
}
.section-subtitle {
text-align: center;
color: var(--text-muted);
font-size: 1.2rem;
margin-bottom: 4rem;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-top: 3rem;
}
.feature-card {
background: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 1rem;
padding: 2rem;
transition: all 0.3s;
}
.feature-card:hover {
transform: translateY(-5px);
border-color: rgba(99, 102, 241, 0.5);
box-shadow: 0 10px 30px rgba(99, 102, 241, 0.2);
}
.feature-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
display: block;
}
.feature-card h3 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
color: var(--text);
}
.feature-card p {
color: var(--text-muted);
line-height: 1.6;
}
/* Tech Stack Section */
.tech-stack {
padding: 6rem 0;
background: rgba(15, 23, 42, 0.5);
}
.tech-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2rem;
margin-top: 3rem;
}
.tech-item {
background: rgba(30, 41, 59, 0.5);
padding: 1rem 2rem;
border-radius: 0.5rem;
border: 1px solid rgba(148, 163, 184, 0.1);
transition: all 0.3s;
}
.tech-item:hover {
border-color: var(--primary);
transform: scale(1.05);
}
/* Footer */
footer {
padding: 3rem 0;
text-align: center;
border-top: 1px solid rgba(148, 163, 184, 0.1);
color: var(--text-muted);
}
footer a {
color: var(--primary);
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media (max-width: 768px) {
.hero h1 {
font-size: 2.5rem;
}
.hero p {
font-size: 1.2rem;
}
.nav-links {
display: none;
}
.features-grid {
grid-template-columns: 1fr;
}
}
/* Animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.feature-card {
animation: fadeInUp 0.6s ease-out;
}
.feature-card:nth-child(1) { animation-delay: 0.1s; }
.feature-card:nth-child(2) { animation-delay: 0.2s; }
.feature-card:nth-child(3) { animation-delay: 0.3s; }
.feature-card:nth-child(4) { animation-delay: 0.4s; }
.feature-card:nth-child(5) { animation-delay: 0.5s; }
.feature-card:nth-child(6) { animation-delay: 0.6s; }
</style>
</head>
<body>
<header>
<nav class="container">
<div class="logo">Automaker</div>
<ul class="nav-links">
<li><a href="#features">Features</a></li>
<li><a href="#tech">Tech Stack</a></li>
<li><a href="https://github.com/AutoMaker-Org/automaker" target="_blank">GitHub</a></li>
</ul>
</nav>
</header>
<main>
<section class="hero">
<div class="container">
<h1>Build Software Faster with AI-Powered Agents</h1>
<p>Automaker is an autonomous AI development studio that helps you build software faster using AI-powered agents. Manage features visually, assign AI agents automatically, and track progress through an intuitive workflow.</p>
<div class="cta-buttons">
<a href="https://github.com/AutoMaker-Org/automaker" class="btn btn-primary" target="_blank">View on GitHub</a>
<a href="https://github.com/AutoMaker-Org/automaker#getting-started" class="btn btn-secondary" target="_blank">Get Started</a>
</div>
</div>
</section>
<section class="features" id="features">
<div class="container">
<h2 class="section-title">Powerful Features</h2>
<p class="section-subtitle">Everything you need to accelerate your development workflow</p>
<div class="features-grid">
<div class="feature-card">
<span class="feature-icon">📋</span>
<h3>Kanban Board</h3>
<p>Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages.</p>
</div>
<div class="feature-card">
<span class="feature-icon">🤖</span>
<h3>AI Agent Integration</h3>
<p>Automatic AI agent assignment to implement features when moved to "In Progress".</p>
</div>
<div class="feature-card">
<span class="feature-icon">🧠</span>
<h3>Multi-Model Support</h3>
<p>Choose from multiple AI models including Claude Opus, Sonnet, and more.</p>
</div>
<div class="feature-card">
<span class="feature-icon">📡</span>
<h3>Real-time Agent Output</h3>
<p>View live agent output, logs, and file diffs as features are being implemented.</p>
</div>
<div class="feature-card">
<span class="feature-icon">🔍</span>
<h3>Project Analysis</h3>
<p>AI-powered project structure analysis to understand your codebase.</p>
</div>
<div class="feature-card">
<span class="feature-icon">💡</span>
<h3>Feature Suggestions</h3>
<p>AI-generated feature suggestions based on your project.</p>
</div>
<div class="feature-card">
<span class="feature-icon"></span>
<h3>Concurrent Processing</h3>
<p>Configure concurrency to process multiple features simultaneously.</p>
</div>
<div class="feature-card">
<span class="feature-icon">🔀</span>
<h3>Git Integration</h3>
<p>View git diffs and track changes made by AI agents.</p>
</div>
<div class="feature-card">
<span class="feature-icon">🖥️</span>
<h3>Cross-Platform</h3>
<p>Desktop application built with Electron for Windows, macOS, and Linux.</p>
</div>
</div>
</div>
</section>
<section class="tech-stack" id="tech">
<div class="container">
<h2 class="section-title">Built With Modern Technology</h2>
<p class="section-subtitle">Powered by the best tools in the industry</p>
<div class="tech-grid">
<div class="tech-item">Next.js</div>
<div class="tech-item">Electron</div>
<div class="tech-item">React</div>
<div class="tech-item">Tailwind CSS</div>
<div class="tech-item">Zustand</div>
<div class="tech-item">dnd-kit</div>
<div class="tech-item">TypeScript</div>
<div class="tech-item">Claude AI</div>
</div>
</div>
</section>
</main>
<footer>
<div class="container">
<p>Made with ❤️ by <a href="mailto:webdevcody@gmail.com">Cody Seibert</a></p>
<p style="margin-top: 1rem;">
<a href="https://github.com/AutoMaker-Org/automaker" target="_blank">GitHub</a> |
<a href="https://github.com/AutoMaker-Org/automaker/blob/main/LICENSE" target="_blank">License</a>
</p>
<p style="margin-top: 1rem; font-size: 0.9rem; color: var(--text-muted);">
⚠️ <strong>Security Disclaimer:</strong> This software uses AI-powered tooling that has access to your operating system. Use at your own risk. We recommend running Automaker in a sandboxed environment.
</p>
</div>
</footer>
</body>
</html>

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "automaker",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}