mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 22:32:04 +00:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfc6be9589 | ||
|
|
43c90adbc0 | ||
|
|
5ac81ce5a9 | ||
|
|
085f5d5d39 | ||
|
|
08de89344c | ||
|
|
6ee50853dc | ||
|
|
d1b97a46a4 | ||
|
|
def02444db | ||
|
|
802e8812f0 | ||
|
|
4763355987 | ||
|
|
d9e459fdfb | ||
|
|
15981c8e1b | ||
|
|
772b0e9e5c | ||
|
|
82cc8abd29 | ||
|
|
ac3ea90950 | ||
|
|
215ae87950 | ||
|
|
f71d6da37d | ||
|
|
d8f55f26db | ||
|
|
8010a03a7c | ||
|
|
60fc043b1e | ||
|
|
6bbcc36409 | ||
|
|
2d937bc47f | ||
|
|
45bd2c64b9 | ||
|
|
2afb5a7645 | ||
|
|
bd1ae73bb9 | ||
|
|
7e3819da4b | ||
|
|
9af6866a9d | ||
|
|
da78bed47d | ||
|
|
9954563581 | ||
|
|
a4dc21fd84 | ||
|
|
d5d6cdf80f | ||
|
|
6086d22a44 | ||
|
|
8a6309ccc9 | ||
|
|
0d462ba080 | ||
|
|
7886a29089 | ||
|
|
a6da65e318 | ||
|
|
7cf9a9f11a | ||
|
|
0bfe77f9f1 | ||
|
|
344651a981 |
13
.automaker/.gitignore
vendored
13
.automaker/.gitignore
vendored
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
"Kanban",
|
||||
"Other",
|
||||
"Settings",
|
||||
"Uncategorized"
|
||||
"Uncategorized",
|
||||
"ka"
|
||||
]
|
||||
@@ -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.10 PM.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.15 PM.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.47 PM.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.26 PM.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.32 PM.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.07 PM.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.04 PM.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.52 PM.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"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"category": "Core",
|
||||
"description": "do nothing, code nothing, print yolo",
|
||||
"steps": [],
|
||||
"status": "waiting_approval",
|
||||
"images": [],
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"model": "opus",
|
||||
"thinkingLevel": "none",
|
||||
"id": "feature-1765414180387-4zcc7wpdv",
|
||||
"startedAt": "2025-12-11T00:49:41.713Z",
|
||||
"summary": "No code changes required. Feature requested 'do nothing, code nothing, print yolo' - completed as specified. YOLO!"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
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
@@ -441,20 +441,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 +451,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 +463,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
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ class AutoModeService {
|
||||
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,
|
||||
|
||||
@@ -819,7 +819,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 +836,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);
|
||||
@@ -1389,7 +1394,10 @@ 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;
|
||||
@@ -1424,7 +1432,7 @@ ipcMain.handle("setup:install-claude", async (event) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("setup:install-progress", {
|
||||
cli: "claude",
|
||||
...progress
|
||||
...progress,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1448,7 +1456,7 @@ ipcMain.handle("setup:install-codex", async (event) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("setup:install-progress", {
|
||||
cli: "codex",
|
||||
...progress
|
||||
...progress,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1472,7 +1480,7 @@ ipcMain.handle("setup:auth-claude", async (event) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("setup:auth-progress", {
|
||||
cli: "claude",
|
||||
...progress
|
||||
...progress,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1496,7 +1504,7 @@ ipcMain.handle("setup:auth-codex", async (event, { apiKey }) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("setup:auth-progress", {
|
||||
cli: "codex",
|
||||
...progress
|
||||
...progress,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1532,7 +1540,11 @@ 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 };
|
||||
@@ -1559,7 +1571,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 +1579,7 @@ ipcMain.handle("setup:get-api-keys", async () => {
|
||||
hasAnthropicKey: false,
|
||||
hasAnthropicOAuthToken: false,
|
||||
hasOpenAIKey: false,
|
||||
hasGoogleKey: false
|
||||
hasGoogleKey: false,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1582,9 +1594,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 +1624,155 @@ 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 };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -24,7 +24,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 +39,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 +73,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,8 +92,7 @@ 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
|
||||
@@ -98,19 +109,32 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
|
||||
// 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 +146,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 +199,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 +214,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 +222,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)
|
||||
@@ -229,11 +268,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 +351,37 @@ 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 }),
|
||||
},
|
||||
});
|
||||
|
||||
// Also expose a flag to detect if we're in Electron
|
||||
|
||||
@@ -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,47 @@ 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');
|
||||
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 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,26 +70,33 @@ class ClaudeCliDetector {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return [...new Set(commonPaths)]; // Remove duplicates
|
||||
}
|
||||
|
||||
static detectClaudeInstallation() {
|
||||
console.log('[ClaudeCliDetector] Detecting Claude installation...');
|
||||
console.log("[ClaudeCliDetector] Detecting Claude installation...");
|
||||
|
||||
try {
|
||||
// Method 1: Check if 'claude' command is in PATH (Unix)
|
||||
if (process.platform !== 'win32') {
|
||||
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);
|
||||
console.log(
|
||||
"[ClaudeCliDetector] Found claude at:",
|
||||
claudePath,
|
||||
"version:",
|
||||
version
|
||||
);
|
||||
return {
|
||||
installed: true,
|
||||
path: claudePath,
|
||||
version: version,
|
||||
method: 'cli'
|
||||
method: "cli",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -92,17 +105,26 @@ class ClaudeCliDetector {
|
||||
}
|
||||
|
||||
// Method 2: Check Windows path
|
||||
if (process.platform === 'win32') {
|
||||
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);
|
||||
console.log(
|
||||
"[ClaudeCliDetector] Found claude at:",
|
||||
claudePath,
|
||||
"version:",
|
||||
version
|
||||
);
|
||||
return {
|
||||
installed: true,
|
||||
path: claudePath,
|
||||
version: version,
|
||||
method: 'cli'
|
||||
method: "cli",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -111,34 +133,49 @@ class ClaudeCliDetector {
|
||||
}
|
||||
|
||||
// Method 3: Check for local installation
|
||||
const localClaudePath = path.join(os.homedir(), '.claude', 'local', 'claude');
|
||||
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);
|
||||
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)
|
||||
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);
|
||||
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
|
||||
@@ -148,29 +185,37 @@ class ClaudeCliDetector {
|
||||
}
|
||||
|
||||
// Method 5: Try to source shell config and check PATH again (for Unix)
|
||||
if (process.platform !== 'win32') {
|
||||
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);
|
||||
console.log(
|
||||
"[ClaudeCliDetector] Found claude via shell config at:",
|
||||
claudePath,
|
||||
"version:",
|
||||
version
|
||||
);
|
||||
return {
|
||||
installed: true,
|
||||
path: claudePath,
|
||||
version: version,
|
||||
method: 'cli'
|
||||
method: "cli",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -179,21 +224,24 @@ class ClaudeCliDetector {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[ClaudeCliDetector] Claude CLI not found');
|
||||
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);
|
||||
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 +254,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,10 +274,10 @@ class ClaudeCliDetector {
|
||||
* @returns {Object} Authentication status
|
||||
*/
|
||||
static getAuthStatus(appCredentialsPath) {
|
||||
console.log('[ClaudeCliDetector] Checking auth status...');
|
||||
console.log("[ClaudeCliDetector] Checking auth status...");
|
||||
|
||||
const envApiKey = process.env.ANTHROPIC_API_KEY;
|
||||
console.log('[ClaudeCliDetector] Env ANTHROPIC_API_KEY:', !!envApiKey);
|
||||
console.log("[ClaudeCliDetector] Env ANTHROPIC_API_KEY:", !!envApiKey);
|
||||
|
||||
// Check app's stored credentials
|
||||
let storedOAuthToken = null;
|
||||
@@ -237,38 +285,44 @@ class ClaudeCliDetector {
|
||||
|
||||
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:', {
|
||||
storedApiKey =
|
||||
credentials.anthropic || credentials.anthropic_api_key || null;
|
||||
console.log("[ClaudeCliDetector] App credentials:", {
|
||||
hasOAuthToken: !!storedOAuthToken,
|
||||
hasApiKey: !!storedApiKey
|
||||
hasApiKey: !!storedApiKey,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ClaudeCliDetector] Error reading app credentials:', error);
|
||||
console.error(
|
||||
"[ClaudeCliDetector] Error reading app credentials:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine authentication method
|
||||
// Priority: Stored OAuth Token > Stored API Key > Env API Key
|
||||
let authenticated = false;
|
||||
let method = 'none';
|
||||
let method = "none";
|
||||
|
||||
if (storedOAuthToken) {
|
||||
authenticated = true;
|
||||
method = 'oauth_token';
|
||||
console.log('[ClaudeCliDetector] Using stored OAuth token (subscription)');
|
||||
method = "oauth_token";
|
||||
console.log(
|
||||
"[ClaudeCliDetector] Using stored OAuth token (subscription)"
|
||||
);
|
||||
} else if (storedApiKey) {
|
||||
authenticated = true;
|
||||
method = 'api_key';
|
||||
console.log('[ClaudeCliDetector] Using stored API key');
|
||||
method = "api_key";
|
||||
console.log("[ClaudeCliDetector] Using stored API key");
|
||||
} else if (envApiKey) {
|
||||
authenticated = true;
|
||||
method = 'api_key_env';
|
||||
console.log('[ClaudeCliDetector] Using environment API key');
|
||||
method = "api_key_env";
|
||||
console.log("[ClaudeCliDetector] Using environment API key");
|
||||
} else {
|
||||
console.log('[ClaudeCliDetector] No authentication found');
|
||||
console.log("[ClaudeCliDetector] No authentication found");
|
||||
}
|
||||
|
||||
const result = {
|
||||
@@ -276,12 +330,26 @@ class ClaudeCliDetector {
|
||||
method,
|
||||
hasStoredOAuthToken: !!storedOAuthToken,
|
||||
hasStoredApiKey: !!storedApiKey,
|
||||
hasEnvApiKey: !!envApiKey
|
||||
hasEnvApiKey: !!envApiKey,
|
||||
};
|
||||
|
||||
console.log('[ClaudeCliDetector] Auth status result:', result);
|
||||
console.log("[ClaudeCliDetector] Auth status result:", result);
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Get installation info (installation status only, no auth)
|
||||
* @returns {Object} Installation info with status property
|
||||
*/
|
||||
static getInstallationInfo() {
|
||||
const installation = this.detectClaudeInstallation();
|
||||
return {
|
||||
status: installation.installed ? "installed" : "not_installed",
|
||||
installed: installation.installed,
|
||||
path: installation.path,
|
||||
version: installation.version,
|
||||
method: installation.method,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full status including installation and auth
|
||||
@@ -294,12 +362,12 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -309,9 +377,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 +393,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 +471,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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,38 +72,109 @@ class CodexCliDetector {
|
||||
|
||||
// Check if auth file exists
|
||||
if (fs.existsSync(authPath)) {
|
||||
const content = fs.readFileSync(authPath, 'utf-8');
|
||||
const auth = JSON.parse(content);
|
||||
console.log('[CodexCliDetector] Auth file exists, reading content...');
|
||||
let auth = null;
|
||||
try {
|
||||
const content = fs.readFileSync(authPath, 'utf-8');
|
||||
auth = JSON.parse(content);
|
||||
console.log('[CodexCliDetector] Auth file content keys:', Object.keys(auth));
|
||||
console.log('[CodexCliDetector] Auth file has token object:', !!auth.token);
|
||||
if (auth.token) {
|
||||
console.log('[CodexCliDetector] Token object keys:', Object.keys(auth.token));
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return {
|
||||
// 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) {
|
||||
const result = {
|
||||
authenticated: true,
|
||||
method: 'cli_tokens', // Distinguish token-based auth from API key auth
|
||||
hasAuthFile: true,
|
||||
hasEnvKey: !!envApiKey,
|
||||
authPath
|
||||
};
|
||||
console.log('[CodexCliDetector] Auth result (cli_tokens):', result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for tokens at root level (alternative structure)
|
||||
if (auth.access_token || auth.refresh_token || auth.Id_token || auth.id_token) {
|
||||
const result = {
|
||||
authenticated: true,
|
||||
method: 'cli_tokens', // These are tokens, not API keys
|
||||
hasAuthFile: true,
|
||||
hasEnvKey: !!envApiKey,
|
||||
authPath
|
||||
};
|
||||
console.log('[CodexCliDetector] Auth result (cli_tokens - root level):', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check for various possible API key fields that codex might use
|
||||
// Note: access_token is NOT an API key, it's a token, so we check for it above
|
||||
if (auth.api_key || auth.openai_api_key || auth.apiKey) {
|
||||
const result = {
|
||||
authenticated: true,
|
||||
method: 'auth_file',
|
||||
hasAuthFile: true,
|
||||
hasEnvKey: !!envApiKey,
|
||||
authPath
|
||||
};
|
||||
console.log('[CodexCliDetector] Auth result (auth_file - API key):', result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for various possible auth fields that codex might use
|
||||
if (auth.api_key || auth.openai_api_key || auth.access_token || auth.apiKey) {
|
||||
} catch (error) {
|
||||
console.error('[CodexCliDetector] Error reading/parsing auth file:', error.message);
|
||||
// If we can't parse the file, we can't determine auth status
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'auth_file',
|
||||
hasAuthFile: true,
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
hasAuthFile: false,
|
||||
hasEnvKey: !!envApiKey,
|
||||
authPath
|
||||
};
|
||||
}
|
||||
|
||||
// Also check if the file has any meaningful content (non-empty object)
|
||||
// This is a fallback - but we should still try to detect if it's tokens
|
||||
if (!auth) {
|
||||
// File exists but couldn't be parsed
|
||||
return {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
hasAuthFile: true,
|
||||
hasEnvKey: !!envApiKey,
|
||||
authPath
|
||||
};
|
||||
}
|
||||
|
||||
const keys = Object.keys(auth);
|
||||
console.log('[CodexCliDetector] File has content, keys:', keys);
|
||||
if (keys.length > 0) {
|
||||
// Check again for tokens in case we missed them (maybe nested differently)
|
||||
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) {
|
||||
const result = {
|
||||
authenticated: true,
|
||||
method: 'cli_tokens',
|
||||
hasAuthFile: true,
|
||||
hasEnvKey: !!envApiKey,
|
||||
authPath
|
||||
};
|
||||
console.log('[CodexCliDetector] Auth result (cli_tokens - fallback detection):', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// File exists and has content, likely authenticated
|
||||
// Try to verify by checking if codex command works
|
||||
try {
|
||||
@@ -116,34 +187,45 @@ class CodexCliDetector {
|
||||
timeout: 3000
|
||||
});
|
||||
// If command succeeds, assume authenticated
|
||||
return {
|
||||
// But check if it's likely tokens vs API key based on file structure
|
||||
const likelyTokens = keys.some(key => key.toLowerCase().includes('token') || key.toLowerCase().includes('refresh'));
|
||||
const result = {
|
||||
authenticated: true,
|
||||
method: 'auth_file',
|
||||
method: likelyTokens ? 'cli_tokens' : 'auth_file',
|
||||
hasAuthFile: true,
|
||||
hasEnvKey: !!envApiKey,
|
||||
authPath
|
||||
};
|
||||
console.log('[CodexCliDetector] Auth result (verified via CLI, method:', result.method, '):', result);
|
||||
return result;
|
||||
} catch (cmdError) {
|
||||
// Command failed, but file exists - might still be authenticated
|
||||
// Return authenticated if file has content
|
||||
return {
|
||||
// Check if it's likely tokens
|
||||
const likelyTokens = keys.some(key => key.toLowerCase().includes('token') || key.toLowerCase().includes('refresh'));
|
||||
const result = {
|
||||
authenticated: true,
|
||||
method: 'auth_file',
|
||||
method: likelyTokens ? 'cli_tokens' : 'auth_file',
|
||||
hasAuthFile: true,
|
||||
hasEnvKey: !!envApiKey,
|
||||
authPath
|
||||
};
|
||||
console.log('[CodexCliDetector] Auth result (file exists, method:', result.method, '):', result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} catch (verifyError) {
|
||||
// Verification failed, but file exists with content
|
||||
return {
|
||||
// Check if it's likely tokens
|
||||
const likelyTokens = keys.some(key => key.toLowerCase().includes('token') || key.toLowerCase().includes('refresh'));
|
||||
const result = {
|
||||
authenticated: true,
|
||||
method: 'auth_file',
|
||||
method: likelyTokens ? 'cli_tokens' : 'auth_file',
|
||||
hasAuthFile: true,
|
||||
hasEnvKey: !!envApiKey,
|
||||
authPath
|
||||
};
|
||||
console.log('[CodexCliDetector] Auth result (fallback, method:', result.method, '):', result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,3 +349,5 @@ class CodexConfigManager {
|
||||
}
|
||||
|
||||
module.exports = new CodexConfigManager();
|
||||
|
||||
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
73
app/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -24,6 +24,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 +32,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",
|
||||
|
||||
BIN
app/public/sounds/ding.mp3
Normal file
BIN
app/public/sounds/ding.mp3
Normal file
Binary file not shown.
@@ -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";
|
||||
@@ -20,6 +20,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
|
||||
@@ -162,7 +201,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 +211,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -393,7 +393,8 @@ 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)
|
||||
@@ -722,7 +723,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>
|
||||
@@ -779,8 +780,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 +820,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>
|
||||
@@ -958,14 +959,14 @@ export function Sidebar() {
|
||||
<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">
|
||||
<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">
|
||||
@@ -1049,13 +1050,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 */}
|
||||
@@ -1115,13 +1116,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,7 +1272,7 @@ export function Sidebar() {
|
||||
Generate feature list
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Automatically populate feature_list.json with all features
|
||||
Automatically create features in the features folder
|
||||
from the implementation roadmap after the spec is generated.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
184
app/src/components/ui/command.tsx
Normal file
184
app/src/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
639
app/src/components/ui/keyboard-map.tsx
Normal file
639
app/src/components/ui/keyboard-map.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
48
app/src/components/ui/popover.tsx
Normal file
48
app/src/components/ui/popover.tsx
Normal 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 }
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -594,11 +594,11 @@ export function AgentView() {
|
||||
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">
|
||||
<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"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -409,7 +409,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 +755,12 @@ ${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
|
||||
for (const feature of detectedFeatures) {
|
||||
await api.features.create(currentProject.path, feature);
|
||||
}
|
||||
|
||||
setFeatureListGenerated(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate feature list:", error);
|
||||
setFeatureListError(
|
||||
@@ -1041,7 +1030,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 +1063,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 && (
|
||||
|
||||
@@ -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) {
|
||||
@@ -529,6 +536,9 @@ export function BoardView() {
|
||||
// Reload features when a feature is completed
|
||||
console.log("[Board] Feature completed, reloading features...");
|
||||
loadFeatures();
|
||||
// Play ding sound when feature is done
|
||||
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(
|
||||
@@ -627,41 +637,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 +734,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 +790,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 +808,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 +818,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 +834,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 +842,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 +857,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 +902,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 +940,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 +985,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 +1026,7 @@ export function BoardView() {
|
||||
|
||||
// Remove the feature immediately without confirmation
|
||||
removeFeature(featureId);
|
||||
persistFeatureDelete(featureId);
|
||||
};
|
||||
|
||||
const handleRunFeature = async (feature: Feature) => {
|
||||
@@ -1056,6 +1139,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 +1153,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 +1205,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 +1269,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 +1299,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 +1517,7 @@ export function BoardView() {
|
||||
|
||||
if (targetStatus !== feature.status) {
|
||||
moveFeature(feature.id, targetStatus);
|
||||
persistFeatureUpdate(feature.id, { status: targetStatus });
|
||||
}
|
||||
|
||||
toast.success("Agent stopped", {
|
||||
@@ -1473,10 +1565,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);
|
||||
}
|
||||
@@ -1885,7 +1979,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 +2387,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 +2437,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 +2806,7 @@ export function BoardView() {
|
||||
onClose={() => setShowOutputModal(false)}
|
||||
featureDescription={outputFeature?.description || ""}
|
||||
featureId={outputFeature?.id || ""}
|
||||
featureStatus={outputFeature?.status}
|
||||
onNumberKeyPress={handleOutputModalNumberKeyPress}
|
||||
/>
|
||||
|
||||
@@ -2720,12 +2858,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 +2875,7 @@ export function BoardView() {
|
||||
|
||||
// Remove the feature
|
||||
removeFeature(feature.id);
|
||||
persistFeatureDelete(feature.id);
|
||||
}
|
||||
|
||||
setShowDeleteAllVerifiedDialog(false);
|
||||
|
||||
@@ -202,12 +202,13 @@ 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
|
||||
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);
|
||||
|
||||
@@ -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,20 @@ 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 = {
|
||||
id: `feature-${Date.now()}-0`,
|
||||
category: "Core",
|
||||
description: "Initial project setup",
|
||||
status: "backlog",
|
||||
steps: [
|
||||
"Step 1: Review app_spec.txt",
|
||||
"Step 2: Set up development environment",
|
||||
"Step 3: Start implementing features",
|
||||
],
|
||||
skipTests: true,
|
||||
};
|
||||
await api.features.create(fullProjectPath, initialFeature);
|
||||
|
||||
const project = {
|
||||
id: `project-${Date.now()}`,
|
||||
@@ -353,7 +359,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 +438,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"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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,16 +234,19 @@ 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 && (
|
||||
@@ -323,6 +344,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 +391,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 +554,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,6 +608,7 @@ 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" />
|
||||
@@ -572,6 +624,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 +645,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 +660,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 +675,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 +691,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 +712,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 +736,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 +758,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 +775,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 +793,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 +815,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleCancelDelete}
|
||||
@@ -761,13 +823,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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
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-muted-foreground">
|
||||
Method:{" "}
|
||||
<span className="font-mono text-foreground">
|
||||
{claudeAuthStatus.method === "oauth"
|
||||
? "OAuth Token"
|
||||
: claudeAuthStatus.method === "api_key"
|
||||
? "API Key"
|
||||
: "Unknown"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{claudeAuthStatus.oauthTokenValid && (
|
||||
<div className="flex items-center gap-2 text-green-400">
|
||||
<CheckCircle2 className="w-3 h-3 shrink-0" />
|
||||
<span>OAuth token configured</span>
|
||||
</div>
|
||||
)}
|
||||
{claudeAuthStatus.apiKeyValid && (
|
||||
<div className="flex items-center gap-2 text-green-400">
|
||||
<CheckCircle2 className="w-3 h-3 shrink-0" />
|
||||
<span>API key configured</span>
|
||||
</div>
|
||||
)}
|
||||
{apiKeyStatus?.hasAnthropicKey && (
|
||||
<div className="flex items-center gap-2 text-blue-400">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>Environment variable detected</span>
|
||||
</div>
|
||||
)}
|
||||
{apiKeys.anthropic && (
|
||||
<div className="flex items-center gap-2 text-blue-400">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>Manual API key in settings</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-muted-foreground py-0.5">
|
||||
<AlertCircle className="w-2.5 h-2.5 shrink-0" />
|
||||
<span className="text-xs">Not Setup</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-muted-foreground">
|
||||
Method:{" "}
|
||||
<span className="font-mono text-foreground">
|
||||
{codexAuthStatus.method === "cli_verified" ||
|
||||
codexAuthStatus.method === "cli_tokens"
|
||||
? "CLI Login (OpenAI Account)"
|
||||
: codexAuthStatus.method === "api_key"
|
||||
? "API Key (Auth File)"
|
||||
: codexAuthStatus.method === "env"
|
||||
? "API Key (Environment)"
|
||||
: "Unknown"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{codexAuthStatus.method === "cli_verified" ||
|
||||
codexAuthStatus.method === "cli_tokens" ? (
|
||||
<div className="flex items-center gap-2 text-green-400">
|
||||
<CheckCircle2 className="w-3 h-3 shrink-0" />
|
||||
<span>Account authenticated</span>
|
||||
</div>
|
||||
) : codexAuthStatus.apiKeyValid ? (
|
||||
<div className="flex items-center gap-2 text-green-400">
|
||||
<CheckCircle2 className="w-3 h-3 shrink-0" />
|
||||
<span>API key configured</span>
|
||||
</div>
|
||||
) : null}
|
||||
{apiKeyStatus?.hasOpenAIKey && (
|
||||
<div className="flex items-center gap-2 text-blue-400">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>Environment variable detected</span>
|
||||
</div>
|
||||
)}
|
||||
{apiKeys.openai && (
|
||||
<div className="flex items-center gap-2 text-blue-400">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>Manual API key in settings</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-muted-foreground py-0.5">
|
||||
<AlertCircle className="w-2.5 h-2.5 shrink-0" />
|
||||
<span className="text-xs">Not Setup</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 text-blue-400">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>Using environment variable (GOOGLE_API_KEY)</span>
|
||||
</div>
|
||||
) : apiKeys.google ? (
|
||||
<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-muted-foreground py-0.5">
|
||||
<AlertCircle className="w-2.5 h-2.5 shrink-0" />
|
||||
<span className="text-xs">Not Setup</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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 "@/store/app-store";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Project } from "@/store/app-store";
|
||||
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>
|
||||
);
|
||||
}
|
||||
29
app/src/components/views/settings-view/config/navigation.ts
Normal file
29
app/src/components/views/settings-view/config/navigation.ts
Normal 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 },
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
169
app/src/components/views/settings-view/hooks/use-cli-status.ts
Normal file
169
app/src/components/views/settings-view/hooks/use-cli-status.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
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;
|
||||
const authStatus = {
|
||||
authenticated: auth.authenticated,
|
||||
method:
|
||||
auth.method === "oauth_token"
|
||||
? ("oauth" as const)
|
||||
: auth.method?.includes("api_key")
|
||||
? ("api_key" as const)
|
||||
: ("none" as const),
|
||||
hasCredentialsFile: auth.hasCredentialsFile ?? false,
|
||||
oauthTokenValid: auth.hasStoredOAuthToken,
|
||||
apiKeyValid: auth.hasStoredApiKey || 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "View Keyboard Map" 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>
|
||||
);
|
||||
}
|
||||
47
app/src/components/views/settings-view/shared/types.ts
Normal file
47
app/src/components/views/settings-view/shared/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -780,6 +780,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);
|
||||
@@ -805,13 +821,16 @@ 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");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
149
app/src/config/api-providers.ts
Normal file
149
app/src/config/api-providers.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
88
app/src/config/theme-options.ts
Normal file
88
app/src/config/theme-options.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
@@ -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) {
|
||||
|
||||
104
app/src/hooks/use-scroll-tracking.ts
Normal file
104
app/src/hooks/use-scroll-tracking.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -52,3 +52,5 @@ export function useWindowState(): WindowState {
|
||||
return windowState;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,11 +15,6 @@ 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
|
||||
@@ -28,12 +23,10 @@ const REQUIRED_STRUCTURE = {
|
||||
directories: [
|
||||
".automaker",
|
||||
".automaker/context",
|
||||
".automaker/agents-context",
|
||||
".automaker/features",
|
||||
".automaker/images",
|
||||
],
|
||||
files: {
|
||||
".automaker/feature_list.json": DEFAULT_FEATURE_LIST,
|
||||
},
|
||||
files: {},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -71,9 +64,9 @@ export async function initializeProject(
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +96,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 +131,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 +151,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: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,7 +37,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 +115,10 @@ export interface KeyboardShortcuts {
|
||||
tools: string;
|
||||
settings: string;
|
||||
profiles: string;
|
||||
|
||||
|
||||
// UI shortcuts
|
||||
toggleSidebar: string;
|
||||
|
||||
|
||||
// Action shortcuts
|
||||
addFeature: string;
|
||||
addContextFile: string;
|
||||
@@ -312,7 +380,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;
|
||||
|
||||
@@ -744,10 +814,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) => {
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface ClaudeAuthStatus {
|
||||
// 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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "automaker",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Reference in New Issue
Block a user