Compare commits

...

140 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
3b8b14b714 Initial plan 2025-12-16 00:28:05 +00:00
Cody Seibert
049f9a9e37 chore: add Git configuration for HTTPS in workflow files to support CI environment 2025-12-15 19:19:23 -05:00
Cody Seibert
19f1c32805 chore: update Node.js version in workflow files from 20 to 22 2025-12-15 19:08:00 -05:00
Cody Seibert
ece8ff8cbc Merge branch 'main' into api-key-redesign 2025-12-15 19:00:14 -05:00
Cody Seibert
a3a648aef1 feat: add Accordion component with customizable behavior and animations, update Checkbox and Slider components for improved functionality, and enhance package dependencies 2025-12-15 18:57:32 -05:00
Web Dev Cody
3bc2b74d30 Merge pull request #105 from AutoMaker-Org/fix/bug-button-position
Fix/bug button position
2025-12-15 17:57:08 -05:00
trueheads
123b471b68 How many Devs does it take to center a navbar icon? 3, as it turns out. 2025-12-15 15:13:43 -06:00
Cody Seibert
b66d228460 feat: enhance CLI and API key verification buttons to hide when already verified 2025-12-15 15:12:49 -05:00
Kacper
770d67d8c4 feat: refactor bug report button into a reusable component for improved sidebar functionality 2025-12-15 20:49:22 +01:00
Cody Seibert
d42857ec26 refactor: remove CLAUDE_CODE_OAUTH_TOKEN references and update authentication to use ANTHROPIC_API_KEY exclusively 2025-12-15 14:33:58 -05:00
Cody Seibert
54b977ee1b redesign our approach for api keys to not use claude setup-token 2025-12-15 14:24:18 -05:00
Kacper
e8999ba908 chore: update README to include a detailed Table of Contents and Community & Support section 2025-12-15 20:14:44 +01:00
Kacper
96c4383b29 feat: Whe sidebar is closed the bug button is overlapping the l... 2025-12-15 20:07:27 +01:00
Web Dev Cody
93d1d2c41a Merge pull request #104 from AutoMaker-Org/chore/update-readme
chore: update clone url from ssh to https
2025-12-15 13:52:58 -05:00
Shirone
b075af5bc9 chore: update clone url from ssh to https 2025-12-15 19:51:03 +01:00
Web Dev Cody
07ca7fccb8 Merge pull request #102 from AutoMaker-Org/feat/disable-worktree-in-ui
feat: In our Feature Defaults section in setting view we have a...
2025-12-15 12:47:31 -05:00
Web Dev Cody
797643ffdc Merge pull request #101 from AutoMaker-Org/readme-update
updating readme to reflect logo and featureset
2025-12-15 12:47:11 -05:00
trueheads
7d4052be95 adjustments 2025-12-15 11:24:01 -06:00
Shirone
1036719f2a Update apps/app/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-12-15 18:19:46 +01:00
Kacper
1ab520eda3 feat: In our Feature Defaults section in setting view we have a...
Implemented by Automaker auto-mode
2025-12-15 18:17:19 +01:00
trueheads
658f7d816e updating readme to reflect logo and featureset 2025-12-15 11:01:36 -06:00
Web Dev Cody
835ab516a6 Merge pull request #99 from AutoMaker-Org/feat/ai-profiles-view-enhancement
Enhanced AI profiles view with better UX and comprehensive test coverage.
2025-12-15 10:06:16 -05:00
Web Dev Cody
00e098e57d Merge pull request #97 from AutoMaker-Org/ui-tweaks
feat: implement completed features management in BoardView and Kanban…
2025-12-15 10:06:04 -05:00
Cody Seibert
f04cac8e2f style: refine sidebar and dropdown menu components for improved UI
- Simplified the sidebar button's class structure by removing unnecessary overflow styling.
- Enhanced the visual representation of the trashed projects count with updated styling for better visibility.
- Wrapped the dropdown menu's subcontent in a portal for improved rendering and performance.
2025-12-15 09:59:20 -05:00
Kacper
c1b9f1cb28 refactor: address code review suggestions
- Simplify countCustomProfiles by reusing getCustomProfiles helper
- Fix misleading test name and assertion for thinking level controls
2025-12-15 15:11:15 +01:00
Kacper
7d8670ff1f feat: add comprehensive tests for AI profiles view
- Introduced a new test suite for the AI profiles view, covering profile creation, editing, deletion, and reordering functionalities.
- Implemented tests for form validation, including checks for empty and whitespace-only profile names.
- Enhanced utility functions for profile interactions, including profile card retrieval and dialog management.
- Improved error handling in toast notifications for better test reliability.
- Updated test utilities to support the new profiles view structure.
2025-12-15 14:59:27 +01:00
Kacper
9f9bcaff65 feat: enhanced ai profiles view
- Refactored profiles view into modular components for better maintainability
- Fixed input/textarea borders showing consistently when not focused (border-input -> border-border)
- Added animated hover effects on profile cards (border color and icon animations)
- Removed redundant Create Profile button, made empty state interactive
- Added confirmation dialog for profile deletion to prevent accidental removal
- Improved dialog scrolling behavior with max-height constraints
- Added ARIA labels to profile card buttons for better accessibility
- Created reusable DeleteConfirmDialog component
2025-12-15 13:02:56 +01:00
Cody Seibert
25b1789b0a fix: update ProjectSetupDialog to correctly handle open state and improve BoardView layout
- Added missing onOpenChange call in ProjectSetupDialog to ensure proper state management.
- Reformatted the COLUMNS array in BoardView for improved readability and consistency.
- Adjusted DragOverlay component's formatting for better code clarity.
2025-12-15 01:13:37 -05:00
Cody Seibert
2c8add3b54 Merge branch 'main' into ui-tweaks 2025-12-15 01:13:30 -05:00
Cody Seibert
f25d62fe25 feat: implement project setup dialog and refactor sidebar integration
- Added a new ProjectSetupDialog component to facilitate project specification generation, enhancing user experience by guiding users through project setup.
- Refactored the Sidebar component to integrate the new ProjectSetupDialog, replacing the previous inline dialog implementation for improved code organization and maintainability.
- Updated the sidebar to handle project overview and feature generation options, streamlining the project setup process.
- Removed the old dialog implementation from the Sidebar, reducing code duplication and improving clarity.
2025-12-15 01:07:47 -05:00
Web Dev Cody
7a9f55e1bd Merge pull request #96 from AutoMaker-Org/polish-UI
style: enhance UI components with improved styling and layout
2025-12-15 00:55:09 -05:00
trueheads
fbdf1689b3 added logo.png to satisfy e2e github error 2025-12-14 22:44:48 -06:00
trueheads
29ba2c5936 adjusted the application icon and added support for mac/linux/win 2025-12-14 22:32:17 -06:00
trueheads
493050fba1 Logo SVG now matches color theme that user has selected 2025-12-14 22:01:55 -06:00
trueheads
455f6fa95b Fixed Logo with new SVG and alignment. Also fixed Agent Runner 'agent sessions' styling to remove rounded border 2025-12-14 21:42:47 -06:00
Web Dev Cody
e14957900e Merge pull request #98 from AutoMaker-Org/feat/new-e2e-test
Refactor: Restructure test utilities and add context view E2E tests
2025-12-14 22:13:49 -05:00
Kacper
c0e0f8d214 refactor: enhance spec editor tests by utilizing utility functions
- Replaced direct element selectors with utility functions for improved readability and maintainability in spec editor tests.
- Streamlined waiting mechanisms by using the new waitForElement function, enhancing test reliability.
- Updated test cases to ensure consistent handling of element visibility and initialization, resulting in more efficient and clearer tests.
2025-12-15 02:52:02 +01:00
Kacper
caae869501 refactor: improve context view tests by utilizing utility functions
- Replaced direct element locators with utility functions for better readability and maintainability in context view tests.
- Removed unnecessary wait statements and replaced them with appropriate utility functions to enhance test reliability.
- Streamlined the verification process for file visibility and content loading, ensuring tests are more efficient and easier to understand.
2025-12-15 02:49:08 +01:00
Kacper
b998d253bb chore: update .gitignore and remove server log file
- Updated .gitignore to exclude the new 'logs' directory.
- Removed the 'server.log' file from the logs directory to clean up unnecessary log data.
2025-12-15 02:40:47 +01:00
Kacper
0b1123e3ce refactor: restructure test utilities and enhance context view tests
- Refactored test utilities by consolidating and organizing helper functions into dedicated modules for better maintainability and clarity.
- Introduced new utility functions for interactions, waiting, and element retrieval, improving the readability of test cases.
- Updated context view tests to utilize the new utility functions, enhancing test reliability and reducing code duplication.
- Removed deprecated utility functions and ensured all tests are aligned with the new structure.
2025-12-15 02:40:09 +01:00
Kacper
a412f5d0fb feat: add context view tests and enhance context drop zone
- Introduced a new test suite for the Context View, covering file management, editing, and edge cases.
- Added a data-testid attribute to the context drop zone for improved testability.
- Implemented various tests for creating, editing, deleting, and uploading context files, ensuring robust functionality and user experience.
2025-12-15 02:39:56 +01:00
Cody Seibert
919e08689a refactor: streamline feature implementation handling in BoardView and KanbanCard
- Introduced a helper function, handleStartImplementation, to manage concurrency checks and feature status updates when moving features from backlog to in_progress.
- Simplified the onImplement callback in KanbanCard to utilize the new helper function, enhancing code readability and maintainability.
- Removed redundant concurrency checks from multiple locations, centralizing the logic for better consistency and reducing code duplication.
2025-12-14 20:21:42 -05:00
Cody Seibert
72e803b56d feat: implement completed features management in BoardView and KanbanCard
- Added functionality to complete and unarchive features, allowing users to manage feature statuses effectively.
- Introduced a modal to display completed features, enhancing user experience by providing a dedicated view for archived items.
- Updated KanbanCard to include buttons for completing features and managing their states, improving interactivity and workflow.
- Modified the Feature interface to include a new "completed" status, ensuring comprehensive state management across the application.
2025-12-14 20:06:52 -05:00
SuperComboGamer
e378704c63 style: enhance UI components with improved styling and layout
- Updated global CSS to include new status colors for better visual feedback.
- Refined button, badge, card, and input components with enhanced styles and transitions for a more polished user experience.
- Adjusted sidebar and dialog components for improved aesthetics and usability.
- Implemented gradient backgrounds and shadow effects across various sections to elevate the overall design.
- Enhanced keyboard shortcuts and settings views with consistent styling and layout adjustments for better accessibility.
2025-12-14 19:21:20 -05:00
Web Dev Cody
f6c50ce336 Merge pull request #95 from AutoMaker-Org/refactor-api-approach
refactoring the api endpoints to be separate files to reduce context …
2025-12-14 18:29:49 -05:00
Cody Seibert
063224966c refactor: update unit tests for setRunningState to use new state management
- Replaced direct access to state variables with calls to the new getSpecRegenerationStatus function in unit tests for setRunningState.
- This change improves encapsulation and ensures that tests reflect the updated state management logic.
2025-12-14 18:24:29 -05:00
Cody Seibert
5d40e694a5 docs: update Agentic Jumpstart course link in README
- Modified the link to the Agentic Jumpstart course to include a UTM parameter for better tracking of referral sources.
- This change enhances the documentation by providing a more effective way to analyze course engagement.
2025-12-14 18:20:00 -05:00
Cody Seibert
4405a97d9b fix: enhance error logging for JSON parsing in suggestions generation
- Added error logging for failed JSON parsing in the suggestions generation route to improve debugging capabilities.
- This change ensures that any parsing errors are captured and logged, aiding in the identification of issues with AI response handling.
2025-12-14 18:18:21 -05:00
Cody Seibert
6733de9e0d refactor: encapsulate state management for spec and suggestions generation
- Made the generation status variables private and introduced getter functions for both spec and suggestions generation states.
- Updated relevant route handlers to utilize the new getter functions, improving encapsulation and reducing direct access to shared state.
- Enhanced code maintainability by centralizing state management logic.
2025-12-14 18:18:11 -05:00
Cody Seibert
01bae7d43e refactor: centralize error handling utilities across route modules
- Introduced a new common utility module for error handling, providing consistent methods for retrieving error messages and logging errors.
- Updated individual route modules to utilize the shared error handling functions, reducing code duplication and improving maintainability.
- Ensured all routes now log errors in a standardized format, enhancing debugging and monitoring capabilities.
2025-12-14 17:59:16 -05:00
Cody Seibert
6b30271441 refactoring the api endpoints to be separate files to reduce context usage 2025-12-14 17:53:21 -05:00
Web Dev Cody
cdc8334d82 Merge pull request #94 from AutoMaker-Org/app_spec_fixes
working on improving the app spec page
2025-12-14 17:49:55 -05:00
Web Dev Cody
4a3a98b562 Merge pull request #90 from AutoMaker-Org/fix-agent-runner
feat: implement SDK session ID handling for conversation continuity
2025-12-14 17:49:40 -05:00
Cody Seibert
c280225a4e refactor: reorganize spec regeneration routes and add unit tests
- Removed the old spec regeneration routes and replaced them with a new structure under the app-spec directory for better modularity.
- Introduced unit tests for common functionalities in app-spec, covering state management and error handling.
- Added documentation on route organization patterns to improve maintainability and clarity for future development.
2025-12-14 17:45:11 -05:00
Cody Seibert
b3ea506a73 working on improving the app spec page 2025-12-14 17:38:12 -05:00
Web Dev Cody
590437c78b Merge pull request #93 from AutoMaker-Org/random-fixes
Random fixes
2025-12-14 14:50:31 -05:00
GTheMachine
790a1b8e20 Merge pull request #92 from AutoMaker-Org/copilot/fix-build-tests-issue
Fix test expectation for fs.readFile call count in agent-service.test.ts
2025-12-14 14:36:00 -05:00
Cody Seibert
fa47264c76 chore: update package-lock.json with new dependencies for CodeMirror and related libraries
- Added new dependencies for CodeMirror, including lang-xml, theme-one-dark, and various utilities to enhance the XML editing experience.
- Updated existing dependencies to their latest versions for improved functionality and security.
- Included additional modules for better code handling and syntax highlighting.
2025-12-14 14:19:12 -05:00
Cody Seibert
a4075fb637 Merge branch 'main' into random-fixes 2025-12-14 14:19:06 -05:00
Cody Seibert
20a7c8b5a8 feat: implement E2E testing workflow and enhance XML syntax editor
- Added a new GitHub Actions workflow for end-to-end (E2E) testing, including setup for Node.js, Playwright, and server initialization.
- Introduced a setup script for E2E test fixtures to create necessary directories and files.
- Integrated CodeMirror for XML syntax editing in the XmlSyntaxEditor component, improving code highlighting and editing experience.
- Updated package dependencies in package.json and package-lock.json to include new libraries for XML handling and theming.
- Refactored various components for improved readability and consistency, including the sidebar and file browser dialog.
- Added tests for spec editor persistence to ensure data integrity across sessions.
2025-12-14 14:12:38 -05:00
copilot-swe-agent[bot]
202494156b Fix test expectation for fs.readFile call count in agent-service.test.ts
The test "should reuse existing session if already started" expected fs.readFile to be called 1 time, but startConversation calls it 2 times on first call (loadSession + loadMetadata). The second call correctly reuses the in-memory session.

Co-authored-by: GTheMachine <156854865+GTheMachine@users.noreply.github.com>
2025-12-14 18:57:54 +00:00
copilot-swe-agent[bot]
7558fed4e4 Initial plan 2025-12-14 18:51:25 +00:00
SuperComboGamer
999ed5b51b fix storage for long term 2025-12-14 13:24:11 -05:00
Web Dev Cody
589155fa1c Merge pull request #89 from AutoMaker-Org/logging
chore: update dependencies and improve project structure
2025-12-14 12:42:58 -05:00
Cody Seibert
ae13551033 fixing the input box issue 2025-12-14 12:41:19 -05:00
Cody Seibert
038caeb2a0 test: update conversation history test to include sdkSessionId handling
- Renamed test case to clarify that it handles conversation history with sdkSessionId using the resume option.
- Updated assertions to verify that the sdk.query method is called with the correct options when a session ID is provided.
2025-12-14 11:10:57 -05:00
Cody Seibert
7b34c9a108 test: update security tests to allow all paths with permissions disabled
- Modified test cases in security.test.ts to reflect that all paths are allowed when permissions are disabled.
- Updated descriptions of test cases to clarify the new behavior regarding path validation and error handling.
2025-12-14 11:04:28 -05:00
SuperComboGamer
5a1fe23ddb feat: implement SDK session ID handling for conversation continuity
- Added support for resuming conversations using the Claude SDK session ID.
- Updated the ClaudeProvider to conditionally resume sessions based on the presence of a session ID and conversation history.
- Enhanced the AgentService to capture and store the SDK session ID from incoming messages, ensuring continuity in conversations.
2025-12-14 11:02:42 -05:00
Cody Seibert
9bb843f82f chore: update dependencies and improve project structure
- Added `morgan` for enhanced request logging in the server.
- Updated `package-lock.json` to include new dependencies and their types.
- Refactored the `NewProjectModal` component for improved readability and structure.
- Enhanced the `FileBrowserDialog` to support initial path selection and improved error handling.
- Updated various components to ensure consistent formatting and better user experience.
- Introduced XML format specification for app specifications to maintain consistency across the application.
2025-12-14 10:59:52 -05:00
Cody Seibert
ebc4f1422a Merge branch 'main' of github.com:webdevcody/automaker 2025-12-14 01:01:01 -05:00
Cody Seibert
96bfa8f131 Add logo_larger.png and update sidebar component for improved branding display
- Introduced a new logo_larger.png file to the public assets.
- Updated the Sidebar component to enhance the branding display based on sidebar state, ensuring a consistent user experience.
2025-12-14 01:01:00 -05:00
Web Dev Cody
406ba14af5 Merge pull request #68 from AutoMaker-Org/fixing-main
Fixing main
2025-12-14 00:57:58 -05:00
Web Dev Cody
13841b1af6 Merge pull request #67 from AutoMaker-Org/move-marketing
Add .DS_Store files and update public assets in marketing app
2025-12-14 00:53:29 -05:00
Cody Seibert
7b3be213e4 refactor: improve auto mode service stop logic and event emission
- Updated the stopAutoLoop method to emit the "auto_mode_stopped" event immediately when the loop is explicitly stopped, enhancing event handling.
- Improved code readability by restructuring feature retrieval calls in integration tests for better clarity.
2025-12-14 00:51:35 -05:00
Cody Seibert
b52b9ba236 feat: enhance project initialization and improve logging in auto mode service
- Added a default categories.json file to the project initialization structure.
- Improved code formatting and readability in the auto-mode-service.ts file by restructuring console log statements and method calls.
- Updated feature status checks to include "backlog" in addition to "pending" and "ready".
2025-12-14 00:43:52 -05:00
Cody Seibert
58f466b443 feat: update terminal shortcut and improve code formatting
- Added a hasInstallScript property to package-lock.json.
- Refactored the app-store.ts file for improved readability by formatting function parameters and object properties.
- Updated the default terminal shortcut from "Cmd+`" to "T" and implemented migration logic for state persistence.
- Incremented version number in the terminal state management to reflect breaking changes.
2025-12-14 00:20:11 -05:00
Cody Seibert
13e3f05a7a refactor: enhance init.sh and server startup error handling
- Refactored init.sh to introduce a reusable function for killing processes on specified ports, improving code clarity and maintainability.
- Added a cleanup function to ensure proper resource management on exit.
- Updated server startup logic in index.ts to handle port conflicts gracefully, providing clear error messages and suggestions for resolution.
- Improved logging for server status and health checks during initialization.
2025-12-13 22:06:53 -05:00
Cody Seibert
7f5cdc0345 chore: update package.json and refactor terminal WebSocket connection handling
- Added a postinstall script in package.json to set permissions for spawn-helper on macOS.
- Refactored the terminal WebSocket connection handling in index.ts for improved readability and consistency.
- Enhanced error logging and connection management in the terminal service.
- Cleaned up formatting and indentation across multiple files for better code clarity.
2025-12-13 22:02:30 -05:00
Cody Seibert
c21a298e07 refactor: improve ClaudeProvider query execution and message handling
- Enhanced the executeQuery method to better handle conversation history and user messages, ensuring compliance with SDK requirements.
- Introduced a default tools array for allowedTools, simplifying the options setup.
- Updated the getAvailableModels method to use type assertions for model tiers and ensured proper return type with TypeScript's satisfies operator.
- Added error handling during query execution to log and propagate errors effectively.
2025-12-13 21:53:55 -05:00
Cody Seibert
3d940e21d5 Merge branch 'main' into move-marketing 2025-12-13 20:29:11 -05:00
Shirone
6446dd5d3a Merge pull request #60 from AutoMaker-Org/feat/add-unit-testing
feat: add unit testing to app/server and remove codex support
2025-12-14 02:27:37 +01:00
Kacper
38cff827b3 Merge branch 'main' into feat/add-unit-testing
Resolved conflicts:
- apps/app/package.json: Combined build:electron scripts from main with postinstall script from feature branch
- package-lock.json: Accepted main version and regenerated with npm install

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 02:22:49 +01:00
Cody Seibert
cc4310b368 Refactor marketing configuration and sidebar display
- Removed the IS_MARKETING flag from app-config.ts to simplify configuration.
- Updated the Sidebar component to always display the "AutoMaker" branding, removing conditional rendering based on the marketing flag.
- Cleaned up package.json by removing the dev:marketing script and ensuring consistency in test commands.
- Cleaned up package-lock.json by removing references to the marketing app and its dependencies.
2025-12-13 20:20:03 -05:00
Cody Seibert
d248e74492 Add .DS_Store files and update public assets in marketing app
- Added .DS_Store files to the root and apps directories.
- Removed outdated icon files: icon_gold.png and icon.png.
- Added new logo_big.png file.
- Deleted logo_larger.png.
- Updated logo.png with new content.
- Removed Dockerfile, package.json, and public HTML files from the marketing app, streamlining the project structure.
2025-12-13 20:18:26 -05:00
Shirone
a3d74fbe6e Merge pull request #65 from AutoMaker-Org/fix-electron-build
feat: enhance Electron build process and server preparation
2025-12-14 02:16:00 +01:00
Web Dev Cody
54dad0135f Merge pull request #66 from AutoMaker-Org/license-tweak
Update LICENSE file to clarify Core Contributor status management and…
2025-12-13 19:30:12 -05:00
Cody Seibert
d9440e86a2 Update LICENSE file to clarify amendment process and section numbering
- Renamed section 8 to "LICENSE AMENDMENTS" and added provisions for unanimous agreement among Core Contributors for any amendments to the License Agreement.
- Renumbered subsequent sections for improved clarity and organization.
2025-12-13 19:26:49 -05:00
Cody Seibert
1f716142af Refine Core Contributor status revocation criteria in LICENSE file
- Clarified the conditions under which Core Contributor status may be revoked, ensuring that the definition of "contributed" is clearly stated in relation to communication and code contributions.
2025-12-13 19:25:11 -05:00
Cody Seibert
f2eb6c3745 Update LICENSE file to amend Core Contributor status management
- Added a provision to update the list of Core Contributors to reflect any changes in status, ensuring clarity in the management of contributor roles.
2025-12-13 19:24:10 -05:00
Cody Seibert
a08641a59b Update LICENSE file to clarify Core Contributor status management and commercial licensing terms
- Added provisions for the revocation and reinstatement of Core Contributor status, requiring unanimous votes for both actions.
- Introduced a new section outlining the process for discussing and issuing commercial licenses among Core Contributors.
- Renumbered sections for clarity and consistency throughout the document.
2025-12-13 19:17:59 -05:00
Kacper
8dc3bdde67 feat: enhance Electron app packaging and server preparation
- Added afterPack script in package.json to rebuild native modules for the server bundle.
- Improved icon handling in main.js to support cross-platform formats and verify icon existence.
- Updated startStaticServer function to return a promise for better error handling.
- Introduced a new script, rebuild-server-natives.js, to rebuild native modules based on the target architecture.
- Enhanced prepare-server.js to include native module rebuilding step for improved terminal functionality.
2025-12-14 01:08:35 +01:00
Kacper
223fff9ef9 feat: update application icon in package.json
- Changed the application icon from "public/logo_larger.png" to "public/icon.ico" for improved branding.
- Added new icon file "icon.ico" to the public directory.
2025-12-14 00:27:24 +01:00
Kacper
986f6c034f feat: improve project path handling in InterviewView component
- Updated the project path construction to use platform-specific path separators, enhancing compatibility across different operating systems.
- Implemented a check for the Electron API to determine the appropriate path separator based on the user's platform.
2025-12-14 00:20:58 +01:00
Kacper
10e647570b feat: improve FileBrowserDialog layout and styling
- Enhanced the layout of the FileBrowserDialog component by adding overflow handling and padding to improve visual consistency.
- Updated the DialogHeader and DialogFooter with additional styling for better separation and usability.
2025-12-14 00:15:04 +01:00
Kacper
6ac888c5ce feat: implement auto-collapse functionality for sidebar on small screens
- Added useEffect hook to automatically collapse the sidebar when the screen width is below 1024px.
- Included event listener for media query changes to handle sidebar state dynamically.
2025-12-14 00:03:48 +01:00
Kacper
1bda0259db feat: enhance workspace management and path handling
- Added functionality to set a default workspace directory in Electron, creating it if it doesn't exist.
- Improved project path construction in the New Project Modal to use platform-specific path separators.
- Enhanced error handling in the Templates route for parent directory access, including logging for better debugging.
2025-12-13 23:52:14 +01:00
Kacper
bea115d1e4 feat: update Electron configuration and static server implementation
- Upgraded Electron version to 39.2.7 and TypeScript to 5.9.3 in package-lock.json.
- Modified next.config.ts to set output to "export" for static site generation.
- Changed package.json to include the output directory for deployment.
- Enhanced main.js to implement a static file server for production builds, serving files from the "out" directory.
- Adjusted the loading mechanism to use the static server in production and the Next.js dev server in development.
2025-12-13 23:12:10 +01:00
SuperComboGamer
bc46a18372 feat: enhance Electron build process and server preparation
- Added new build scripts for Electron targeting Windows, macOS, and Linux.
- Updated the main build script to include server preparation steps.
- Introduced a new script to prepare the server for bundling with Electron, including cleaning previous builds and installing production dependencies.
- Modified the Electron main process to verify server file existence and improved error handling.
- Updated .gitignore to exclude the new server-bundle directory.
2025-12-13 16:40:09 -05:00
Cody Seibert
574680fc11 fix: update server startup command to use node from PATH and improve error handling for tsx resolution 2025-12-13 15:55:21 -05:00
Kacper
673dcd1113 chore: add installation of Linux native bindings in CI workflows to address npm optional dependencies issue 2025-12-13 21:49:16 +01:00
Kacper
41ae35bcdb chore: restructure package-lock.json by moving dependencies under apps/app and removing unused entries 2025-12-13 21:42:02 +01:00
Kacper
a2ad1d9420 chore: update .npmrc to comment out platform-specific bindings for faster installs 2025-12-13 21:33:17 +01:00
Kacper
1f4e801c58 chore: update electron version to 39.2.7 and add postinstall script in package.json; add unit tests for terminal service 2025-12-13 21:28:22 +01:00
Kacper
ff06821fcd chore: update .npmrc to include platform-specific bindings and add new applications to package-lock.json 2025-12-13 21:14:52 +01:00
Kacper
25edfecbd4 fix: correct error message 2025-12-13 20:52:49 +01:00
Kacper
aa83583ee9 refactor: remove ultrathink toast notifications and clean up component structure in BoardView and WelcomeStep 2025-12-13 20:44:34 +01:00
Kacper
7fe3dff655 feat: remoe codex references after merging of main branch 2025-12-13 20:38:05 +01:00
Kacper
7c6d9d3723 chore: update Next.js version to 16.0.10 in package.json and package-lock.json 2025-12-13 20:27:07 +01:00
Kacper
a1ff498585 chore: clean up package-lock.json by removing resolved and integrity fields for several dependencies 2025-12-13 20:22:58 +01:00
Kacper
37f45ee89b feat: remove codex support 2025-12-13 20:17:24 +01:00
Shirone
83fbf55781 Merge branch 'main' into feat/add-unit-testing 2025-12-13 19:53:00 +01:00
Kacper
c24cd9721c fix: correct model check for Codex API key validation
- Updated the model check logic to only consider "gpt-" prefixed models, removing the previous check for unsupported models.
- Adjusted error message for authentication failures to provide clearer guidance on resolving API key issues.
2025-12-13 13:46:11 +01:00
Shirone
8c33b1c751 Merge branch 'main' into feat/add-unit-testing 2025-12-13 13:44:46 +01:00
Kacper
4d0d15d1d5 ci: add GitHub Actions workflow for test suite
- Added test.yml workflow to run on PRs and pushes to main/master
- Runs server tests with coverage on every PR
- Commented out Codecov integration (can be enabled when token is configured)
- Added test:server:coverage script to root package.json
- Adjusted coverage thresholds to match current coverage levels:
  - lines: 70% (current: 72.73%)
  - statements: 70% (current: 72.65%)
  - branches: 64% (current: 64.66%)
  - functions: 80% (current: 80.87%)

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 13:43:15 +01:00
Kacper
4ba82e131a ci: add GitHub Actions workflow for test suite
- Added test.yml workflow to run on PRs and pushes to main/master
- Runs server tests with coverage on every PR
- Uploads coverage reports to Codecov
- Added test:server:coverage script to root package.json
- Coverage thresholds enforced: 80% lines/functions, 75% branches

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 13:35:19 +01:00
Kacper
23ff99d2e2 feat: add comprehensive integration tests for auto-mode-service
- Created git-test-repo helper for managing test git repositories
- Added 13 integration tests covering:
  - Worktree operations (create, error handling, non-worktree mode)
  - Feature execution (status updates, model selection, duplicate prevention)
  - Auto loop (start/stop, pending features, max concurrency, events)
  - Error handling (provider errors, continue after failures)
- Integration tests use real git operations with temporary repos
- All 416 tests passing with 72.65% overall coverage
- Service coverage improved: agent-service 58%, auto-mode-service 44%, feature-loader 66%

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 13:34:27 +01:00
Kacper
0473b35db3 refactor: restrict model checks to gpt-* for OpenAI/Codex models
- Updated model resolution logic to only check for gpt-* models, removing references to unsupported o1/o3 models in both model-resolver and provider-factory files.
- Enhanced comments for clarity regarding model support in Codex CLI.
2025-12-13 13:12:04 +01:00
Cody Seibert
f71533ab17 feat: improve URL accessibility checks and download handling
- Enhanced the URL accessibility check function to handle multiple redirect types and provide detailed feedback on accessibility status, including content type validation.
- Updated the download function to follow redirects correctly and ensure proper error handling, improving the reliability of downloading source archives from GitHub.
- Adjusted the main function to utilize the final URLs after redirects for downloading, ensuring accurate resource retrieval.
2025-12-13 11:53:26 +01:00
Cody Seibert
8709b5d34b feat: implement URL accessibility check with exponential backoff
- Added a new function to check the accessibility of URLs with retries and exponential backoff, improving the reliability of downloading source archives from GitHub.
- Updated the main function to wait for the source archives to be accessible before proceeding with the download, enhancing error handling and user feedback.
2025-12-13 11:53:26 +01:00
Cody Seibert
88a059ca52 chore: specify shell for version extraction in release workflow
- Updated the release workflow to explicitly set the shell to bash for the version extraction steps, ensuring consistent execution across environments.
2025-12-13 11:53:26 +01:00
Cody Seibert
f3ffb22487 testing releases 2025-12-13 11:53:26 +01:00
Cody Seibert
b915a43eb0 fix: update release URL in marketing pages
- Changed the default release URL from 'https://releases.automaker.dev/releases.json' to 'https://releases.automaker.app/releases.json' in both index.html and releases.html files to ensure correct resource loading.
2025-12-13 11:53:26 +01:00
Cody Seibert
8b2b7662ee feat: enhance board background settings and introduce animated borders
- Added default background settings to streamline background management across components.
- Implemented animated border styles for in-progress cards to improve visual feedback.
- Refactored BoardBackgroundModal and BoardView components to utilize the new default settings, ensuring consistent background behavior.
- Updated KanbanCard to support animated borders, enhancing the user experience during task progress.
- Improved Sidebar component by optimizing the fetching of running agents count with a more efficient use of hooks.
2025-12-13 11:53:26 +01:00
Cody Seibert
e6d3e8e5a5 chore: clean up .gitignore by removing redundant node_modules entry
- Removed duplicate entry for node_modules from the .gitignore file to streamline ignored files and improve clarity.
2025-12-13 11:52:47 +01:00
Cody Seibert
26e01c930f feat: add project management actions to WelcomeView
- Introduced `addProject` and `setCurrentProject` actions to the WelcomeView component for enhanced project management capabilities.
- Updated the component's state management to support these new actions, improving user experience in project handling.
2025-12-13 11:52:47 +01:00
Cody Seibert
8621a3095d feat: enhance background image handling with cache-busting
- Added a cache-busting query parameter to the background image URL to ensure the browser reloads the image when updated.
- Updated the AppState to include an optional imageVersion property for managing image updates.
- Modified the BoardBackgroundModal and BoardView components to utilize the new imageVersion for dynamic image loading.
2025-12-13 11:52:47 +01:00
Cody Seibert
6e7352e67e feat: implement upsert project functionality in sidebar and welcome view
- Refactored project handling in Sidebar and WelcomeView components to use a new `upsertAndSetCurrentProject` action for creating or updating projects.
- Enhanced theme preservation logic during project creation and updates by integrating theme management directly into the store action.
- Cleaned up redundant code related to project existence checks and state updates, improving maintainability and readability.
2025-12-13 11:52:47 +01:00
Cody Seibert
7e3f77cb38 feat: add video demo section to marketing page
- Introduced a new video demo section to showcase features with an embedded video player.
- Styled the video container for responsive design and improved aesthetics.
- Added media queries for better display on smaller screens.
2025-12-13 11:52:47 +01:00
Cody Seibert
75b73c55e0 feat: introduce marketing mode and update sidebar display
- Added a new configuration flag `IS_MARKETING` to toggle marketing mode.
- Updated the sidebar component to conditionally display the marketing URL when in marketing mode.
- Refactored event type naming for consistency in the sidebar logic.
- Cleaned up formatting in the HttpApiClient for improved readability.
2025-12-13 11:52:47 +01:00
Cody Seibert
ebd928e3b6 feat: add red theme and board background modal
- Introduced a new red theme with custom color variables for a bold aesthetic.
- Updated the theme management to include the new red theme option.
- Added a BoardBackgroundModal component for managing board background settings, including image uploads and opacity controls.
- Enhanced KanbanCard and KanbanColumn components to support new background settings such as opacity and border visibility.
- Updated API client to handle saving and deleting board backgrounds.
- Refactored theme application logic to accommodate the new preview theme functionality.
2025-12-13 11:52:47 +01:00
Cody Seibert
80cbabeeb0 various fixes 2025-12-13 11:48:53 +01:00
Cody Seibert
05910905ee adding new project from template 2025-12-13 11:48:53 +01:00
Shirone
25f5f7d6b2 Update apps/app/src/store/app-store.ts
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-12-13 04:42:08 +01:00
Kacper
2f2eab6e02 refactor: update auto-mode-service to use dynamic model resolution
- Replaced hardcoded model string with dynamic resolution for the analysis model, allowing for future flexibility.
- Enhanced error handling to provide specific authentication failure messages based on the model type, improving user feedback.

This change streamlines the model selection process and improves error clarity for users.
2025-12-13 04:37:53 +01:00
Kacper
6726050969 Merge main into feat/codex-new-model - resolved conflict in auto-mode-service.ts 2025-12-13 04:35:32 +01:00
Kacper
d08eba2331 fix: resolve TypeScript compilation errors
Fixed 4 TypeScript errors:
- fs.ts: Removed duplicate 'os' import (lines 8 and 10)
- spec-regeneration.ts: Removed dead code checking for impossible error type (2 occurrences)

The error type checks were comparing msg.type to "error", but the SDK type union
does not include "error" as a valid message type. Errors are properly handled
in the catch blocks, so these checks were unreachable dead code.

All TypeScript compilation now passes cleanly.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 04:30:13 +01:00
Kacper
7cbdb3db73 refactor: eliminate code duplication with shared utilities
Created 5 new utility modules in apps/server/src/lib/ to eliminate ~320 lines of duplicated code:
- image-handler.ts: Centralized image processing (MIME types, base64, content blocks)
- prompt-builder.ts: Standardized prompt building with image attachments
- model-resolver.ts: Model alias resolution and provider routing
- conversation-utils.ts: Conversation history processing for providers
- error-handler.ts: Error classification and user-friendly messages

Updated services and providers to use shared utilities:
- agent-service.ts: -51 lines (removed duplicate image handling, model logic)
- auto-mode-service.ts: -75 lines (removed MODEL_MAP, duplicate utilities)
- claude-provider.ts: -10 lines (uses conversation-utils)
- codex-provider.ts: -5 lines (uses conversation-utils)

Added comprehensive documentation:
- docs/server/utilities.md: Complete reference for all 9 lib utilities
- docs/server/providers.md: Provider architecture guide with examples

Benefits:
- Single source of truth for critical business logic
- Improved maintainability and testability
- Consistent behavior across services and providers
- Better documentation for future development

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 04:26:58 +01:00
Kacper
0519aba820 feat: add missing Codex models and restore subprocess logs
- Added gpt-5.1-codex-mini model (lightweight, faster)
- Added gpt-5.1 model (general-purpose)
- Restored subprocess spawn/exit logs for debugging
- Now all 5 Codex models are available:
  * GPT-5.2
  * GPT-5.1 Codex Max
  * GPT-5.1 Codex
  * GPT-5.1 Codex Mini
  * GPT-5.1

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 03:50:31 +01:00
Kacper
a65b16cbae feat: implement modular provider architecture with Codex CLI support
Implements a flexible provider pattern that supports both Claude Agent SDK
and OpenAI Codex CLI, enabling future expansion to other AI providers
(Cursor, OpenCode, etc.) with minimal changes.

## Architecture Changes

### New Provider System
- Created provider abstraction layer with BaseProvider interface
- Model-based routing: model prefix determines provider
  - `gpt-*`, `o*` → CodexProvider (subprocess CLI)
  - `claude-*`, `opus/sonnet/haiku` → ClaudeProvider (SDK)
- Providers implement common ExecuteOptions interface

### New Files Created
- `providers/types.ts` - Shared interfaces (ExecuteOptions, ProviderMessage, etc.)
- `providers/base-provider.ts` - Abstract base class
- `providers/claude-provider.ts` - Claude Agent SDK wrapper
- `providers/codex-provider.ts` - Codex CLI subprocess executor
- `providers/codex-cli-detector.ts` - Installation & auth detection
- `providers/codex-config-manager.ts` - TOML config management
- `providers/provider-factory.ts` - Model-based provider routing
- `lib/subprocess-manager.ts` - Reusable subprocess utilities

## Features Implemented

### Codex CLI Integration
- Spawns Codex CLI as subprocess with JSONL output
- Converts Codex events to Claude SDK-compatible format
- Supports both `codex login` and OPENAI_API_KEY auth methods
- Handles: reasoning, messages, commands, todos, file changes
- Extracts text from content blocks for non-vision CLI

### Conversation History
- Added conversationHistory support to ExecuteOptions
- ClaudeProvider: yields previous messages to SDK
- CodexProvider: prepends history as text context
- Follow-up prompts maintain full conversation context

### Image Upload Support
- Images embedded as base64 for vision models
- Image paths appended to prompt text for Read tool access
- Auto-mode: copies images to feature folder
- Follow-up: combines original + new images
- Updates feature.json with image metadata

### Session Model Persistence
- Added `model` field to Session and SessionMetadata
- Sessions remember model preference across interactions
- API endpoints accept model parameter
- Auto-mode respects feature's model setting

## Modified Files

### Services
- `agent-service.ts`:
  - Added conversation history building
  - Uses ProviderFactory instead of direct SDK calls
  - Appends image paths to prompts
  - Added model parameter and persistence

- `auto-mode-service.ts`:
  - Removed OpenAI model block restriction
  - Uses ProviderFactory for all models
  - Added image support in buildFeaturePrompt
  - Follow-up: loads context, copies images, updates feature.json
  - Returns to waiting_approval after follow-up

### Routes
- `agent.ts`: Added model parameter to /send endpoint
- `sessions.ts`: Added model field to create/update
- `models.ts`: Added Codex models (gpt-5.2, gpt-5.1-codex*)

### Configuration
- `.env.example`: Added OPENAI_API_KEY and CODEX_CLI_PATH
- `.gitignore`: Added provider-specific ignores

## Bug Fixes
- Fixed image path resolution (relative → absolute)
- Fixed Codex empty prompt when images attached
- Fixed follow-up status management (in_progress → waiting_approval)
- Fixed follow-up images not appearing in prompt text
- Removed OpenAI model restrictions in auto-mode

## Testing Notes
- Codex CLI authentication verified with both methods
- Image uploads work for both Claude (vision) and Codex (Read tool)
- Follow-up prompts maintain full context
- Conversation history persists across turns
- Model switching works per-session

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 03:45:41 +01:00
Kacper
55603cb5c7 feat: add GPT-5.2 model support and refresh profiles functionality
- Introduced the GPT-5.2 model with advanced coding capabilities across various components.
- Added a new button in ProfilesView to refresh default profiles, enhancing user experience.
- Updated CodexSetupStep to clarify authentication requirements and added commands for verifying login status.
- Enhanced utility functions to recognize the new GPT-5.2 model in the application.
2025-12-13 01:36:15 +01:00
333 changed files with 43284 additions and 23554 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(dir \"C:\\Users\\Ben\\Desktop\\appdev\\git\\automaker\\apps\\app\\public\")"
]
}
}

96
.github/workflows/e2e-tests.yml vendored Normal file
View File

@@ -0,0 +1,96 @@
name: E2E Tests
on:
pull_request:
branches:
- "*"
push:
branches:
- main
- master
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
- name: Configure Git for HTTPS
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
# This is needed because SSH authentication isn't available in CI
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Install dependencies
# Use npm install instead of npm ci to correctly resolve platform-specific
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
run: npm install
- name: Install Linux native bindings
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Explicitly install Linux bindings needed for build tools
run: |
npm install --no-save --force \
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
working-directory: apps/app
- name: Build server
run: npm run build --workspace=apps/server
- name: Start backend server
run: npm run start --workspace=apps/server &
env:
PORT: 3008
NODE_ENV: test
- name: Wait for backend server
run: |
echo "Waiting for backend server to be ready..."
for i in {1..30}; do
if curl -s http://localhost:3008/api/health > /dev/null 2>&1; then
echo "Backend server is ready!"
exit 0
fi
echo "Waiting... ($i/30)"
sleep 1
done
echo "Backend server failed to start!"
exit 1
- name: Run E2E tests
# Playwright automatically starts the Next.js frontend via webServer config
# (see apps/app/playwright.config.ts) - no need to start it manually
run: npm run test --workspace=apps/app
env:
CI: true
NEXT_PUBLIC_SERVER_URL: http://localhost:3008
NEXT_PUBLIC_SKIP_SETUP: "true"
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: apps/app/playwright-report/
retention-days: 7
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results
path: apps/app/test-results/
retention-days: 7

View File

@@ -20,14 +20,27 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
- name: Configure Git for HTTPS
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
# This is needed because SSH authentication isn't available in CI
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Install dependencies
# Use npm install instead of npm ci to correctly resolve platform-specific
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
run: npm install
- name: Install Linux native bindings
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Explicitly install Linux bindings needed for build tools
run: |
npm install --no-save --force \
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Run build:electron
run: npm run build:electron

View File

@@ -39,15 +39,29 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
- name: Configure Git for HTTPS
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
# This is needed because SSH authentication isn't available in CI
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Install dependencies
# Use npm install instead of npm ci to correctly resolve platform-specific
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
run: npm install
- name: Install Linux native bindings
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Only needed on Linux - macOS and Windows get their bindings automatically
if: matrix.os == 'ubuntu-latest'
run: |
npm install --no-save --force \
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Extract and set version
id: version
shell: bash

58
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: Test Suite
on:
pull_request:
branches:
- "*"
push:
branches:
- main
- master
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
- name: Configure Git for HTTPS
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
# This is needed because SSH authentication isn't available in CI
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Install dependencies
# Use npm install instead of npm ci to correctly resolve platform-specific
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
run: npm install
- name: Install Linux native bindings
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Explicitly install Linux bindings needed for build tools
run: |
npm install --no-save --force \
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Run server tests with coverage
run: npm run test:server:coverage
env:
NODE_ENV: test
# - name: Upload coverage reports
# uses: codecov/codecov-action@v4
# if: always()
# with:
# files: ./apps/server/coverage/coverage-final.json
# flags: server
# name: server-coverage
# env:
# CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ dist/
.automaker/
/.automaker/*
/.automaker/
/logs

6
.npmrc
View File

@@ -8,3 +8,9 @@
#
# In CI/CD: Use "npm install" instead of "npm ci" to allow npm to resolve
# the correct platform-specific binaries at install time.
# Include bindings for all platforms in package-lock.json to support CI/CD
# This ensures Linux, macOS, and Windows bindings are all present
# NOTE: Only enable when regenerating package-lock.json, then comment out to keep installs fast
# supportedArchitectures.os=linux,darwin,win32
# supportedArchitectures.cpu=x64,arm64

35
LICENSE
View File

@@ -43,6 +43,10 @@ b) Licensee may run the Software on personal or organizational infrastructure fo
c) Core Contributors are each individually granted a perpetual, worldwide, royalty-free, non-exclusive license to use, copy, modify, distribute, and sublicense the Software for any purpose, including Monetization, without payment of any fees or royalties. Each Core Contributor may exercise these rights independently and does not require permission, consent, or approval from any other Core Contributor to Monetize the Software in any way they see fit.
d) Commercial licenses for the Software may be discussed and issued to external parties or companies seeking to use the Software for financial gain or Monetization purposes. Core Contributors already have full rights under section 2(c) and do not require commercial licenses. Any commercial license issued to external parties shall require a unanimous vote by all Core Contributors and shall be granted in writing and signed by all Core Contributors.
e) The list of individuals defined as "Core Contributors" in Section 1 shall be amended to reflect any revocation or reinstatement of status made under this section.
3. RESTRICTIONS
Licensee may NOT:
@@ -63,7 +67,24 @@ Licensee MAY:
- Use the Software to build other commercial products (products that do NOT contain the Software or Derivative Works)
- Modify the Software for internal use within their organization (commercial or non-profit)
4. CONTRIBUTIONS AND RIGHTS ASSIGNMENT
4. CORE CONTRIBUTOR STATUS MANAGEMENT
a) Core Contributor status may be revoked indefinitely by the remaining Core Contributors if:
- A Core Contributor cannot be reached for a period of one (1) month through reasonable means of communication (including but not limited to email, Discord, GitHub, or other project communication channels)
- AND the Core Contributor has not contributed to the project during that one-month period. For purposes of this section, "contributed" means at least one of the following activities:
- Discussing the Software through project communication channels
- Committing code changes to the project repository
- Submitting bug fixes or patches
- Participating in project-related discussions or decision-making
b) Revocation of Core Contributor status requires a unanimous vote by all other Core Contributors (excluding the Core Contributor whose status is being considered for revocation).
c) Upon revocation of Core Contributor status, the individual shall no longer be considered a Core Contributor and shall lose the rights granted under section 2(c) of this Agreement. However, any Contributions made prior to revocation shall remain subject to the terms of section 5 (CONTRIBUTIONS AND RIGHTS ASSIGNMENT).
d) A revoked Core Contributor may be reinstated to Core Contributor status with a unanimous vote by all current Core Contributors. Upon reinstatement, the individual shall regain all rights granted under section 2(c) of this Agreement.
5. CONTRIBUTIONS AND RIGHTS ASSIGNMENT
By submitting, pushing, or contributing any code, documentation, pull requests, issues, or other materials ("Contributions") to the Automaker project, you agree to the following terms without reservation:
@@ -75,11 +96,11 @@ c) **Waiver of Moral Rights:** You waive any "moral rights" or other rights with
d) **Right to Contribute:** You represent and warrant that you are the original author of the Contributions, or that you have sufficient rights to grant the rights conveyed by this section, and that your Contributions do not infringe upon the rights of any third party.
5. TERMINATION
6. TERMINATION
This license will terminate automatically if Licensee breaches any term of this Agreement. Upon termination, Licensee must immediately cease all use of the Software and destroy all copies in their possession.
6. HIGH RISK DISCLAIMER AND LIMITATION OF LIABILITY
7. HIGH RISK DISCLAIMER AND LIMITATION OF LIABILITY
a) **AI RISKS:** THE SOFTWARE UTILIZES ARTIFICIAL INTELLIGENCE TO GENERATE CODE, EXECUTE COMMANDS, AND INTERACT WITH YOUR FILE SYSTEM. YOU ACKNOWLEDGE THAT AI SYSTEMS CAN BE UNPREDICTABLE, MAY GENERATE INCORRECT, INSECURE, OR DESTRUCTIVE CODE, AND MAY TAKE ACTIONS THAT COULD DAMAGE YOUR SYSTEM, FILES, OR HARDWARE.
@@ -95,7 +116,11 @@ d) **LIMITATION OF LIABILITY:** IN NO EVENT SHALL THE CORE CONTRIBUTORS, LICENSO
- FINANCIAL LOSSES
- BUSINESS INTERRUPTION
7. CONTACT
8. LICENSE AMENDMENTS
Any amendment, modification, or update to this License Agreement must be agreed upon unanimously by all Core Contributors. No changes to this Agreement shall be effective unless all Core Contributors have provided their written consent or approval through a unanimous vote.
9. CONTACT
For inquiries regarding this license or permissions for Monetization, please contact the Core Contributors through the official project channels:
@@ -105,7 +130,7 @@ For inquiries regarding this license or permissions for Monetization, please con
Any permission for Monetization requires the unanimous written consent of all Core Contributors.
8. GOVERNING LAW
10. GOVERNING LAW
This Agreement shall be governed by and construed in accordance with the laws of the State of Tennessee, USA, without regard to conflict of law principles.

121
README.md
View File

@@ -1,6 +1,71 @@
<p align="center">
<img src="apps/app/public/readme_logo.png" alt="Automaker Logo" height="80" />
</p>
> **[!TIP]**
>
> **Learn more about Agentic Coding!**
>
> Automaker itself was built by a group of engineers using AI and agentic coding techniques to build features faster than ever. By leveraging tools like Cursor IDE and Claude Code CLI, the team orchestrated AI agents to implement complex functionality in days instead of weeks.
>
> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker).
# Automaker
Automaker is an autonomous AI development studio that helps you build software faster using AI-powered agents. It provides a visual Kanban board interface to manage features, automatically assigns AI agents to implement them, and tracks progress through an intuitive workflow from backlog to verified completion.
**Stop typing code. Start directing AI agents.**
<details open>
<summary><h2>Table of Contents</h2></summary>
- [What Makes Automaker Different?](#what-makes-automaker-different)
- [The Workflow](#the-workflow)
- [Powered by Claude Code](#powered-by-claude-code)
- [Why This Matters](#why-this-matters)
- [Security Disclaimer](#security-disclaimer)
- [Community & Support](#community--support)
- [Getting Started](#getting-started)
- [Prerequisites](#prerequisites)
- [Quick Start](#quick-start)
- [How to Run](#how-to-run)
- [Development Mode](#development-mode)
- [Electron Desktop App (Recommended)](#electron-desktop-app-recommended)
- [Web Browser Mode](#web-browser-mode)
- [Building for Production](#building-for-production)
- [Running Production Build](#running-production-build)
- [Testing](#testing)
- [Linting](#linting)
- [Authentication Options](#authentication-options)
- [Persistent Setup (Optional)](#persistent-setup-optional)
- [Features](#features)
- [Tech Stack](#tech-stack)
- [Learn More](#learn-more)
- [License](#license)
</details>
Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Code automatically implement them.
![Automaker UI](https://i.imgur.com/jdwKydM.png)
## What Makes Automaker Different?
Traditional development tools help you write code. Automaker helps you **orchestrate AI agents** to build entire features autonomously. Think of it as having a team of AI developers working for you—you define what needs to be built, and Automaker handles the implementation.
### The Workflow
1. **Add Features** - Describe features you want built (with text, images, or screenshots)
2. **Move to "In Progress"** - Automaker automatically assigns an AI agent to implement the feature
3. **Watch It Build** - See real-time progress as the agent writes code, runs tests, and makes changes
4. **Review & Verify** - Review the changes, run tests, and approve when ready
5. **Ship Faster** - Build entire applications in days, not weeks
### Powered by Claude Code
Automaker leverages the [Claude Agent SDK](https://docs.anthropic.com/en/docs/claude-code) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe.
### Why This Matters
The future of software development is **agentic coding**—where developers become architects directing AI agents rather than manual coders. Automaker puts this future in your hands today, letting you experience what it's like to build software 10x faster with AI agents handling the implementation while you focus on architecture and business logic.
---
@@ -18,6 +83,22 @@ Automaker is an autonomous AI development studio that helps you build software f
---
## Community & Support
Join the **Agentic Jumpstart** to connect with other builders exploring **agentic coding** and autonomous development workflows.
In the Discord, you can:
- 💬 Discuss agentic coding patterns and best practices
- 🧠 Share ideas for AI-driven development workflows
- 🛠️ Get help setting up or extending Automaker
- 🚀 Show off projects built with AI agents
- 🤝 Collaborate with other developers and contributors
👉 **Join the Discord:**
https://discord.gg/jjem7aEDKU
---
## Getting Started
### Prerequisites
@@ -30,26 +111,28 @@ Automaker is an autonomous AI development studio that helps you build software f
```bash
# 1. Clone the repo
git clone git@github.com:AutoMaker-Org/automaker.git
git clone https://github.com/AutoMaker-Org/automaker.git
cd automaker
# 2. Install dependencies
npm install
# 3. Get your Claude Code OAuth token
claude setup-token
# ⚠️ This prints your token - don't share your screen!
# 4. Set the token and run
export CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat01-..."
npm run dev:electron
# 3. Run Automaker (pick your mode)
npm run dev
# Then choose your run mode when prompted, or use specific commands below
```
## How to Run
### Development Modes
### Development Mode
Automaker can be run in several modes:
Start Automaker in development mode:
```bash
npm run dev
```
This will prompt you to choose your run mode, or you can specify a mode directly:
#### Electron Desktop App (Recommended)
@@ -72,8 +155,6 @@ npm run dev:electron:wsl:gpu
```bash
# Run in web browser (http://localhost:3007)
npm run dev:web
# or
npm run dev
```
### Building for Production
@@ -114,21 +195,17 @@ npm run lint
Automaker supports multiple authentication methods (in order of priority):
| Method | Environment Variable | Description |
| -------------------- | ------------------------- | --------------------------------------------------------- |
| OAuth Token (env) | `CLAUDE_CODE_OAUTH_TOKEN` | From `claude setup-token` - uses your Claude subscription |
| OAuth Token (stored) | — | Stored in app credentials file |
| API Key (stored) | — | Anthropic API key stored in app |
| API Key (env) | `ANTHROPIC_API_KEY` | Pay-per-use API key |
**Recommended:** Use `CLAUDE_CODE_OAUTH_TOKEN` if you have a Claude subscription.
| Method | Environment Variable | Description |
| ---------------- | -------------------- | ------------------------------- |
| API Key (env) | `ANTHROPIC_API_KEY` | Anthropic API key |
| API Key (stored) | — | Anthropic API key stored in app |
### Persistent Setup (Optional)
Add to your `~/.bashrc` or `~/.zshrc`:
```bash
export CLAUDE_CODE_OAUTH_TOKEN="YOUR_TOKEN_HERE"
export ANTHROPIC_API_KEY="YOUR_API_KEY_HERE"
```
Then restart your terminal or run `source ~/.bashrc`.

BIN
apps/.DS_Store vendored Normal file

Binary file not shown.

1
apps/app/.gitignore vendored
View File

@@ -48,3 +48,4 @@ next-env.d.ts
# Electron
/dist/
/server-bundle/

View File

@@ -1,123 +0,0 @@
# Automaker
Automaker is an autonomous AI development studio that helps you build software faster using AI-powered agents. It provides a visual Kanban board interface to manage features, automatically assigns AI agents to implement them, and tracks progress through an intuitive workflow from backlog to verified completion.
---
> **[!CAUTION]**
>
> ## Security Disclaimer
>
> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.**
>
> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it.
>
> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine.
>
> **[Read the full disclaimer](../DISCLAIMER.md)**
---
## Getting Started
**Step 1:** Clone this repository:
```bash
git clone git@github.com:AutoMaker-Org/automaker.git
cd automaker
```
**Step 2:** Install dependencies:
```bash
npm install
```
### Windows notes (in-app Claude auth)
- Node.js 22.x
- Prebuilt PTY is bundled; Visual Studio build tools are not required for Claude auth.
- If you prefer the external terminal flow, set `CLAUDE_AUTH_DISABLE_PTY=1`.
- If you later add native modules beyond the prebuilt PTY, you may still need VS Build Tools + Python to rebuild those.
**Step 3:** Run the Claude Code setup token command:
```bash
claude setup-token
```
> **⚠️ Warning:** This command will print your token to your terminal. Be careful if you're streaming or sharing your screen, as the token will be visible to anyone watching.
**Step 4:** Export the Claude Code OAuth token in your shell:
```bash
export CLAUDE_CODE_OAUTH_TOKEN="your-token-here"
```
**Step 5:** Start the development server:
```bash
npm run dev:electron
```
This will start both the Next.js development server and the Electron application.
### Auth smoke test (Windows)
1. Ensure dependencies are installed (prebuilt pty is included).
2. Run `npm run dev:electron` and open the Setup modal.
3. Click Start on Claude auth; watch the embedded terminal stream logs.
4. Successful runs show “Token captured automatically.”; otherwise copy/paste the token from the log.
5. Optional: `node --test tests/claude-cli-detector.test.js` to verify token parsing.
**Step 6:** MOST IMPORTANT: Run the Following after all is setup
```bash
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
echo "W"
```
## Features
- 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages
- 🤖 **AI Agent Integration** - Automatic AI agent assignment to implement features when moved to "In Progress"
- 🧠 **Multi-Model Support** - Choose from multiple AI models including Claude Opus, Sonnet, and more
- 💭 **Extended Thinking** - Enable extended thinking modes for complex problem-solving
- 📡 **Real-time Agent Output** - View live agent output, logs, and file diffs as features are being implemented
- 🔍 **Project Analysis** - AI-powered project structure analysis to understand your codebase
- 📁 **Context Management** - Add context files to help AI agents understand your project better
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on your project
- 🖼️ **Image Support** - Attach images and screenshots to feature descriptions
-**Concurrent Processing** - Configure concurrency to process multiple features simultaneously
- 🧪 **Test Integration** - Automatic test running and verification for implemented features
- 🔀 **Git Integration** - View git diffs and track changes made by AI agents
- 👤 **AI Profiles** - Create and manage different AI agent profiles for various tasks
- 💬 **Chat History** - Keep track of conversations and interactions with AI agents
- ⌨️ **Keyboard Shortcuts** - Efficient navigation and actions via keyboard shortcuts
- 🎨 **Dark/Light Theme** - Beautiful UI with theme support
- 🖥️ **Cross-Platform** - Desktop application built with Electron for Windows, macOS, and Linux
## Tech Stack
- [Next.js](https://nextjs.org) - React framework
- [Electron](https://www.electronjs.org/) - Desktop application framework
- [Tailwind CSS](https://tailwindcss.com/) - Styling
- [Zustand](https://zustand-demo.pmnd.rs/) - State management
- [dnd-kit](https://dndkit.com/) - Drag and drop functionality
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## License
See [LICENSE](../LICENSE) for details.

View File

@@ -8,21 +8,114 @@
const path = require("path");
const { spawn } = require("child_process");
const fs = require("fs");
// Load environment variables from .env file
require("dotenv").config({ path: path.join(__dirname, "../.env") });
const http = require("http");
const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron");
// Load environment variables from .env file (development only)
if (!app.isPackaged) {
try {
require("dotenv").config({ path: path.join(__dirname, "../.env") });
} catch (error) {
console.warn("[Electron] dotenv not available:", error.message);
}
}
let mainWindow = null;
let serverProcess = null;
let staticServer = null;
const SERVER_PORT = 3008;
const STATIC_PORT = 3007;
// Get icon path - works in both dev and production
// Get icon path - works in both dev and production, cross-platform
function getIconPath() {
return app.isPackaged
? path.join(process.resourcesPath, "app", "public", "logo.png")
: path.join(__dirname, "../public/logo.png");
// Different icon formats for different platforms
let iconFile;
if (process.platform === "win32") {
iconFile = "icon.ico";
} else if (process.platform === "darwin") {
iconFile = "logo_larger.png";
} else {
// Linux
iconFile = "logo_larger.png";
}
const iconPath = path.join(__dirname, "../public", iconFile);
// Verify the icon exists
if (!fs.existsSync(iconPath)) {
console.warn(`[Electron] Icon not found at: ${iconPath}`);
return null;
}
return iconPath;
}
/**
* Start static file server for production builds
*/
async function startStaticServer() {
const staticPath = path.join(__dirname, "../out");
staticServer = http.createServer((request, response) => {
// Parse the URL and remove query string
let filePath = path.join(staticPath, request.url.split("?")[0]);
// Default to index.html for directory requests
if (filePath.endsWith("/")) {
filePath = path.join(filePath, "index.html");
} else if (!path.extname(filePath)) {
filePath += ".html";
}
// Check if file exists
fs.stat(filePath, (err, stats) => {
if (err || !stats.isFile()) {
// Try index.html for SPA fallback
filePath = path.join(staticPath, "index.html");
}
// Read and serve the file
fs.readFile(filePath, (error, content) => {
if (error) {
response.writeHead(500);
response.end("Server Error");
return;
}
// Set content type based on file extension
const ext = path.extname(filePath);
const contentTypes = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".eot": "application/vnd.ms-fontobject",
};
response.writeHead(200, { "Content-Type": contentTypes[ext] || "application/octet-stream" });
response.end(content);
});
});
});
return new Promise((resolve, reject) => {
staticServer.listen(STATIC_PORT, (err) => {
if (err) {
reject(err);
} else {
console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`);
resolve();
}
});
});
}
/**
@@ -35,14 +128,18 @@ async function startServer() {
let command, args, serverPath;
if (isDev) {
// In development, use tsx to run TypeScript directly
// Use the node executable that's running Electron
command = process.execPath; // This is the path to node.exe
// Use node from PATH (process.execPath in Electron points to Electron, not Node.js)
// spawn() resolves "node" from PATH on all platforms (Windows, Linux, macOS)
command = "node";
serverPath = path.join(__dirname, "../../server/src/index.ts");
// Find tsx CLI - check server node_modules first, then root
const serverNodeModules = path.join(__dirname, "../../server/node_modules/tsx");
const serverNodeModules = path.join(
__dirname,
"../../server/node_modules/tsx"
);
const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx");
let tsxCliPath;
if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) {
tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs");
@@ -51,30 +148,61 @@ async function startServer() {
} else {
// Last resort: try require.resolve
try {
tsxCliPath = require.resolve("tsx/cli.mjs", { paths: [path.join(__dirname, "../../server")] });
tsxCliPath = require.resolve("tsx/cli.mjs", {
paths: [path.join(__dirname, "../../server")],
});
} catch {
throw new Error("Could not find tsx. Please run 'npm install' in the server directory.");
throw new Error(
"Could not find tsx. Please run 'npm install' in the server directory."
);
}
}
args = [tsxCliPath, "watch", serverPath];
} else {
// In production, use compiled JavaScript
command = "node";
serverPath = path.join(process.resourcesPath, "server", "index.js");
args = [serverPath];
// Verify server files exist
if (!fs.existsSync(serverPath)) {
throw new Error(`Server not found at: ${serverPath}`);
}
}
// Set environment variables for server
const serverNodeModules = app.isPackaged
? path.join(process.resourcesPath, "server", "node_modules")
: path.join(__dirname, "../../server/node_modules");
// Set default workspace directory to user's Documents/Automaker
const defaultWorkspaceDir = path.join(app.getPath("documents"), "Automaker");
// Ensure workspace directory exists
if (!fs.existsSync(defaultWorkspaceDir)) {
try {
fs.mkdirSync(defaultWorkspaceDir, { recursive: true });
console.log("[Electron] Created workspace directory:", defaultWorkspaceDir);
} catch (error) {
console.error("[Electron] Failed to create workspace directory:", error);
}
}
const env = {
...process.env,
PORT: SERVER_PORT.toString(),
DATA_DIR: app.getPath("userData"),
NODE_PATH: serverNodeModules,
WORKSPACE_DIR: process.env.WORKSPACE_DIR || defaultWorkspaceDir,
};
console.log("[Electron] Starting backend server...");
console.log("[Electron] Server path:", serverPath);
console.log("[Electron] NODE_PATH:", serverNodeModules);
serverProcess = spawn(command, args, {
cwd: path.dirname(serverPath),
env,
stdio: ["ignore", "pipe", "pipe"],
});
@@ -92,6 +220,11 @@ async function startServer() {
serverProcess = null;
});
serverProcess.on("error", (err) => {
console.error(`[Server] Failed to start server process:`, err);
serverProcess = null;
});
// Wait for server to be ready
await waitForServer();
}
@@ -105,13 +238,16 @@ async function waitForServer(maxAttempts = 30) {
for (let i = 0; i < maxAttempts; i++) {
try {
await new Promise((resolve, reject) => {
const req = http.get(`http://localhost:${SERVER_PORT}/api/health`, (res) => {
if (res.statusCode === 200) {
resolve();
} else {
reject(new Error(`Status: ${res.statusCode}`));
const req = http.get(
`http://localhost:${SERVER_PORT}/api/health`,
(res) => {
if (res.statusCode === 200) {
resolve();
} else {
reject(new Error(`Status: ${res.statusCode}`));
}
}
});
);
req.on("error", reject);
req.setTimeout(1000, () => {
req.destroy();
@@ -132,12 +268,12 @@ async function waitForServer(maxAttempts = 30) {
* Create the main window
*/
function createWindow() {
mainWindow = new BrowserWindow({
const iconPath = getIconPath();
const windowOptions = {
width: 1400,
height: 900,
minWidth: 1024,
minHeight: 700,
icon: getIconPath(),
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
@@ -145,17 +281,20 @@ function createWindow() {
},
titleBarStyle: "hiddenInset",
backgroundColor: "#0a0a0a",
});
};
// Load Next.js dev server in development or production build
// Only set icon if it exists
if (iconPath) {
windowOptions.icon = iconPath;
}
mainWindow = new BrowserWindow(windowOptions);
// Load Next.js dev server in development or static server in production
const isDev = !app.isPackaged;
if (isDev) {
mainWindow.loadURL("http://localhost:3007");
if (process.env.OPEN_DEVTOOLS === "true") {
mainWindow.webContents.openDevTools();
}
} else {
mainWindow.loadFile(path.join(__dirname, "../.next/server/app/index.html"));
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
if (isDev && process.env.OPEN_DEVTOOLS === "true") {
mainWindow.webContents.openDevTools();
}
mainWindow.on("closed", () => {
@@ -173,10 +312,22 @@ function createWindow() {
app.whenReady().then(async () => {
// Set app icon (dock icon on macOS)
if (process.platform === "darwin" && app.dock) {
app.dock.setIcon(getIconPath());
const iconPath = getIconPath();
if (iconPath) {
try {
app.dock.setIcon(iconPath);
} catch (error) {
console.warn("[Electron] Failed to set dock icon:", error.message);
}
}
}
try {
// Start static file server in production
if (app.isPackaged) {
await startStaticServer();
}
// Start backend server
await startServer();
@@ -207,6 +358,13 @@ app.on("before-quit", () => {
serverProcess.kill();
serverProcess = null;
}
// Close static server
if (staticServer) {
console.log("[Electron] Stopping static server...");
staticServer.close();
staticServer = null;
}
});
// ============================================

View File

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

View File

@@ -20,18 +20,26 @@
"dev:electron": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron .\"",
"dev:electron:debug": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && OPEN_DEVTOOLS=true electron .\"",
"build": "next build",
"build:electron": "next build && electron-builder",
"build:electron": "node scripts/prepare-server.js && next build && electron-builder",
"build:electron:win": "node scripts/prepare-server.js && next build && electron-builder --win",
"build:electron:mac": "node scripts/prepare-server.js && next build && electron-builder --mac",
"build:electron:linux": "node scripts/prepare-server.js && next build && electron-builder --linux",
"postinstall": "electron-builder install-app-deps",
"start": "next start",
"lint": "eslint",
"pretest": "node scripts/setup-e2e-fixtures.js",
"test": "playwright test",
"test:headed": "playwright test --headed",
"dev:electron:wsl": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron . --no-sandbox --disable-gpu\"",
"dev:electron:wsl:gpu": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA electron . --no-sandbox --disable-gpu-sandbox\""
},
"dependencies": {
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.3",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -42,6 +50,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
"@uiw/react-codemirror": "^4.25.4",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
@@ -51,7 +60,7 @@
"dotenv": "^17.2.3",
"geist": "^1.5.1",
"lucide-react": "^0.556.0",
"next": "16.0.7",
"next": "^16.0.10",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-markdown": "^10.1.0",
@@ -79,31 +88,44 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.2.1",
"electron": "^39.2.6",
"electron": "39.2.7",
"electron-builder": "^26.0.12",
"eslint": "^9",
"eslint-config-next": "16.0.7",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"typescript": "5.9.3",
"wait-on": "^9.0.3"
},
"build": {
"appId": "com.automaker.app",
"productName": "Automaker",
"artifactName": "${productName}-${version}-${arch}.${ext}",
"afterPack": "./scripts/rebuild-server-natives.js",
"directories": {
"output": "dist"
},
"files": [
"electron/**/*",
".next/**/*",
"out/**/*",
"public/**/*",
"!node_modules/**/*"
],
"extraResources": [
{
"from": ".env",
"from": "server-bundle/dist",
"to": "server"
},
{
"from": "server-bundle/node_modules",
"to": "server/node_modules"
},
{
"from": "server-bundle/package.json",
"to": "server/package.json"
},
{
"from": "../../.env",
"to": ".env",
"filter": [
"**/*"
@@ -139,7 +161,7 @@
]
}
],
"icon": "public/logo_larger.png"
"icon": "public/icon.ico"
},
"linux": {
"target": [

View File

@@ -30,6 +30,10 @@ export default defineConfig({
url: `http://localhost:${port}`,
reuseExistingServer: !process.env.CI,
timeout: 120000,
env: {
...process.env,
NEXT_PUBLIC_SKIP_SETUP: "true",
},
},
}),
});

View File

@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" role="img" aria-label="Code icon">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="256" y2="256" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#6B5BFF"></stop>
<stop offset="100%" stop-color="#2EC7FF"></stop>
</linearGradient>
<filter id="iconShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="4" stdDeviation="4" flood-color="#000000" flood-opacity="0.25"></feDropShadow>
</filter>
</defs>
<!-- Rounded square background -->
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg)"></rect>
<!-- </> icon (slightly reduced overall size) -->
<g fill="none" stroke="#FFFFFF" stroke-width="20" stroke-linecap="round" stroke-linejoin="round" filter="url(#iconShadow)">
<!-- Left bracket < -->
<path d="M92 92 L52 128 L92 164"></path>
<!-- Slash / -->
<path d="M144 72 L116 184"></path>
<!-- Right bracket > -->
<path d="M164 92 L204 128 L164 164"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env node
/**
* This script prepares the server for bundling with Electron.
* It copies the server dist and installs production dependencies
* in a way that works with npm workspaces.
*/
import { execSync } from 'child_process';
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const APP_DIR = join(__dirname, '..');
const SERVER_DIR = join(APP_DIR, '..', 'server');
const BUNDLE_DIR = join(APP_DIR, 'server-bundle');
console.log('🔧 Preparing server for Electron bundling...\n');
// Step 1: Clean up previous bundle
if (existsSync(BUNDLE_DIR)) {
console.log('🗑️ Cleaning previous server-bundle...');
rmSync(BUNDLE_DIR, { recursive: true });
}
mkdirSync(BUNDLE_DIR, { recursive: true });
// Step 2: Build the server TypeScript
console.log('📦 Building server TypeScript...');
execSync('npm run build', { cwd: SERVER_DIR, stdio: 'inherit' });
// Step 3: Copy server dist
console.log('📋 Copying server dist...');
cpSync(join(SERVER_DIR, 'dist'), join(BUNDLE_DIR, 'dist'), { recursive: true });
// Step 4: Create a minimal package.json for the server
console.log('📝 Creating server package.json...');
const serverPkg = JSON.parse(readFileSync(join(SERVER_DIR, 'package.json'), 'utf-8'));
const bundlePkg = {
name: '@automaker/server-bundle',
version: serverPkg.version,
type: 'module',
main: 'dist/index.js',
dependencies: serverPkg.dependencies
};
writeFileSync(
join(BUNDLE_DIR, 'package.json'),
JSON.stringify(bundlePkg, null, 2)
);
// Step 5: Install production dependencies
console.log('📥 Installing server production dependencies...');
execSync('npm install --omit=dev', {
cwd: BUNDLE_DIR,
stdio: 'inherit',
env: {
...process.env,
// Prevent npm from using workspace resolution
npm_config_workspace: ''
}
});
// Step 6: Rebuild native modules for current architecture
// This is critical for modules like node-pty that have native bindings
console.log('🔨 Rebuilding native modules for current architecture...');
try {
execSync('npm rebuild', {
cwd: BUNDLE_DIR,
stdio: 'inherit'
});
console.log('✅ Native modules rebuilt successfully');
} catch (error) {
console.warn('⚠️ Warning: Failed to rebuild native modules. Terminal functionality may not work.');
console.warn(' Error:', error.message);
}
console.log('\n✅ Server prepared for bundling at:', BUNDLE_DIR);

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env node
/**
* Electron-builder afterPack hook
* Rebuilds native modules in the server bundle for the target architecture
*/
const { exec } = require('child_process');
const { promisify } = require('util');
const path = require('path');
const execAsync = promisify(exec);
exports.default = async function(context) {
const { appOutDir, electronPlatformName, arch, packager } = context;
const electronVersion = packager.config.electronVersion;
// Convert arch to string if it's a number (electron-builder sometimes passes indices)
const archNames = ['ia32', 'x64', 'armv7l', 'arm64', 'universal'];
const archStr = typeof arch === 'number' ? archNames[arch] : arch;
console.log(`\n🔨 Rebuilding server native modules for ${electronPlatformName}-${archStr}...`);
// Path to server node_modules in the packaged app
let serverNodeModulesPath;
if (electronPlatformName === 'darwin') {
serverNodeModulesPath = path.join(
appOutDir,
`${packager.appInfo.productName}.app`,
'Contents',
'Resources',
'server',
'node_modules'
);
} else if (electronPlatformName === 'win32') {
serverNodeModulesPath = path.join(
appOutDir,
'resources',
'server',
'node_modules'
);
} else {
serverNodeModulesPath = path.join(
appOutDir,
'resources',
'server',
'node_modules'
);
}
try {
// Rebuild native modules for the target architecture
const rebuildCmd = `npx --yes @electron/rebuild --version=${electronVersion} --arch=${archStr} --force --module-dir="${serverNodeModulesPath}/.."`;
console.log(` Command: ${rebuildCmd}`);
const { stdout, stderr } = await execAsync(rebuildCmd);
if (stdout) console.log(stdout);
if (stderr) console.error(stderr);
console.log(`✅ Server native modules rebuilt successfully for ${archStr}\n`);
} catch (error) {
console.error(`❌ Failed to rebuild server native modules:`, error.message);
// Don't fail the build, just warn
}
};

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* Setup script for E2E test fixtures
* Creates the necessary test fixture directories and files before running Playwright tests
*/
import * as fs from "fs";
import * as path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Resolve workspace root (apps/app/scripts -> workspace root)
const WORKSPACE_ROOT = path.resolve(__dirname, "../../..");
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, "test/fixtures/projectA");
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, ".automaker/app_spec.txt");
const SPEC_CONTENT = `<app_spec>
<name>Test Project A</name>
<description>A test fixture project for Playwright testing</description>
<tech_stack>
<item>TypeScript</item>
<item>React</item>
</tech_stack>
</app_spec>
`;
function setupFixtures() {
console.log("Setting up E2E test fixtures...");
console.log(`Workspace root: ${WORKSPACE_ROOT}`);
console.log(`Fixture path: ${FIXTURE_PATH}`);
// Create fixture directory
const specDir = path.dirname(SPEC_FILE_PATH);
if (!fs.existsSync(specDir)) {
fs.mkdirSync(specDir, { recursive: true });
console.log(`Created directory: ${specDir}`);
}
// Create app_spec.txt
fs.writeFileSync(SPEC_FILE_PATH, SPEC_CONTENT);
console.log(`Created fixture file: ${SPEC_FILE_PATH}`);
console.log("E2E test fixtures setup complete!");
}
setupFixtures();

View File

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

View File

@@ -11,7 +11,7 @@ export async function POST(request: NextRequest) {
const { apiKey } = await request.json();
// Use provided API key or fall back to environment variable
const effectiveApiKey = apiKey || process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN;
const effectiveApiKey = apiKey || process.env.ANTHROPIC_API_KEY;
if (!effectiveApiKey) {
return NextResponse.json(

View File

@@ -79,6 +79,19 @@
--color-running-indicator: var(--running-indicator);
--color-running-indicator-text: var(--running-indicator-text);
/* Status colors */
--color-status-success: var(--status-success);
--color-status-success-bg: var(--status-success-bg);
--color-status-warning: var(--status-warning);
--color-status-warning-bg: var(--status-warning-bg);
--color-status-error: var(--status-error);
--color-status-error-bg: var(--status-error-bg);
--color-status-info: var(--status-info);
--color-status-info-bg: var(--status-info-bg);
--color-status-backlog: var(--status-backlog);
--color-status-in-progress: var(--status-in-progress);
--color-status-waiting: var(--status-waiting);
/* Border radius */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
@@ -142,6 +155,31 @@
/* Running indicator - Purple */
--running-indicator: oklch(0.55 0.25 265);
--running-indicator-text: oklch(0.6 0.22 265);
/* Status colors - Light mode */
--status-success: oklch(0.55 0.2 140);
--status-success-bg: oklch(0.55 0.2 140 / 0.15);
--status-warning: oklch(0.7 0.15 70);
--status-warning-bg: oklch(0.7 0.15 70 / 0.15);
--status-error: oklch(0.55 0.22 25);
--status-error-bg: oklch(0.55 0.22 25 / 0.15);
--status-info: oklch(0.55 0.2 230);
--status-info-bg: oklch(0.55 0.2 230 / 0.15);
--status-backlog: oklch(0.5 0 0);
--status-in-progress: oklch(0.7 0.15 70);
--status-waiting: oklch(0.65 0.18 50);
/* Shadow tokens */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
/* Transition tokens */
--transition-fast: 150ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease-out;
}
/* Apply dark mode immediately based on system preference (before JS runs) */
@@ -215,6 +253,26 @@
/* Running indicator - Purple */
--running-indicator: oklch(0.6 0.25 265);
--running-indicator-text: oklch(0.65 0.22 265);
/* Status colors - Dark mode */
--status-success: oklch(0.65 0.2 140);
--status-success-bg: oklch(0.65 0.2 140 / 0.2);
--status-warning: oklch(0.75 0.15 70);
--status-warning-bg: oklch(0.75 0.15 70 / 0.2);
--status-error: oklch(0.65 0.22 25);
--status-error-bg: oklch(0.65 0.22 25 / 0.2);
--status-info: oklch(0.65 0.2 230);
--status-info-bg: oklch(0.65 0.2 230 / 0.2);
--status-backlog: oklch(0.6 0 0);
--status-in-progress: oklch(0.75 0.15 70);
--status-waiting: oklch(0.7 0.18 50);
/* Shadow tokens - darker for dark mode */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.2);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.3);
}
}
@@ -344,6 +402,26 @@
/* Running indicator - Purple */
--running-indicator: oklch(0.6 0.25 265);
--running-indicator-text: oklch(0.65 0.22 265);
/* Status colors - Dark mode */
--status-success: oklch(0.65 0.2 140);
--status-success-bg: oklch(0.65 0.2 140 / 0.2);
--status-warning: oklch(0.75 0.15 70);
--status-warning-bg: oklch(0.75 0.15 70 / 0.2);
--status-error: oklch(0.65 0.22 25);
--status-error-bg: oklch(0.65 0.22 25 / 0.2);
--status-info: oklch(0.65 0.2 230);
--status-info-bg: oklch(0.65 0.2 230 / 0.2);
--status-backlog: oklch(0.6 0 0);
--status-in-progress: oklch(0.75 0.15 70);
--status-waiting: oklch(0.7 0.18 50);
/* Shadow tokens - darker for dark mode */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.2);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.3);
}
.retro {
@@ -2443,3 +2521,34 @@
.xml-editor .xml-highlight {
z-index: 0;
}
/* Accordion animations - CSS-only approach */
@keyframes accordion-down {
from {
height: 0;
opacity: 0;
}
to {
height: var(--accordion-content-height, auto);
opacity: 1;
}
}
@keyframes accordion-up {
from {
height: var(--accordion-content-height, auto);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}
.animate-accordion-down {
animation: accordion-down 0.2s ease-out forwards;
}
.animate-accordion-up {
animation: accordion-up 0.2s ease-out forwards;
}

View File

@@ -222,12 +222,6 @@ function HomeContent() {
return (
<main className="h-screen overflow-hidden" data-testid="app-container">
<SetupView />
{/* Environment indicator */}
{isMounted && !isElectron() && (
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-blue-500/10 text-blue-500 text-xs rounded-full border border-blue-500/20 pointer-events-none">
Web Mode
</div>
)}
</main>
);
}
@@ -242,13 +236,6 @@ function HomeContent() {
{renderView()}
</div>
{/* Environment indicator - only show after mount to prevent hydration issues */}
{isMounted && !isElectron() && (
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-blue-500/10 text-blue-500 text-xs rounded-full border border-blue-500/20 pointer-events-none">
Web Mode
</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 ${

View File

@@ -1,7 +1,15 @@
"use client";
import { useState, useEffect } from "react";
import { FolderOpen, Folder, ChevronRight, Home, ArrowLeft, HardDrive } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import {
FolderOpen,
Folder,
ChevronRight,
Home,
ArrowLeft,
HardDrive,
CornerDownLeft,
} from "lucide-react";
import {
Dialog,
DialogContent,
@@ -11,6 +19,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface DirectoryEntry {
name: string;
@@ -24,6 +33,7 @@ interface BrowseResult {
directories: DirectoryEntry[];
drives?: string[];
error?: string;
warning?: string;
}
interface FileBrowserDialogProps {
@@ -32,6 +42,7 @@ interface FileBrowserDialogProps {
onSelect: (path: string) => void;
title?: string;
description?: string;
initialPath?: string;
}
export function FileBrowserDialog({
@@ -39,22 +50,28 @@ export function FileBrowserDialog({
onOpenChange,
onSelect,
title = "Select Project Directory",
description = "Navigate to your project folder",
description = "Navigate to your project folder or paste a path directly",
initialPath,
}: FileBrowserDialogProps) {
const [currentPath, setCurrentPath] = useState<string>("");
const [pathInput, setPathInput] = useState<string>("");
const [parentPath, setParentPath] = useState<string | null>(null);
const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
const [drives, setDrives] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [warning, setWarning] = useState("");
const pathInputRef = useRef<HTMLInputElement>(null);
const browseDirectory = async (dirPath?: string) => {
setLoading(true);
setError("");
setWarning("");
try {
// Get server URL from environment or default
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const serverUrl =
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const response = await fetch(`${serverUrl}/api/fs/browse`, {
method: "POST",
@@ -66,26 +83,42 @@ export function FileBrowserDialog({
if (result.success) {
setCurrentPath(result.currentPath);
setPathInput(result.currentPath);
setParentPath(result.parentPath);
setDirectories(result.directories);
setDrives(result.drives || []);
setWarning(result.warning || "");
} else {
setError(result.error || "Failed to browse directory");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load directories");
setError(
err instanceof Error ? err.message : "Failed to load directories"
);
} finally {
setLoading(false);
}
};
// Load home directory on mount
// Reset current path when dialog closes
useEffect(() => {
if (open && !currentPath) {
browseDirectory();
if (!open) {
setCurrentPath("");
setPathInput("");
setParentPath(null);
setDirectories([]);
setError("");
setWarning("");
}
}, [open]);
// Load initial path or home directory when dialog opens
useEffect(() => {
if (open && !currentPath) {
browseDirectory(initialPath);
}
}, [open, initialPath]);
const handleSelectDirectory = (dir: DirectoryEntry) => {
browseDirectory(dir.path);
};
@@ -104,6 +137,20 @@ export function FileBrowserDialog({
browseDirectory(drivePath);
};
const handleGoToPath = () => {
const trimmedPath = pathInput.trim();
if (trimmedPath) {
browseDirectory(trimmedPath);
}
};
const handlePathInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleGoToPath();
}
};
const handleSelect = () => {
if (currentPath) {
onSelect(currentPath);
@@ -113,8 +160,8 @@ export function FileBrowserDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader className="pb-2">
<DialogTitle className="flex items-center gap-2">
<FolderOpen className="w-5 h-5 text-brand-500" />
{title}
@@ -124,7 +171,32 @@ export function FileBrowserDialog({
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 min-h-[400px]">
<div className="flex flex-col gap-3 min-h-[400px] flex-1 overflow-hidden py-2">
{/* Direct path input */}
<div className="flex items-center gap-2">
<Input
ref={pathInputRef}
type="text"
placeholder="Paste or type a full path (e.g., /home/user/projects/myapp)"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathInputKeyDown}
className="flex-1 font-mono text-sm"
data-testid="path-input"
disabled={loading}
/>
<Button
variant="secondary"
size="sm"
onClick={handleGoToPath}
disabled={loading || !pathInput.trim()}
data-testid="go-to-path-button"
>
<CornerDownLeft className="w-4 h-4 mr-1" />
Go
</Button>
</div>
{/* Drives selector (Windows only) */}
{drives.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
@@ -135,7 +207,9 @@ export function FileBrowserDialog({
{drives.map((drive) => (
<Button
key={drive}
variant={currentPath.startsWith(drive) ? "default" : "outline"}
variant={
currentPath.startsWith(drive) ? "default" : "outline"
}
size="sm"
onClick={() => handleSelectDrive(drive)}
className="h-7 px-3 text-xs"
@@ -178,7 +252,9 @@ export function FileBrowserDialog({
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-lg">
{loading && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-muted-foreground">Loading directories...</div>
<div className="text-sm text-muted-foreground">
Loading directories...
</div>
</div>
)}
@@ -188,9 +264,17 @@ export function FileBrowserDialog({
</div>
)}
{!loading && !error && directories.length === 0 && (
{warning && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg mb-2">
<div className="text-sm text-yellow-500">{warning}</div>
</div>
)}
{!loading && !error && !warning && directories.length === 0 && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-muted-foreground">No subdirectories found</div>
<div className="text-sm text-muted-foreground">
No subdirectories found
</div>
</div>
)}
@@ -212,11 +296,12 @@ export function FileBrowserDialog({
</div>
<div className="text-xs text-muted-foreground">
Click on a folder to navigate. Select the current folder or navigate to a subfolder.
Paste a full path above, or click on folders to navigate. Press
Enter or click Go to jump to a path.
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<DialogFooter className="border-t border-border pt-4 gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>

View File

@@ -0,0 +1,167 @@
"use client";
import { Sparkles, Clock } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
// Feature count options
export type FeatureCount = 20 | 50 | 100;
const FEATURE_COUNT_OPTIONS: {
value: FeatureCount;
label: string;
warning?: string;
}[] = [
{ value: 20, label: "20" },
{ value: 50, label: "50", warning: "May take up to 5 minutes" },
{ value: 100, label: "100", warning: "May take up to 5 minutes" },
];
interface ProjectSetupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectOverview: string;
onProjectOverviewChange: (value: string) => void;
generateFeatures: boolean;
onGenerateFeaturesChange: (value: boolean) => void;
featureCount: FeatureCount;
onFeatureCountChange: (value: FeatureCount) => void;
onCreateSpec: () => void;
onSkip: () => void;
isCreatingSpec: boolean;
}
export function ProjectSetupDialog({
open,
onOpenChange,
projectOverview,
onProjectOverviewChange,
generateFeatures,
onGenerateFeaturesChange,
featureCount,
onFeatureCountChange,
onCreateSpec,
onSkip,
isCreatingSpec,
}: ProjectSetupDialogProps) {
return (
<Dialog
open={open}
onOpenChange={(open) => {
onOpenChange(open);
if (!open && !isCreatingSpec) {
onSkip();
}
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Set Up Your Project</DialogTitle>
<DialogDescription className="text-muted-foreground">
We didn&apos;t find an app_spec.txt file. Let us help you generate
your app_spec.txt to help describe your project for our system.
We&apos;ll analyze your project&apos;s tech stack and create a
comprehensive specification.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Project Overview</label>
<p className="text-xs text-muted-foreground">
Describe what your project does and what features you want to
build. Be as detailed as you want - this will help us create a
better specification.
</p>
<textarea
className="w-full h-48 p-3 rounded-md border border-border bg-background font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
value={projectOverview}
onChange={(e) => onProjectOverviewChange(e.target.value)}
placeholder="e.g., A project management tool that allows teams to track tasks, manage sprints, and visualize progress through kanban boards. It should support user authentication, real-time updates, and file attachments..."
autoFocus
/>
</div>
<div className="flex items-start space-x-3 pt-2">
<Checkbox
id="sidebar-generate-features"
checked={generateFeatures}
onCheckedChange={(checked) =>
onGenerateFeaturesChange(checked === true)
}
/>
<div className="space-y-1">
<label
htmlFor="sidebar-generate-features"
className="text-sm font-medium cursor-pointer"
>
Generate feature list
</label>
<p className="text-xs text-muted-foreground">
Automatically create features in the features folder from the
implementation roadmap after the spec is generated.
</p>
</div>
</div>
{/* Feature Count Selection - only shown when generateFeatures is enabled */}
{generateFeatures && (
<div className="space-y-2 pt-2 pl-7">
<label className="text-sm font-medium">Number of Features</label>
<div className="flex gap-2">
{FEATURE_COUNT_OPTIONS.map((option) => (
<Button
key={option.value}
type="button"
variant={
featureCount === option.value ? "default" : "outline"
}
size="sm"
onClick={() => onFeatureCountChange(option.value)}
className={cn(
"flex-1 transition-all",
featureCount === option.value
? "bg-primary hover:bg-primary/90 text-primary-foreground"
: "bg-muted/30 hover:bg-muted/50 border-border"
)}
data-testid={`feature-count-${option.value}`}
>
{option.label}
</Button>
))}
</div>
{FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount)
?.warning && (
<p className="text-xs text-amber-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{
FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount)
?.warning
}
</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onSkip}>
Skip for now
</Button>
<Button onClick={onCreateSpec} disabled={!projectOverview.trim()}>
<Sparkles className="w-4 h-4 mr-2" />
Generate Spec
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,8 @@ import { getHttpApiClient } from "@/lib/http-api-client";
import { cn } from "@/lib/utils";
import { useFileBrowser } from "@/contexts/file-browser-context";
const LAST_PROJECT_DIR_KEY = "automaker:lastProjectDir";
interface ValidationErrors {
projectName?: boolean;
workspaceDir?: boolean;
@@ -41,7 +43,10 @@ interface ValidationErrors {
interface NewProjectModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreateBlankProject: (projectName: string, parentDir: string) => Promise<void>;
onCreateBlankProject: (
projectName: string,
parentDir: string
) => Promise<void>;
onCreateFromTemplate: (
template: StarterTemplate,
projectName: string,
@@ -67,7 +72,8 @@ export function NewProjectModal({
const [projectName, setProjectName] = useState("");
const [workspaceDir, setWorkspaceDir] = useState<string>("");
const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<StarterTemplate | null>(null);
const [selectedTemplate, setSelectedTemplate] =
useState<StarterTemplate | null>(null);
const [useCustomUrl, setUseCustomUrl] = useState(false);
const [customUrl, setCustomUrl] = useState("");
const [errors, setErrors] = useState<ValidationErrors>({});
@@ -76,9 +82,18 @@ export function NewProjectModal({
// Fetch workspace directory when modal opens
useEffect(() => {
if (open) {
// First, check localStorage for last used directory
const lastUsedDir = localStorage.getItem(LAST_PROJECT_DIR_KEY);
if (lastUsedDir) {
setWorkspaceDir(lastUsedDir);
return;
}
// Fall back to server config if no saved directory
setIsLoadingWorkspace(true);
const httpClient = getHttpApiClient();
httpClient.workspace.getConfig()
httpClient.workspace
.getConfig()
.then((result) => {
if (result.success && result.workspaceDir) {
setWorkspaceDir(result.workspaceDir);
@@ -113,7 +128,10 @@ export function NewProjectModal({
}, [projectName, errors.projectName]);
useEffect(() => {
if ((selectedTemplate || (useCustomUrl && customUrl)) && errors.templateSelection) {
if (
(selectedTemplate || (useCustomUrl && customUrl)) &&
errors.templateSelection
) {
setErrors((prev) => ({ ...prev, templateSelection: false }));
}
}, [selectedTemplate, useCustomUrl, customUrl, errors.templateSelection]);
@@ -187,10 +205,14 @@ export function NewProjectModal({
const handleBrowseDirectory = async () => {
const selectedPath = await openFileBrowser({
title: "Select Base Project Directory",
description: "Choose the parent directory where your project will be created",
description:
"Choose the parent directory where your project will be created",
initialPath: workspaceDir || undefined,
});
if (selectedPath) {
setWorkspaceDir(selectedPath);
// Save to localStorage for next time
localStorage.setItem(LAST_PROJECT_DIR_KEY, selectedPath);
// Clear any workspace error when a valid directory is selected
if (errors.workspaceDir) {
setErrors((prev) => ({ ...prev, workspaceDir: false }));
@@ -198,7 +220,17 @@ export function NewProjectModal({
}
};
const projectPath = workspaceDir && projectName ? `${workspaceDir}/${projectName}` : "";
// Use platform-specific path separator
const pathSep =
typeof window !== "undefined" && (window as any).electronAPI
? navigator.platform.indexOf("Win") !== -1
? "\\"
: "/"
: "/";
const projectPath =
workspaceDir && projectName
? `${workspaceDir}${pathSep}${projectName}`
: "";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -207,7 +239,9 @@ export function NewProjectModal({
data-testid="new-project-modal"
>
<DialogHeader className="pb-2">
<DialogTitle className="text-foreground">Create New Project</DialogTitle>
<DialogTitle className="text-foreground">
Create New Project
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Start with a blank project or choose from a starter template.
</DialogDescription>
@@ -216,8 +250,15 @@ export function NewProjectModal({
{/* Project Name Input - Always visible at top */}
<div className="space-y-3 pb-4 border-b border-border">
<div className="space-y-2">
<Label htmlFor="project-name" className={cn("text-foreground", errors.projectName && "text-red-500")}>
Project Name {errors.projectName && <span className="text-red-500">*</span>}
<Label
htmlFor="project-name"
className={cn(
"text-foreground",
errors.projectName && "text-red-500"
)}
>
Project Name{" "}
{errors.projectName && <span className="text-red-500">*</span>}
</Label>
<Input
id="project-name"
@@ -239,16 +280,23 @@ export function NewProjectModal({
</div>
{/* Workspace Directory Display */}
<div className={cn(
"flex items-center gap-2 text-sm",
errors.workspaceDir ? "text-red-500" : "text-muted-foreground"
)}>
<div
className={cn(
"flex items-center gap-2 text-sm",
errors.workspaceDir ? "text-red-500" : "text-muted-foreground"
)}
>
<Folder className="w-4 h-4 shrink-0" />
<span className="flex-1 min-w-0">
{isLoadingWorkspace ? (
"Loading workspace..."
) : workspaceDir ? (
<>Will be created at: <code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">{projectPath || "..."}</code></>
<>
Will be created at:{" "}
<code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">
{projectPath || workspaceDir}
</code>
</>
) : (
<span className="text-red-500">No workspace configured</span>
)}
@@ -299,14 +347,18 @@ export function NewProjectModal({
<div className="space-y-4">
{/* Error message for template selection */}
{errors.templateSelection && (
<p className="text-sm text-red-500">Please select a template or enter a custom GitHub URL</p>
<p className="text-sm text-red-500">
Please select a template or enter a custom GitHub URL
</p>
)}
{/* Preset Templates */}
<div className={cn(
"space-y-3 rounded-lg p-1 -m-1",
errors.templateSelection && "ring-2 ring-red-500/50"
)}>
<div
className={cn(
"space-y-3 rounded-lg p-1 -m-1",
errors.templateSelection && "ring-2 ring-red-500/50"
)}
>
{starterTemplates.map((template) => (
<div
key={template.id}
@@ -325,9 +377,10 @@ export function NewProjectModal({
<h4 className="font-medium text-foreground">
{template.name}
</h4>
{selectedTemplate?.id === template.id && !useCustomUrl && (
<Check className="w-4 h-4 text-brand-500" />
)}
{selectedTemplate?.id === template.id &&
!useCustomUrl && (
<Check className="w-4 h-4 text-brand-500" />
)}
</div>
<p className="text-sm text-muted-foreground mb-3">
{template.description}
@@ -388,15 +441,22 @@ export function NewProjectModal({
>
<div className="flex items-center gap-2 mb-2">
<Link className="w-4 h-4 text-muted-foreground" />
<h4 className="font-medium text-foreground">Custom GitHub URL</h4>
{useCustomUrl && <Check className="w-4 h-4 text-brand-500" />}
<h4 className="font-medium text-foreground">
Custom GitHub URL
</h4>
{useCustomUrl && (
<Check className="w-4 h-4 text-brand-500" />
)}
</div>
<p className="text-sm text-muted-foreground mb-3">
Clone any public GitHub repository as a starting point.
</p>
{useCustomUrl && (
<div onClick={(e) => e.stopPropagation()} className="space-y-1">
<div
onClick={(e) => e.stopPropagation()}
className="space-y-1"
>
<Input
placeholder="https://github.com/username/repository"
value={customUrl}
@@ -410,7 +470,9 @@ export function NewProjectModal({
data-testid="custom-url-input"
/>
{errors.customUrl && (
<p className="text-xs text-red-500">GitHub URL is required</p>
<p className="text-xs text-red-500">
GitHub URL is required
</p>
)}
</div>
)}

View File

@@ -320,7 +320,7 @@ export function SessionManager({
activeTab === "active" ? activeSessions : archivedSessions;
return (
<Card className="h-full flex flex-col">
<Card className="h-full flex flex-col rounded-none">
<CardHeader className="pb-3">
<div className="flex items-center justify-between mb-4">
<CardTitle>Agent Sessions</CardTitle>

View File

@@ -0,0 +1,243 @@
"use client";
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
type AccordionType = "single" | "multiple";
interface AccordionContextValue {
type: AccordionType;
value: string | string[];
onValueChange: (value: string) => void;
collapsible?: boolean;
}
const AccordionContext = React.createContext<AccordionContextValue | null>(
null
);
interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
type?: "single" | "multiple";
value?: string | string[];
defaultValue?: string | string[];
onValueChange?: (value: string | string[]) => void;
collapsible?: boolean;
}
const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
(
{
type = "single",
value,
defaultValue,
onValueChange,
collapsible = false,
className,
children,
...props
},
ref
) => {
const [internalValue, setInternalValue] = React.useState<string | string[]>(
() => {
if (value !== undefined) return value;
if (defaultValue !== undefined) return defaultValue;
return type === "single" ? "" : [];
}
);
const currentValue = value !== undefined ? value : internalValue;
const handleValueChange = React.useCallback(
(itemValue: string) => {
let newValue: string | string[];
if (type === "single") {
if (currentValue === itemValue && collapsible) {
newValue = "";
} else if (currentValue === itemValue && !collapsible) {
return;
} else {
newValue = itemValue;
}
} else {
const currentArray = Array.isArray(currentValue)
? currentValue
: [currentValue].filter(Boolean);
if (currentArray.includes(itemValue)) {
newValue = currentArray.filter((v) => v !== itemValue);
} else {
newValue = [...currentArray, itemValue];
}
}
if (value === undefined) {
setInternalValue(newValue);
}
onValueChange?.(newValue);
},
[type, currentValue, collapsible, value, onValueChange]
);
const contextValue = React.useMemo(
() => ({
type,
value: currentValue,
onValueChange: handleValueChange,
collapsible,
}),
[type, currentValue, handleValueChange, collapsible]
);
return (
<AccordionContext.Provider value={contextValue}>
<div
ref={ref}
data-slot="accordion"
className={cn("w-full", className)}
{...props}
>
{children}
</div>
</AccordionContext.Provider>
);
}
);
Accordion.displayName = "Accordion";
interface AccordionItemContextValue {
value: string;
isOpen: boolean;
}
const AccordionItemContext =
React.createContext<AccordionItemContextValue | null>(null);
interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
value: string;
}
const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
({ className, value, children, ...props }, ref) => {
const accordionContext = React.useContext(AccordionContext);
if (!accordionContext) {
throw new Error("AccordionItem must be used within an Accordion");
}
const isOpen = Array.isArray(accordionContext.value)
? accordionContext.value.includes(value)
: accordionContext.value === value;
const contextValue = React.useMemo(
() => ({ value, isOpen }),
[value, isOpen]
);
return (
<AccordionItemContext.Provider value={contextValue}>
<div
ref={ref}
data-slot="accordion-item"
data-state={isOpen ? "open" : "closed"}
className={cn("border-b border-border", className)}
{...props}
>
{children}
</div>
</AccordionItemContext.Provider>
);
}
);
AccordionItem.displayName = "AccordionItem";
interface AccordionTriggerProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
const AccordionTrigger = React.forwardRef<
HTMLButtonElement,
AccordionTriggerProps
>(({ className, children, ...props }, ref) => {
const accordionContext = React.useContext(AccordionContext);
const itemContext = React.useContext(AccordionItemContext);
if (!accordionContext || !itemContext) {
throw new Error("AccordionTrigger must be used within an AccordionItem");
}
const { onValueChange } = accordionContext;
const { value, isOpen } = itemContext;
return (
<div data-slot="accordion-header" className="flex">
<button
ref={ref}
type="button"
data-slot="accordion-trigger"
data-state={isOpen ? "open" : "closed"}
aria-expanded={isOpen}
onClick={() => onValueChange(value)}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</button>
</div>
);
});
AccordionTrigger.displayName = "AccordionTrigger";
interface AccordionContentProps extends React.HTMLAttributes<HTMLDivElement> {}
const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>(
({ className, children, ...props }, ref) => {
const itemContext = React.useContext(AccordionItemContext);
const contentRef = React.useRef<HTMLDivElement>(null);
const [height, setHeight] = React.useState<number | undefined>(undefined);
if (!itemContext) {
throw new Error("AccordionContent must be used within an AccordionItem");
}
const { isOpen } = itemContext;
React.useEffect(() => {
if (contentRef.current) {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setHeight(entry.contentRect.height);
}
});
resizeObserver.observe(contentRef.current);
return () => resizeObserver.disconnect();
}
}, []);
return (
<div
data-slot="accordion-content"
data-state={isOpen ? "open" : "closed"}
className="overflow-hidden text-sm transition-all duration-200 ease-out"
style={{
height: isOpen ? (height !== undefined ? `${height}px` : "auto") : 0,
opacity: isOpen ? 1 : 0,
}}
{...props}
>
<div ref={contentRef}>
<div ref={ref} className={cn("pb-4 pt-0", className)}>
{children}
</div>
</div>
</div>
);
}
);
AccordionContent.displayName = "AccordionContent";
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -4,21 +4,42 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shadow-sm",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
"border-transparent bg-primary text-primary-foreground hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
"border-transparent bg-destructive text-white hover:bg-destructive/90",
outline:
"text-foreground border-border bg-background/50 backdrop-blur-sm",
// Semantic status variants using CSS variables
success:
"border-transparent bg-[var(--status-success-bg)] text-[var(--status-success)] border border-[var(--status-success)]/30",
warning:
"border-transparent bg-[var(--status-warning-bg)] text-[var(--status-warning)] border border-[var(--status-warning)]/30",
error:
"border-transparent bg-[var(--status-error-bg)] text-[var(--status-error)] border border-[var(--status-error)]/30",
info:
"border-transparent bg-[var(--status-info-bg)] text-[var(--status-info)] border border-[var(--status-info)]/30",
// Muted variants for subtle indication
muted:
"border-border/50 bg-muted/50 text-muted-foreground",
brand:
"border-transparent bg-brand-500/15 text-brand-500 border border-brand-500/30",
},
size: {
default: "px-2.5 py-0.5 text-xs",
sm: "px-2 py-0.5 text-[10px]",
lg: "px-3 py-1 text-sm",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
@@ -27,9 +48,9 @@ export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
function Badge({ className, variant, size, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
<div className={cn(badgeVariants({ variant, size }), className)} {...props} />
);
}

View File

@@ -1,24 +1,26 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default:
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md hover:shadow-primary/25",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"bg-destructive text-white shadow-sm hover:bg-destructive/90 hover:shadow-md hover:shadow-destructive/25 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
link: "text-primary underline-offset-4 hover:underline active:scale-100",
"animated-outline":
"relative overflow-hidden rounded-xl hover:bg-transparent shadow-none",
},
@@ -38,17 +40,32 @@ const buttonVariants = cva(
}
);
// Loading spinner component
function ButtonSpinner({ className }: { className?: string }) {
return (
<Loader2
className={cn("size-4 animate-spin", className)}
aria-hidden="true"
/>
);
}
function Button({
className,
variant,
size,
asChild = false,
loading = false,
disabled,
children,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
loading?: boolean;
}) {
const isDisabled = disabled || loading;
// Special handling for animated-outline variant
if (variant === "animated-outline" && !asChild) {
return (
@@ -59,20 +76,22 @@ function Button({
className
)}
data-slot="button"
disabled={isDisabled}
{...props}
>
{/* Animated rotating gradient border */}
<span className="absolute inset-[-1000%] animate-[spin_2s_linear_infinite] animated-outline-gradient" />
{/* Animated rotating gradient border - smoother animation */}
<span className="absolute inset-[-1000%] animate-[spin_3s_linear_infinite] animated-outline-gradient opacity-75 transition-opacity duration-300 group-hover:opacity-100" />
{/* Inner content container */}
<span
className={cn(
"animated-outline-inner inline-flex h-full w-full cursor-pointer items-center justify-center gap-2 rounded-[10px] px-4 py-1 text-sm font-medium backdrop-blur-3xl transition-all",
"animated-outline-inner inline-flex h-full w-full cursor-pointer items-center justify-center gap-2 rounded-[10px] px-4 py-1 text-sm font-medium backdrop-blur-3xl transition-all duration-200",
size === "sm" && "px-3 text-xs gap-1.5",
size === "lg" && "px-8",
size === "icon" && "p-0 gap-0"
)}
>
{loading && <ButtonSpinner />}
{children}
</span>
</button>
@@ -85,8 +104,10 @@ function Button({
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
disabled={isDisabled}
{...props}
>
{loading && <ButtonSpinner />}
{children}
</Comp>
);

View File

@@ -2,12 +2,20 @@ import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
interface CardProps extends React.ComponentProps<"div"> {
gradient?: boolean;
}
function Card({ className, gradient = false, ...props }: CardProps) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border border-white/10 backdrop-blur-sm py-6 shadow-sm",
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border border-white/10 backdrop-blur-md py-6",
// Premium layered shadow
"shadow-[0_1px_2px_rgba(0,0,0,0.05),0_4px_6px_rgba(0,0,0,0.05),0_10px_20px_rgba(0,0,0,0.04)]",
// Gradient border option
gradient && "relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10",
className
)}
{...props}
@@ -20,7 +28,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
@@ -32,7 +40,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
className={cn("leading-none font-semibold tracking-tight", className)}
{...props}
/>
);
@@ -42,7 +50,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
className={cn("text-muted-foreground text-sm leading-relaxed", className)}
{...props}
/>
);
@@ -75,7 +83,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
className={cn("flex items-center gap-3 px-6 [.border-t]:pt-6", className)}
{...props}
/>
);

View File

@@ -6,25 +6,37 @@ import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
interface CheckboxProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "checked" | "defaultChecked"> {
checked?: boolean | "indeterminate";
defaultChecked?: boolean | "indeterminate";
onCheckedChange?: (checked: boolean) => void;
required?: boolean;
}
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
({ className, onCheckedChange, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80",
className
)}
onCheckedChange={(checked) => {
// Handle indeterminate state by treating it as false for consumers expecting boolean
if (onCheckedChange) {
onCheckedChange(checked === true);
}
}}
{...props}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
);
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -38,7 +38,10 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"duration-200",
className
)}
{...props}
@@ -66,7 +69,17 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200 max-h-[calc(100vh-4rem)]",
"fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
"flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-4rem)]",
"bg-card border border-border rounded-xl shadow-2xl",
// Premium shadow
"shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]",
// Animations - smoother with scale
"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-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]",
"duration-200",
compact
? "max-w-4xl p-4"
: !hasCustomMaxWidth
@@ -81,8 +94,13 @@ function DialogContent({
<DialogPrimitive.Close
data-slot="dialog-close"
className={cn(
"ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute rounded-xs opacity-70 transition-opacity cursor-pointer hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
compact ? "top-2 right-3" : "top-3 right-5"
"absolute rounded-lg opacity-60 transition-all duration-200 cursor-pointer",
"hover:opacity-100 hover:bg-muted",
"focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none",
"disabled:pointer-events-none disabled:cursor-not-allowed",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4",
"p-1.5",
compact ? "top-2 right-3" : "top-4 right-4"
)}
>
<XIcon />
@@ -109,7 +127,7 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end mt-6",
className
)}
{...props}
@@ -124,7 +142,7 @@ function DialogTitle({
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
className={cn("text-lg leading-none font-semibold tracking-tight", className)}
{...props}
/>
);
@@ -137,7 +155,7 @@ function DialogDescription({
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
className={cn("text-muted-foreground text-sm leading-relaxed", className)}
{...props}
/>
);

View File

@@ -43,14 +43,16 @@ const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName

View File

@@ -2,20 +2,64 @@ import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
interface InputProps extends React.ComponentProps<"input"> {
startAddon?: React.ReactNode;
endAddon?: React.ReactNode;
}
function Input({ className, type, startAddon, endAddon, ...props }: InputProps) {
const hasAddons = startAddon || endAddon;
const inputElement = (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-input border-input h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground bg-input border-border h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
// Inner shadow for depth
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
// Animated focus ring
"transition-[color,box-shadow,border-color] duration-200 ease-out",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
// Adjust padding for addons
startAddon && "pl-0",
endAddon && "pr-0",
hasAddons && "border-0 shadow-none focus-visible:ring-0",
className
)}
{...props}
/>
)
);
if (!hasAddons) {
return inputElement;
}
return (
<div
className={cn(
"flex items-center h-9 w-full rounded-md border border-border bg-input shadow-xs",
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
"transition-[box-shadow,border-color] duration-200 ease-out",
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
"has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed",
"has-[input[aria-invalid]]:ring-destructive/20 has-[input[aria-invalid]]:border-destructive"
)}
>
{startAddon && (
<span className="flex items-center justify-center px-3 text-muted-foreground text-sm">
{startAddon}
</span>
)}
{inputElement}
{endAddon && (
<span className="flex items-center justify-center px-3 text-muted-foreground text-sm">
{endAddon}
</span>
)}
</div>
);
}
export { Input }

View File

@@ -1,39 +1,43 @@
"use client"
"use client";
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
interface SheetOverlayProps extends React.HTMLAttributes<HTMLDivElement> {
forceMount?: true;
}
const SheetOverlay = ({ className, ...props }: SheetOverlayProps) => {
const Overlay = SheetPrimitive.Overlay as React.ComponentType<
SheetOverlayProps & { "data-slot": string }
>;
return (
<SheetPrimitive.Overlay
<Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
@@ -41,21 +45,35 @@ function SheetOverlay({
)}
{...props}
/>
)
);
};
interface SheetContentProps extends React.HTMLAttributes<HTMLDivElement> {
side?: "top" | "right" | "bottom" | "left";
forceMount?: true;
onEscapeKeyDown?: (event: KeyboardEvent) => void;
onPointerDownOutside?: (event: PointerEvent) => void;
onInteractOutside?: (event: Event) => void;
}
function SheetContent({
const SheetContent = ({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
}: SheetContentProps) => {
const Content = SheetPrimitive.Content as React.ComponentType<
SheetContentProps & { "data-slot": string }
>;
const Close = SheetPrimitive.Close as React.ComponentType<{
className: string;
children: React.ReactNode;
}>;
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
<Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
@@ -72,14 +90,14 @@ function SheetContent({
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</Close>
</Content>
</SheetPortal>
)
}
);
};
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
@@ -88,7 +106,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -98,34 +116,39 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
interface SheetTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
const SheetTitle = ({ className, ...props }: SheetTitleProps) => {
const Title = SheetPrimitive.Title as React.ComponentType<
SheetTitleProps & { "data-slot": string }
>;
return (
<SheetPrimitive.Title
<Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
);
};
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
interface SheetDescriptionProps
extends React.HTMLAttributes<HTMLParagraphElement> {}
const SheetDescription = ({ className, ...props }: SheetDescriptionProps) => {
const Description = SheetPrimitive.Description as React.ComponentType<
SheetDescriptionProps & { "data-slot": string }
>;
return (
<SheetPrimitive.Description
<Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
);
};
export {
Sheet,
@@ -136,4 +159,4 @@ export {
SheetFooter,
SheetTitle,
SheetDescription,
}
};

View File

@@ -4,24 +4,38 @@ import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ComponentRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
<SliderPrimitive.Range className="slider-range absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
</SliderPrimitive.Root>
));
interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "defaultValue" | "dir"> {
value?: number[];
defaultValue?: number[];
onValueChange?: (value: number[]) => void;
onValueCommit?: (value: number[]) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
orientation?: "horizontal" | "vertical";
dir?: "ltr" | "rtl";
inverted?: boolean;
minStepsBetweenThumbs?: number;
}
const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
<SliderPrimitive.Range className="slider-range absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
</SliderPrimitive.Root>
)
);
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -7,7 +7,11 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input min-h-[80px] w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none",
"placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-border min-h-[80px] w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none",
// Inner shadow for depth
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
// Animated focus ring
"transition-[color,box-shadow,border-color] duration-200 ease-out",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className

View File

@@ -14,13 +14,23 @@ const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
>(({ className, sideOffset = 6, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 overflow-hidden rounded-lg border border-border bg-popover px-3 py-1.5 text-xs font-medium text-popover-foreground",
// Premium shadow
"shadow-lg shadow-black/10",
// Faster, snappier animations
"animate-in fade-in-0 zoom-in-95 duration-150",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-100",
// Slide from edge
"data-[side=bottom]:slide-in-from-top-1",
"data-[side=left]:slide-in-from-right-1",
"data-[side=right]:slide-in-from-left-1",
"data-[side=top]:slide-in-from-bottom-1",
className
)}
{...props}

View File

@@ -1,6 +1,11 @@
"use client";
import { useRef, useCallback, useMemo } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { xml } from "@codemirror/lang-xml";
import { EditorView } from "@codemirror/view";
import { Extension } from "@codemirror/state";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags as t } from "@lezer/highlight";
import { cn } from "@/lib/utils";
interface XmlSyntaxEditorProps {
@@ -11,199 +16,78 @@ interface XmlSyntaxEditorProps {
"data-testid"?: string;
}
// Tokenize XML content into parts for highlighting
interface Token {
type:
| "tag-bracket"
| "tag-name"
| "attribute-name"
| "attribute-equals"
| "attribute-value"
| "text"
| "comment"
| "cdata"
| "doctype";
value: string;
}
// Syntax highlighting that uses CSS variables from the app's theme system
// This automatically adapts to any theme (dark, light, dracula, nord, etc.)
const syntaxColors = HighlightStyle.define([
// XML tags - use primary color
{ tag: t.tagName, color: "var(--primary)" },
{ tag: t.angleBracket, color: "var(--muted-foreground)" },
function tokenizeXml(text: string): Token[] {
const tokens: Token[] = [];
let i = 0;
// Attributes
{ tag: t.attributeName, color: "var(--chart-2, oklch(0.6 0.118 184.704))" },
{ tag: t.attributeValue, color: "var(--chart-1, oklch(0.646 0.222 41.116))" },
while (i < text.length) {
// Comment: <!-- ... -->
if (text.slice(i, i + 4) === "<!--") {
const end = text.indexOf("-->", i + 4);
if (end !== -1) {
tokens.push({ type: "comment", value: text.slice(i, end + 3) });
i = end + 3;
continue;
}
}
// Strings and content
{ tag: t.string, color: "var(--chart-1, oklch(0.646 0.222 41.116))" },
{ tag: t.content, color: "var(--foreground)" },
// CDATA: <![CDATA[ ... ]]>
if (text.slice(i, i + 9) === "<![CDATA[") {
const end = text.indexOf("]]>", i + 9);
if (end !== -1) {
tokens.push({ type: "cdata", value: text.slice(i, end + 3) });
i = end + 3;
continue;
}
}
// Comments
{ tag: t.comment, color: "var(--muted-foreground)", fontStyle: "italic" },
// DOCTYPE: <!DOCTYPE ... >
if (text.slice(i, i + 9).toUpperCase() === "<!DOCTYPE") {
const end = text.indexOf(">", i + 9);
if (end !== -1) {
tokens.push({ type: "doctype", value: text.slice(i, end + 1) });
i = end + 1;
continue;
}
}
// Special
{ tag: t.processingInstruction, color: "var(--muted-foreground)" },
{ tag: t.documentMeta, color: "var(--muted-foreground)" },
]);
// Tag: < ... >
if (text[i] === "<") {
// Find the end of the tag
let tagEnd = i + 1;
let inString: string | null = null;
// Editor theme using CSS variables
const editorTheme = EditorView.theme({
"&": {
height: "100%",
fontSize: "0.875rem",
fontFamily: "ui-monospace, monospace",
backgroundColor: "transparent",
color: "var(--foreground)",
},
".cm-scroller": {
overflow: "auto",
fontFamily: "ui-monospace, monospace",
},
".cm-content": {
padding: "1rem",
minHeight: "100%",
caretColor: "var(--primary)",
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "var(--primary)",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
{
backgroundColor: "oklch(0.55 0.25 265 / 0.3)",
},
".cm-activeLine": {
backgroundColor: "transparent",
},
".cm-line": {
padding: "0",
},
"&.cm-focused": {
outline: "none",
},
".cm-gutters": {
display: "none",
},
".cm-placeholder": {
color: "var(--muted-foreground)",
fontStyle: "italic",
},
});
while (tagEnd < text.length) {
const char = text[tagEnd];
if (inString) {
if (char === inString && text[tagEnd - 1] !== "\\") {
inString = null;
}
} else {
if (char === '"' || char === "'") {
inString = char;
} else if (char === ">") {
tagEnd++;
break;
}
}
tagEnd++;
}
const tagContent = text.slice(i, tagEnd);
const tagTokens = tokenizeTag(tagContent);
tokens.push(...tagTokens);
i = tagEnd;
continue;
}
// Text content between tags
const nextTag = text.indexOf("<", i);
if (nextTag === -1) {
tokens.push({ type: "text", value: text.slice(i) });
break;
} else if (nextTag > i) {
tokens.push({ type: "text", value: text.slice(i, nextTag) });
i = nextTag;
}
}
return tokens;
}
function tokenizeTag(tag: string): Token[] {
const tokens: Token[] = [];
let i = 0;
// Opening bracket (< or </ or <?)
if (tag.startsWith("</")) {
tokens.push({ type: "tag-bracket", value: "</" });
i = 2;
} else if (tag.startsWith("<?")) {
tokens.push({ type: "tag-bracket", value: "<?" });
i = 2;
} else {
tokens.push({ type: "tag-bracket", value: "<" });
i = 1;
}
// Skip whitespace
while (i < tag.length && /\s/.test(tag[i])) {
tokens.push({ type: "text", value: tag[i] });
i++;
}
// Tag name
let tagName = "";
while (i < tag.length && /[a-zA-Z0-9_:-]/.test(tag[i])) {
tagName += tag[i];
i++;
}
if (tagName) {
tokens.push({ type: "tag-name", value: tagName });
}
// Attributes and closing
while (i < tag.length) {
// Skip whitespace
if (/\s/.test(tag[i])) {
let ws = "";
while (i < tag.length && /\s/.test(tag[i])) {
ws += tag[i];
i++;
}
tokens.push({ type: "text", value: ws });
continue;
}
// Closing bracket
if (tag[i] === ">" || tag.slice(i, i + 2) === "/>" || tag.slice(i, i + 2) === "?>") {
tokens.push({ type: "tag-bracket", value: tag.slice(i) });
break;
}
// Attribute name
let attrName = "";
while (i < tag.length && /[a-zA-Z0-9_:-]/.test(tag[i])) {
attrName += tag[i];
i++;
}
if (attrName) {
tokens.push({ type: "attribute-name", value: attrName });
}
// Skip whitespace around =
while (i < tag.length && /\s/.test(tag[i])) {
tokens.push({ type: "text", value: tag[i] });
i++;
}
// Equals sign
if (tag[i] === "=") {
tokens.push({ type: "attribute-equals", value: "=" });
i++;
}
// Skip whitespace after =
while (i < tag.length && /\s/.test(tag[i])) {
tokens.push({ type: "text", value: tag[i] });
i++;
}
// Attribute value
if (tag[i] === '"' || tag[i] === "'") {
const quote = tag[i];
let value = quote;
i++;
while (i < tag.length && tag[i] !== quote) {
value += tag[i];
i++;
}
if (i < tag.length) {
value += tag[i];
i++;
}
tokens.push({ type: "attribute-value", value });
}
}
return tokens;
}
// Combine all extensions
const extensions: Extension[] = [
xml(),
syntaxHighlighting(syntaxColors),
editorTheme,
];
export function XmlSyntaxEditor({
value,
@@ -212,78 +96,24 @@ export function XmlSyntaxEditor({
className,
"data-testid": testId,
}: XmlSyntaxEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
// Sync scroll between textarea and highlight layer
const handleScroll = useCallback(() => {
if (textareaRef.current && highlightRef.current) {
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
}
}, []);
// Handle tab key for indentation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Tab") {
e.preventDefault();
const textarea = e.currentTarget;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newValue =
value.substring(0, start) + " " + value.substring(end);
onChange(newValue);
// Reset cursor position after state update
requestAnimationFrame(() => {
textarea.selectionStart = textarea.selectionEnd = start + 2;
});
}
},
[value, onChange]
);
// Memoize the highlighted content
const highlightedContent = useMemo(() => {
const tokens = tokenizeXml(value);
return tokens.map((token, index) => {
const className = `xml-${token.type}`;
// React handles escaping automatically, just render the raw value
return (
<span key={index} className={className}>
{token.value}
</span>
);
});
}, [value]);
return (
<div className={cn("relative w-full h-full xml-editor", className)}>
{/* Syntax highlighted layer (read-only, behind textarea) */}
<div
ref={highlightRef}
className="absolute inset-0 overflow-auto pointer-events-none font-mono text-sm p-4 whitespace-pre-wrap break-words"
aria-hidden="true"
>
{value ? (
<code className="xml-highlight">{highlightedContent}</code>
) : (
<span className="text-muted-foreground opacity-50">{placeholder}</span>
)}
</div>
{/* Actual textarea (transparent text, handles input) */}
<textarea
ref={textareaRef}
<div className={cn("w-full h-full", className)} data-testid={testId}>
<CodeMirror
value={value}
onChange={(e) => onChange(e.target.value)}
onScroll={handleScroll}
onKeyDown={handleKeyDown}
placeholder=""
spellCheck={false}
className="absolute inset-0 w-full h-full font-mono text-sm p-4 bg-transparent resize-none focus:outline-none text-transparent caret-foreground selection:bg-primary/30"
data-testid={testId}
onChange={onChange}
extensions={extensions}
theme="none"
placeholder={placeholder}
className="h-full [&_.cm-editor]:h-full"
basicSetup={{
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightSelectionMatches: true,
autocompletion: true,
bracketMatching: true,
indentOnInput: true,
}}
/>
</div>
);

View File

@@ -2,7 +2,6 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useAppStore } from "@/store/app-store";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ImageDropZone } from "@/components/ui/image-drop-zone";
@@ -222,11 +221,6 @@ export function AgentView() {
e.stopPropagation();
if (isProcessing || !isConnected) return;
console.log(
"[agent-view] Drag enter types:",
Array.from(e.dataTransfer.types)
);
// Check if dragged items contain files
if (e.dataTransfer.types.includes("Files")) {
setIsDragOver(true);
@@ -262,39 +256,21 @@ export function AgentView() {
if (isProcessing || !isConnected) return;
console.log("[agent-view] Drop event:", {
filesCount: e.dataTransfer.files.length,
itemsCount: e.dataTransfer.items.length,
types: Array.from(e.dataTransfer.types),
});
// Check if we have files
const files = e.dataTransfer.files;
if (files && files.length > 0) {
console.log("[agent-view] Processing files from dataTransfer.files");
processDroppedFiles(files);
return;
}
// Handle file paths (from screenshots or other sources)
// This is common on macOS when dragging screenshots
const items = e.dataTransfer.items;
if (items && items.length > 0) {
console.log("[agent-view] Processing items");
for (let i = 0; i < items.length; i++) {
const item = items[i];
console.log(`[agent-view] Item ${i}:`, {
kind: item.kind,
type: item.type,
});
if (item.kind === "file") {
const file = item.getAsFile();
if (file) {
console.log("[agent-view] Got file from item:", {
name: file.name,
type: file.type,
size: file.size,
});
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
processDroppedFiles(dataTransfer.files);
@@ -315,10 +291,6 @@ export function AgentView() {
for (let i = 0; i < items.length; i++) {
const item = items[i];
console.log("[agent-view] Paste item:", {
kind: item.kind,
type: item.type,
});
if (item.kind === "file") {
const file = item.getAsFile();
@@ -330,10 +302,6 @@ export function AgentView() {
}
if (files.length > 0) {
console.log(
"[agent-view] Processing pasted image files:",
files.length
);
const dataTransfer = new DataTransfer();
files.forEach((file) => dataTransfer.items.add(file));
await processDroppedFiles(dataTransfer.files);
@@ -442,13 +410,15 @@ export function AgentView() {
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center"
className="flex-1 flex items-center justify-center bg-background"
data-testid="agent-view-no-project"
>
<div className="text-center">
<Sparkles className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project Selected</h2>
<p className="text-muted-foreground">
<div className="text-center max-w-md">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-6">
<Sparkles className="w-8 h-8 text-primary" />
</div>
<h2 className="text-xl font-semibold mb-3 text-foreground">No Project Selected</h2>
<p className="text-muted-foreground leading-relaxed">
Open or create a project to start working with the AI agent.
</p>
</div>
@@ -472,12 +442,12 @@ export function AgentView() {
return (
<div
className="flex-1 flex overflow-hidden content-bg"
className="flex-1 flex overflow-hidden bg-background"
data-testid="agent-view"
>
{/* Session Manager Sidebar */}
{showSessionManager && currentProject && (
<div className="w-80 border-r flex-shrink-0">
<div className="w-80 border-r border-border flex-shrink-0 bg-card/50">
<SessionManager
currentSessionId={currentSessionId}
onSelectSession={handleSelectSession}
@@ -491,13 +461,13 @@ export function AgentView() {
{/* Chat Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={() => setShowSessionManager(!showSessionManager)}
className="h-8 w-8 p-0"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
{showSessionManager ? (
<PanelLeftClose className="w-4 h-4" />
@@ -505,26 +475,28 @@ export function AgentView() {
<PanelLeft className="w-4 h-4" />
)}
</Button>
<Bot className="w-5 h-5 text-primary" />
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
<Bot className="w-5 h-5 text-primary" />
</div>
<div>
<h1 className="text-xl font-bold">AI Agent</h1>
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
<p className="text-sm text-muted-foreground">
{currentProject.name}
{currentSessionId && !isConnected && " · Connecting..."}
{currentSessionId && !isConnected && " - Connecting..."}
</p>
</div>
</div>
{/* Status indicators & actions */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-3">
{currentTool && (
<div className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
<Wrench className="w-3 h-3" />
<span>{currentTool}</span>
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border">
<Wrench className="w-3 h-3 text-primary" />
<span className="font-medium">{currentTool}</span>
</div>
)}
{agentError && (
<span className="text-xs text-destructive">{agentError}</span>
<span className="text-xs text-destructive font-medium">{agentError}</span>
)}
{currentSessionId && messages.length > 0 && (
<Button
@@ -532,8 +504,9 @@ export function AgentView() {
size="sm"
onClick={handleClearChat}
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground"
>
<Trash2 className="w-4 h-4 mr-1" />
<Trash2 className="w-4 h-4 mr-2" />
Clear
</Button>
)}
@@ -543,22 +516,25 @@ export function AgentView() {
{/* Messages */}
{!currentSessionId ? (
<div
className="flex-1 flex items-center justify-center"
className="flex-1 flex items-center justify-center bg-background"
data-testid="no-session-placeholder"
>
<div className="text-center">
<Bot className="w-12 h-12 text-muted-foreground mx-auto mb-4 opacity-50" />
<h2 className="text-lg font-semibold mb-2">
<div className="text-center max-w-md">
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mx-auto mb-6">
<Bot className="w-8 h-8 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold mb-3 text-foreground">
No Session Selected
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create or select a session to start chatting
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
Create or select a session to start chatting with the AI agent
</p>
<Button
onClick={() => setShowSessionManager(true)}
variant="outline"
className="gap-2"
>
<PanelLeft className="w-4 h-4 mr-2" />
<PanelLeft className="w-4 h-4" />
{showSessionManager ? "View" : "Show"} Sessions
</Button>
</div>
@@ -566,7 +542,7 @@ export function AgentView() {
) : (
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto p-4 space-y-4"
className="flex-1 overflow-y-auto px-6 py-6 space-y-6 scroll-smooth"
data-testid="message-list"
onScroll={handleScroll}
>
@@ -574,95 +550,156 @@ export function AgentView() {
<div
key={message.id}
className={cn(
"flex gap-3",
message.role === "user" && "flex-row-reverse"
"flex gap-4 max-w-4xl",
message.role === "user" ? "flex-row-reverse ml-auto" : ""
)}
>
{/* Avatar */}
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
message.role === "assistant" ? "bg-primary/10" : "bg-muted"
"w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm",
message.role === "assistant"
? "bg-primary/10 ring-1 ring-primary/20"
: "bg-muted ring-1 ring-border"
)}
>
{message.role === "assistant" ? (
<Bot className="w-4 h-4 text-primary" />
) : (
<User className="w-4 h-4" />
<User className="w-4 h-4 text-muted-foreground" />
)}
</div>
<Card
{/* Message Bubble */}
<div
className={cn(
"max-w-[80%] py-0",
"flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm",
message.role === "user"
? "bg-transparent border border-primary text-foreground"
: "border-l-4 border-primary bg-card"
? "bg-primary text-primary-foreground"
: "bg-card border border-border"
)}
>
<CardContent className="px-3 py-2">
{message.role === "assistant" ? (
<Markdown className="text-sm text-primary prose-headings:text-primary prose-strong:text-primary prose-code:text-primary">
{message.content}
</Markdown>
) : (
<p className="text-sm whitespace-pre-wrap">
{message.content}
</p>
)}
<p
className={cn(
"text-xs mt-1",
message.role === "user"
? "text-muted-foreground"
: "text-primary/70"
)}
>
{new Date(message.timestamp).toLocaleTimeString()}
{message.role === "assistant" ? (
<Markdown className="text-sm text-foreground prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-code:text-primary prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded">
{message.content}
</Markdown>
) : (
<p className="text-sm whitespace-pre-wrap leading-relaxed">
{message.content}
</p>
</CardContent>
</Card>
)}
<p
className={cn(
"text-[11px] mt-2 font-medium",
message.role === "user"
? "text-primary-foreground/70"
: "text-muted-foreground"
)}
>
{new Date(message.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</div>
))}
{/* Thinking Indicator */}
{isProcessing && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<div className="flex gap-4 max-w-4xl">
<div className="w-9 h-9 rounded-xl bg-primary/10 ring-1 ring-primary/20 flex items-center justify-center shrink-0 shadow-sm">
<Bot className="w-4 h-4 text-primary" />
</div>
<Card className="border-l-4 border-primary bg-card py-0">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="text-sm text-primary">
Thinking...
</span>
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" style={{ animationDelay: "0ms" }} />
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" style={{ animationDelay: "150ms" }} />
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" style={{ animationDelay: "300ms" }} />
</div>
</CardContent>
</Card>
<span className="text-sm text-muted-foreground">
Thinking...
</span>
</div>
</div>
</div>
)}
</div>
)}
{/* Input */}
{/* Input Area */}
{currentSessionId && (
<div className="border-t border-border p-4 space-y-3 bg-background">
<div className="border-t border-border p-4 bg-card/50 backdrop-blur-sm">
{/* Image Drop Zone (when visible) */}
{showImageDropZone && (
<ImageDropZone
onImagesSelected={handleImagesSelected}
images={selectedImages}
maxFiles={5}
className="mb-3"
className="mb-4"
disabled={isProcessing || !isConnected}
/>
)}
{/* Text Input and Controls - with drag and drop support */}
{/* Selected Images Preview */}
{selectedImages.length > 0 && (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{selectedImages.length} image
{selectedImages.length > 1 ? "s" : ""} attached
</p>
<button
onClick={() => setSelectedImages([])}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
disabled={isProcessing}
>
Clear all
</button>
</div>
<div className="flex flex-wrap gap-2">
{selectedImages.map((image) => (
<div
key={image.id}
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
>
{/* Image thumbnail */}
<div className="w-8 h-8 rounded-md overflow-hidden bg-muted flex-shrink-0">
<img
src={image.data}
alt={image.filename}
className="w-full h-full object-cover"
/>
</div>
{/* Image info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate max-w-24">
{image.filename}
</p>
<p className="text-[10px] text-muted-foreground">
{formatFileSize(image.size)}
</p>
</div>
{/* Remove button */}
<button
onClick={() => removeImage(image.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
disabled={isProcessing}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
</div>
)}
{/* Text Input and Controls */}
<div
className={cn(
"flex gap-2 transition-all duration-200 rounded-lg",
isDragOver &&
"bg-primary/10 ring-2 ring-primary ring-offset-2 ring-offset-background"
"flex gap-2 transition-all duration-200 rounded-xl p-1",
isDragOver && "bg-primary/5 ring-2 ring-primary/30"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
@@ -684,21 +721,19 @@ export function AgentView() {
disabled={isProcessing || !isConnected}
data-testid="agent-input"
className={cn(
"bg-input border-border",
selectedImages.length > 0 &&
"border-primary/50 bg-primary/5",
isDragOver &&
"border-primary bg-primary/10"
"h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all",
"focus:ring-2 focus:ring-primary/20 focus:border-primary/50",
selectedImages.length > 0 && "border-primary/30",
isDragOver && "border-primary bg-primary/5"
)}
/>
{selectedImages.length > 0 && !isDragOver && (
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 text-xs text-primary-foreground bg-primary px-2 py-1 rounded">
{selectedImages.length} image
{selectedImages.length > 1 ? "s" : ""}
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
{selectedImages.length} image{selectedImages.length > 1 ? "s" : ""}
</div>
)}
{isDragOver && (
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 text-xs text-primary-foreground bg-primary px-2 py-1 rounded flex items-center gap-1">
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-xs text-primary font-medium">
<Paperclip className="w-3 h-3" />
Drop here
</div>
@@ -708,13 +743,13 @@ export function AgentView() {
{/* Image Attachment Button */}
<Button
variant="outline"
size="default"
size="icon"
onClick={toggleImageDropZone}
disabled={isProcessing || !isConnected}
className={cn(
showImageDropZone &&
"bg-primary/20 text-primary border-primary",
selectedImages.length > 0 && "border-primary"
"h-11 w-11 rounded-xl border-border",
showImageDropZone && "bg-primary/10 text-primary border-primary/30",
selectedImages.length > 0 && "border-primary/30 text-primary"
)}
title="Attach images"
>
@@ -729,64 +764,17 @@ export function AgentView() {
isProcessing ||
!isConnected
}
className="h-11 px-4 rounded-xl"
data-testid="send-message"
>
<Send className="w-4 h-4" />
</Button>
</div>
{/* Selected Images Preview */}
{selectedImages.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{selectedImages.length} image
{selectedImages.length > 1 ? "s" : ""} attached
</p>
<button
onClick={() => setSelectedImages([])}
className="text-xs text-muted-foreground hover:text-foreground"
disabled={isProcessing}
>
Clear all
</button>
</div>
<div className="flex flex-wrap gap-2">
{selectedImages.map((image) => (
<div
key={image.id}
className="relative group rounded-md border border-muted bg-muted/50 p-2 flex items-center space-x-2"
>
{/* Image thumbnail */}
<div className="w-8 h-8 rounded overflow-hidden bg-muted flex-shrink-0">
<img
src={image.data}
alt={image.filename}
className="w-full h-full object-cover"
/>
</div>
{/* Image info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate">
{image.filename}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(image.size)}
</p>
</div>
{/* Remove button */}
<button
onClick={() => removeImage(image.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive hover:text-destructive-foreground text-muted-foreground"
disabled={isProcessing}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
</div>
)}
{/* Keyboard hint */}
<p className="text-[11px] text-muted-foreground mt-2 text-center">
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to send
</p>
</div>
)}
</div>

View File

@@ -345,6 +345,7 @@ export function AnalysisView() {
const techStack = detectTechStack();
// Generate the spec content
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
const specContent = `<project_specification>
<project_name>${projectName}</project_name>

View File

@@ -28,6 +28,7 @@ import {
} from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { cn, modelSupportsThinking } from "@/lib/utils";
import type { SpecRegenerationEvent } from "@/types/electron";
import {
Card,
CardDescription,
@@ -89,6 +90,8 @@ import {
Maximize2,
Shuffle,
ImageIcon,
Archive,
ArchiveRestore,
} from "lucide-react";
import { toast } from "sonner";
import { Slider } from "@/components/ui/slider";
@@ -109,11 +112,23 @@ import { useWindowState } from "@/hooks/use-window-state";
type ColumnId = Feature["status"];
const COLUMNS: { id: ColumnId; title: string; color: string }[] = [
{ id: "backlog", title: "Backlog", color: "bg-zinc-500" },
{ id: "in_progress", title: "In Progress", color: "bg-yellow-500" },
{ id: "waiting_approval", title: "Waiting Approval", color: "bg-orange-500" },
{ id: "verified", title: "Verified", color: "bg-green-500" },
const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
{ id: "backlog", title: "Backlog", colorClass: "bg-[var(--status-backlog)]" },
{
id: "in_progress",
title: "In Progress",
colorClass: "bg-[var(--status-in-progress)]",
},
{
id: "waiting_approval",
title: "Waiting Approval",
colorClass: "bg-[var(--status-waiting)]",
},
{
id: "verified",
title: "Verified",
colorClass: "bg-[var(--status-success)]",
},
];
type ModelOption = {
@@ -121,7 +136,7 @@ type ModelOption = {
label: string;
description: string;
badge?: string;
provider: "claude" | "codex";
provider: "claude";
};
const CLAUDE_MODELS: ModelOption[] = [
@@ -148,37 +163,6 @@ const CLAUDE_MODELS: ModelOption[] = [
},
];
const CODEX_MODELS: ModelOption[] = [
{
id: "gpt-5.1-codex-max",
label: "GPT-5.1 Codex Max",
description: "Flagship Codex model tuned for deep coding tasks.",
badge: "Flagship",
provider: "codex",
},
{
id: "gpt-5.1-codex",
label: "GPT-5.1 Codex",
description: "Strong coding performance with lower cost.",
badge: "Standard",
provider: "codex",
},
{
id: "gpt-5.1-codex-mini",
label: "GPT-5.1 Codex Mini",
description: "Fastest Codex option for lightweight edits.",
badge: "Fast",
provider: "codex",
},
{
id: "gpt-5.1",
label: "GPT-5.1",
description: "General-purpose reasoning with solid coding ability.",
badge: "General",
provider: "codex",
},
];
// Profile icon mapping
const PROFILE_ICONS: Record<
string,
@@ -210,6 +194,8 @@ export function BoardView() {
kanbanCardDetailLevel,
setKanbanCardDetailLevel,
boardBackgroundByProject,
specCreatingForProject,
setSpecCreatingForProject,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
@@ -236,6 +222,9 @@ export function BoardView() {
useState(false);
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
useState(false);
const [showCompletedModal, setShowCompletedModal] = useState(false);
const [deleteCompletedFeature, setDeleteCompletedFeature] =
useState<Feature | null>(null);
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
@@ -264,6 +253,9 @@ export function BoardView() {
const [searchQuery, setSearchQuery] = useState("");
// Validation state for add feature form
const [descriptionError, setDescriptionError] = useState(false);
// Derive spec creation state from store - check if current project is the one being created
const isCreatingSpec = specCreatingForProject === currentProject?.path;
const creatingSpecProjectPath = specCreatingForProject;
// Make current project available globally for modal
useEffect(() => {
@@ -295,6 +287,37 @@ export function BoardView() {
};
}, []);
// Subscribe to spec regeneration events to clear creating state on completion
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event) => {
console.log(
"[BoardView] Spec regeneration event:",
event.type,
"for project:",
event.projectPath
);
// Only handle completion/error events for the project being created
// The creating state is set by sidebar when user initiates the action
if (event.projectPath !== specCreatingForProject) {
return;
}
if (event.type === "spec_regeneration_complete") {
setSpecCreatingForProject(null);
} else if (event.type === "spec_regeneration_error") {
setSpecCreatingForProject(null);
}
});
return () => {
unsubscribe();
};
}, [specCreatingForProject, setSpecCreatingForProject]);
// Track previous project to detect switches
const prevProjectPathRef = useRef<string | null>(null);
const isSwitchingProjectRef = useRef<boolean>(false);
@@ -484,6 +507,30 @@ export function BoardView() {
}
}, [currentProject, setFeatures]);
// Subscribe to spec regeneration complete events to refresh kanban board
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event) => {
// Refresh the kanban board when spec regeneration completes for the current project
if (
event.type === "spec_regeneration_complete" &&
currentProject &&
event.projectPath === currentProject.path
) {
console.log(
"[BoardView] Spec regeneration complete, refreshing features"
);
loadFeatures();
}
});
return () => {
unsubscribe();
};
}, [currentProject, loadFeatures]);
// Load persisted categories from file
const loadCategories = useCallback(async () => {
if (!currentProject) return;
@@ -838,34 +885,12 @@ export function BoardView() {
// Same column, nothing to do
if (targetStatus === draggedFeature.status) return;
// Check concurrency limit before moving to in_progress (only for backlog -> in_progress and if running agent)
if (
targetStatus === "in_progress" &&
draggedFeature.status === "backlog" &&
!autoMode.canStartNewTask
) {
console.log("[Board] Cannot start new task - at max concurrency limit");
toast.error("Concurrency limit reached", {
description: `You can only have ${autoMode.maxConcurrency} task${
autoMode.maxConcurrency > 1 ? "s" : ""
} running at a time. Wait for a task to complete or increase the limit.`,
});
return;
}
// Handle different drag scenarios
if (draggedFeature.status === "backlog") {
// From backlog
if (targetStatus === "in_progress") {
// Update with startedAt timestamp
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);
// Use helper function to handle concurrency check and start implementation
await handleStartImplementation(draggedFeature);
} else {
moveFeature(featureId, targetStatus);
persistFeatureUpdate(featureId, { status: targetStatus });
@@ -1138,6 +1163,28 @@ export function BoardView() {
}
};
// Helper function to start implementing a feature (from backlog to in_progress)
const handleStartImplementation = async (feature: Feature) => {
if (!autoMode.canStartNewTask) {
toast.error("Concurrency limit reached", {
description: `You can only have ${autoMode.maxConcurrency} task${
autoMode.maxConcurrency > 1 ? "s" : ""
} running at a time. Wait for a task to complete or increase the limit.`,
});
return false;
}
const updates = {
status: "in_progress" as const,
startedAt: new Date().toISOString(),
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
console.log("[Board] Feature moved to in_progress, starting agent...");
await handleRunFeature(feature);
return true;
};
const handleVerifyFeature = async (feature: Feature) => {
if (!currentProject) return;
@@ -1491,6 +1538,47 @@ export function BoardView() {
}
};
// Complete a verified feature (move to completed/archived)
const handleCompleteFeature = (feature: Feature) => {
console.log("[Board] Completing feature:", {
id: feature.id,
description: feature.description,
});
const updates = {
status: "completed" as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.success("Feature completed", {
description: `Archived: ${feature.description.slice(0, 50)}${
feature.description.length > 50 ? "..." : ""
}`,
});
};
// Unarchive a completed feature (move back to verified)
const handleUnarchiveFeature = (feature: Feature) => {
console.log("[Board] Unarchiving feature:", {
id: feature.id,
description: feature.description,
});
const updates = {
status: "verified" as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.success("Feature restored", {
description: `Moved back to verified: ${feature.description.slice(
0,
50
)}${feature.description.length > 50 ? "..." : ""}`,
});
};
const checkContextExists = async (featureId: string): Promise<boolean> => {
if (!currentProject) return false;
@@ -1512,6 +1600,11 @@ export function BoardView() {
}
};
// Memoize completed features for the archive modal
const completedFeatures = useMemo(() => {
return features.filter((f) => f.status === "completed");
}, [features]);
// Memoize column features to prevent unnecessary re-renders
const columnFeaturesMap = useMemo(() => {
const map: Record<ColumnId, Feature[]> = {
@@ -1519,6 +1612,7 @@ export function BoardView() {
in_progress: [],
waiting_approval: [],
verified: [],
completed: [], // Completed features are shown in the archive modal, not as a column
};
// Filter features by search query (case-insensitive)
@@ -1527,7 +1621,7 @@ export function BoardView() {
? features.filter(
(f) =>
f.description.toLowerCase().includes(normalizedQuery) ||
f.category.toLowerCase().includes(normalizedQuery)
f.category?.toLowerCase().includes(normalizedQuery)
)
: features;
@@ -1548,6 +1642,13 @@ export function BoardView() {
}
});
// Sort backlog by priority: 1 (high) -> 2 (medium) -> 3 (low) -> no priority
map.backlog.sort((a, b) => {
const aPriority = a.priority ?? 999; // Features without priority go last
const bPriority = b.priority ?? 999;
return aPriority - bPriority;
});
return map;
}, [features, runningAutoTasks, searchQuery]);
@@ -1693,12 +1794,8 @@ export function BoardView() {
<div className="flex gap-2 flex-wrap">
{options.map((option) => {
const isSelected = selectedModel === option.id;
const isCodex = option.provider === "codex";
// Shorter display names for compact view
const shortName = option.label
.replace("Claude ", "")
.replace("GPT-5.1 Codex ", "")
.replace("GPT-5.1 ", "");
const shortName = option.label.replace("Claude ", "");
return (
<button
key={option.id}
@@ -1708,9 +1805,7 @@ export function BoardView() {
className={cn(
"flex-1 min-w-[80px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
isSelected
? isCodex
? "bg-emerald-600 text-white border-emerald-500"
: "bg-primary text-primary-foreground border-primary"
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`${testIdPrefix}-${option.id}`}
@@ -1828,34 +1923,50 @@ export function BoardView() {
<div className="flex-1 flex flex-col overflow-hidden">
{/* Search Bar Row */}
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
<div className="relative max-w-md flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
type="text"
placeholder="Search features by keyword..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-12 border-border"
data-testid="kanban-search-input"
/>
{searchQuery ? (
<button
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-sm hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
data-testid="kanban-search-clear"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
) : (
<span
className="absolute right-2 top-1/2 -translate-y-1/2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
data-testid="kanban-search-hotkey"
>
/
</span>
)}
<div className="relative max-w-md flex-1 flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
type="text"
placeholder="Search features by keyword..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-12 border-border"
data-testid="kanban-search-input"
/>
{searchQuery ? (
<button
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-sm hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
data-testid="kanban-search-clear"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
) : (
<span
className="absolute right-2 top-1/2 -translate-y-1/2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
data-testid="kanban-search-hotkey"
>
/
</span>
)}
</div>
{/* Spec Creation Loading Badge */}
{isCreatingSpec &&
currentProject?.path === creatingSpecProjectPath && (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-brand-500/10 border border-brand-500/20 shrink-0"
title="Creating App Specification"
data-testid="spec-creation-badge"
>
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
Creating spec
</span>
</div>
)}
</div>
{/* Board Background & Detail Level Controls */}
@@ -1880,6 +1991,31 @@ export function BoardView() {
</TooltipContent>
</Tooltip>
{/* Completed/Archived Features Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => setShowCompletedModal(true)}
className="h-8 px-2 relative"
data-testid="completed-features-button"
>
<Archive className="w-4 h-4" />
{completedFeatures.length > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
{completedFeatures.length > 99
? "99+"
: completedFeatures.length}
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Completed Features ({completedFeatures.length})</p>
</TooltipContent>
</Tooltip>
{/* Kanban Card Detail Level Toggle */}
<div
className="flex items-center rounded-lg bg-secondary border border-border"
@@ -1985,7 +2121,7 @@ export function BoardView() {
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex gap-4 h-full min-w-max">
<div className="flex gap-5 h-full min-w-max py-1">
{COLUMNS.map((column) => {
const columnFeatures = getColumnFeatures(column.id);
return (
@@ -1993,7 +2129,7 @@ export function BoardView() {
key={column.id}
id={column.id}
title={column.title}
color={column.color}
colorClass={column.colorClass}
count={columnFeatures.length}
opacity={backgroundSettings.columnOpacity}
showBorder={backgroundSettings.columnBorderEnabled}
@@ -2044,7 +2180,7 @@ export function BoardView() {
data-testid="start-next-button"
>
<FastForward className="w-3 h-3 mr-1" />
Pull Top
Make
</HotkeyButton>
)}
</div>
@@ -2084,6 +2220,12 @@ export function BoardView() {
onCommit={() => handleCommitFeature(feature)}
onRevert={() => handleRevertFeature(feature)}
onMerge={() => handleMergeFeature(feature)}
onComplete={() =>
handleCompleteFeature(feature)
}
onImplement={() =>
handleStartImplementation(feature)
}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(
feature.id
@@ -2108,14 +2250,19 @@ export function BoardView() {
})}
</div>
<DragOverlay>
<DragOverlay
dropAnimation={{
duration: 200,
easing: "cubic-bezier(0.18, 0.67, 0.6, 1.22)",
}}
>
{activeFeature && (
<Card className="w-72 opacity-90 rotate-3 shadow-xl">
<Card className="w-72 rotate-2 shadow-2xl shadow-black/25 border-primary/50 bg-card/95 backdrop-blur-sm transition-transform">
<CardHeader className="p-3">
<CardTitle className="text-sm">
<CardTitle className="text-sm font-medium line-clamp-2">
{activeFeature.description}
</CardTitle>
<CardDescription className="text-xs">
<CardDescription className="text-xs text-muted-foreground">
{activeFeature.category}
</CardDescription>
</CardHeader>
@@ -2134,6 +2281,136 @@ export function BoardView() {
onOpenChange={setShowBoardBackgroundModal}
/>
{/* Completed Features Modal */}
<Dialog open={showCompletedModal} onOpenChange={setShowCompletedModal}>
<DialogContent className="max-w-5xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Archive className="w-5 h-5 text-brand-500" />
Completed Features
</DialogTitle>
<DialogDescription>
{completedFeatures.length === 0
? "No completed features yet. Features you complete will appear here."
: `${completedFeatures.length} completed feature${
completedFeatures.length === 1 ? "" : "s"
}`}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4">
{completedFeatures.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Archive className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium">No completed features</p>
<p className="text-sm">
Complete features from the Verified column to archive them
here.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{completedFeatures.map((feature) => (
<Card
key={feature.id}
className="flex flex-col"
data-testid={`completed-card-${feature.id}`}
>
<CardHeader className="p-3 pb-2 flex-1">
<CardTitle className="text-sm leading-tight line-clamp-3">
{feature.description || feature.summary || feature.id}
</CardTitle>
<CardDescription className="text-xs mt-1 truncate">
{feature.category || "Uncategorized"}
</CardDescription>
</CardHeader>
<div className="p-3 pt-0 flex gap-2">
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-xs"
onClick={() => handleUnarchiveFeature(feature)}
data-testid={`unarchive-${feature.id}`}
>
<ArchiveRestore className="w-3 h-3 mr-1" />
Restore
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteCompletedFeature(feature)}
data-testid={`delete-completed-${feature.id}`}
title="Delete"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</Card>
))}
</div>
)}
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setShowCompletedModal(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Completed Feature Confirmation Dialog */}
<Dialog
open={!!deleteCompletedFeature}
onOpenChange={(open) => !open && setDeleteCompletedFeature(null)}
>
<DialogContent data-testid="delete-completed-confirmation-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<Trash2 className="w-5 h-5" />
Delete Feature
</DialogTitle>
<DialogDescription>
Are you sure you want to permanently delete this feature?
<span className="block mt-2 font-medium text-foreground">
&quot;{deleteCompletedFeature?.description?.slice(0, 100)}
{(deleteCompletedFeature?.description?.length ?? 0) > 100
? "..."
: ""}
&quot;
</span>
<span className="block mt-2 text-destructive font-medium">
This action cannot be undone.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDeleteCompletedFeature(null)}
data-testid="cancel-delete-completed-button"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={async () => {
if (deleteCompletedFeature) {
await handleDeleteFeature(deleteCompletedFeature.id);
setDeleteCompletedFeature(null);
}
}}
data-testid="confirm-delete-completed-button"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Feature Dialog */}
<Dialog
open={showAddDialog}
@@ -2270,7 +2547,6 @@ export function BoardView() {
const IconComponent = profile.icon
? PROFILE_ICONS[profile.icon]
: Brain;
const isCodex = profile.provider === "codex";
const isSelected =
newFeature.model === profile.model &&
newFeature.thinkingLevel === profile.thinkingLevel;
@@ -2284,13 +2560,6 @@ export function BoardView() {
model: profile.model,
thinkingLevel: profile.thinkingLevel,
});
if (profile.thinkingLevel === "ultrathink") {
toast.warning("Ultrathink Selected", {
description:
"Ultrathink uses extensive reasoning (45-180s, ~$0.48/task).",
duration: 4000,
});
}
}}
className={cn(
"flex items-center gap-2 p-2 rounded-lg border text-left transition-all",
@@ -2300,19 +2569,9 @@ export function BoardView() {
)}
data-testid={`profile-quick-select-${profile.id}`}
>
<div
className={cn(
"w-7 h-7 rounded flex items-center justify-center flex-shrink-0",
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
)}
>
<div className="w-7 h-7 rounded flex items-center justify-center flex-shrink-0 bg-primary/10">
{IconComponent && (
<IconComponent
className={cn(
"w-4 h-4",
isCodex ? "text-emerald-500" : "text-primary"
)}
/>
<IconComponent className="w-4 h-4 text-primary" />
)}
</div>
<div className="min-w-0 flex-1">
@@ -2401,13 +2660,6 @@ export function BoardView() {
...newFeature,
thinkingLevel: level,
});
if (level === "ultrathink") {
toast.warning("Ultrathink Selected", {
description:
"Ultrathink uses extensive reasoning (45-180s, ~$0.48/task). Best for complex architecture, migrations, or debugging.",
duration: 5000,
});
}
}}
className={cn(
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]",
@@ -2433,36 +2685,6 @@ export function BoardView() {
)}
</div>
)}
{/* Separator */}
{(!showProfilesOnly || showAdvancedOptions) && (
<div className="border-t border-border" />
)}
{/* Codex Models Section - Hidden when showProfilesOnly is true and showAdvancedOptions is false */}
{(!showProfilesOnly || showAdvancedOptions) && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Zap className="w-4 h-4 text-emerald-500" />
OpenAI via Codex CLI
</Label>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-emerald-500/50 text-emerald-600 dark:text-emerald-300">
CLI
</span>
</div>
{renderModelOptions(CODEX_MODELS, newFeature.model, (model) =>
setNewFeature({
...newFeature,
model,
thinkingLevel: "none",
})
)}
<p className="text-xs text-muted-foreground">
Codex models do not support thinking levels.
</p>
</div>
)}
</TabsContent>
{/* Testing Tab */}
@@ -2688,7 +2910,6 @@ export function BoardView() {
const IconComponent = profile.icon
? PROFILE_ICONS[profile.icon]
: Brain;
const isCodex = profile.provider === "codex";
const isSelected =
editingFeature.model === profile.model &&
editingFeature.thinkingLevel ===
@@ -2703,13 +2924,6 @@ export function BoardView() {
model: profile.model,
thinkingLevel: profile.thinkingLevel,
});
if (profile.thinkingLevel === "ultrathink") {
toast.warning("Ultrathink Selected", {
description:
"Ultrathink uses extensive reasoning (45-180s, ~$0.48/task).",
duration: 4000,
});
}
}}
className={cn(
"flex items-center gap-2 p-2 rounded-lg border text-left transition-all",
@@ -2719,21 +2933,9 @@ export function BoardView() {
)}
data-testid={`edit-profile-quick-select-${profile.id}`}
>
<div
className={cn(
"w-7 h-7 rounded flex items-center justify-center flex-shrink-0",
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
)}
>
<div className="w-7 h-7 rounded flex items-center justify-center flex-shrink-0 bg-primary/10">
{IconComponent && (
<IconComponent
className={cn(
"w-4 h-4",
isCodex
? "text-emerald-500"
: "text-primary"
)}
/>
<IconComponent className="w-4 h-4 text-primary" />
)}
</div>
<div className="min-w-0 flex-1">
@@ -2813,13 +3015,6 @@ export function BoardView() {
...editingFeature,
thinkingLevel: level,
});
if (level === "ultrathink") {
toast.warning("Ultrathink Selected", {
description:
"Ultrathink uses extensive reasoning (45-180s, ~$0.48/task). Best for complex architecture, migrations, or debugging.",
duration: 5000,
});
}
}}
className={cn(
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]",
@@ -2846,40 +3041,6 @@ export function BoardView() {
)}
</div>
)}
{/* Separator */}
{(!showProfilesOnly || showEditAdvancedOptions) && (
<div className="border-t border-border" />
)}
{/* Codex Models Section - Hidden when showProfilesOnly is true and showEditAdvancedOptions is false */}
{(!showProfilesOnly || showEditAdvancedOptions) && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Zap className="w-4 h-4 text-emerald-500" />
OpenAI via Codex CLI
</Label>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-emerald-500/50 text-emerald-600 dark:text-emerald-300">
CLI
</span>
</div>
{renderModelOptions(
CODEX_MODELS,
(editingFeature.model ?? "opus") as AgentModel,
(model) =>
setEditingFeature({
...editingFeature,
model,
thinkingLevel: "none",
}),
"edit-model-select"
)}
<p className="text-xs text-muted-foreground">
Codex models do not support thinking levels.
</p>
</div>
)}
</TabsContent>
{/* Testing Tab */}

View File

@@ -393,6 +393,7 @@ export function ContextView() {
className="flex-1 flex overflow-hidden"
onDrop={handleDrop}
onDragOver={handleDragOver}
data-testid="context-drop-zone"
>
{/* Left Panel - File List */}
<div className="w-64 border-r border-border flex flex-col overflow-hidden">

View File

@@ -19,6 +19,7 @@ import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron";
import { Markdown } from "@/components/ui/markdown";
import { useFileBrowser } from "@/contexts/file-browser-context";
import { toast } from "sonner";
interface InterviewMessage {
id: string;
@@ -247,6 +248,7 @@ export function InterviewView() {
.toLowerCase()
.replace(/[^a-z0-9-]/g, "");
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
return `<project_specification>
<project_name>${projectName || "my-project"}</project_name>
@@ -290,7 +292,8 @@ export function InterviewView() {
const handleSelectDirectory = async () => {
const selectedPath = await openFileBrowser({
title: "Select Base Directory",
description: "Choose the parent directory where your new project will be created",
description:
"Choose the parent directory where your new project will be created",
});
if (selectedPath) {
@@ -305,10 +308,24 @@ export function InterviewView() {
try {
const api = getElectronAPI();
const fullProjectPath = `${projectPath}/${projectName}`;
// Use platform-specific path separator
const pathSep =
typeof window !== "undefined" && (window as any).electronAPI
? navigator.platform.indexOf("Win") !== -1
? "\\"
: "/"
: "/";
const fullProjectPath = `${projectPath}${pathSep}${projectName}`;
// Create project directory
await api.mkdir(fullProjectPath);
const mkdirResult = await api.mkdir(fullProjectPath);
if (!mkdirResult.success) {
toast.error("Failed to create project directory", {
description: mkdirResult.error || "Unknown error occurred",
});
setIsGenerating(false);
return;
}
// Write app_spec.txt with generated content
await api.writeFile(

View File

@@ -57,6 +57,8 @@ import {
ChevronDown,
ChevronUp,
Brain,
Wand2,
Archive,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { getElectronAPI } from "@/lib/electron";
@@ -103,20 +105,16 @@ interface KanbanCardProps {
onCommit?: () => void;
onRevert?: () => void;
onMerge?: () => void;
onImplement?: () => void;
onComplete?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
shortcutKey?: string;
/** Context content for extracting progress info */
contextContent?: string;
/** Feature summary from agent completion */
summary?: string;
/** Opacity percentage (0-100) */
opacity?: number;
/** Whether to use glassmorphism (backdrop-blur) effect */
glassmorphism?: boolean;
/** Whether to show card borders */
cardBorderEnabled?: boolean;
/** Card border opacity percentage (0-100) */
cardBorderOpacity?: number;
}
@@ -134,6 +132,8 @@ export const KanbanCard = memo(function KanbanCard({
onCommit,
onRevert,
onMerge,
onImplement,
onComplete,
hasContext,
isCurrentAutoTask,
shortcutKey,
@@ -152,16 +152,13 @@ export const KanbanCard = memo(function KanbanCard({
const [currentTime, setCurrentTime] = useState(() => Date.now());
const { kanbanCardDetailLevel } = useAppStore();
// Check if feature has worktree
const hasWorktree = !!feature.branchName;
// Helper functions to check what should be shown based on detail level
const showSteps =
kanbanCardDetailLevel === "standard" ||
kanbanCardDetailLevel === "detailed";
const showAgentInfo = kanbanCardDetailLevel === "detailed";
// Helper to check if "just finished" badge should be shown (within 2 minutes)
const isJustFinished = useMemo(() => {
if (
!feature.justFinishedAt ||
@@ -171,26 +168,23 @@ export const KanbanCard = memo(function KanbanCard({
return false;
}
const finishedTime = new Date(feature.justFinishedAt).getTime();
const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds
const twoMinutes = 2 * 60 * 1000;
return currentTime - finishedTime < twoMinutes;
}, [feature.justFinishedAt, feature.status, feature.error, currentTime]);
// Update current time periodically to check if badge should be hidden
useEffect(() => {
if (!feature.justFinishedAt || feature.status !== "waiting_approval") {
return;
}
const finishedTime = new Date(feature.justFinishedAt).getTime();
const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds
const twoMinutes = 2 * 60 * 1000;
const timeRemaining = twoMinutes - (currentTime - finishedTime);
if (timeRemaining <= 0) {
// Already past 2 minutes
return;
}
// Update time every second to check if 2 minutes have passed
const interval = setInterval(() => {
setCurrentTime(Date.now());
}, 1000);
@@ -198,17 +192,14 @@ export const KanbanCard = memo(function KanbanCard({
return () => clearInterval(interval);
}, [feature.justFinishedAt, feature.status, currentTime]);
// Load context file for in_progress, waiting_approval, and verified features
useEffect(() => {
const loadContext = async () => {
// Use provided context or load from file
if (contextContent) {
const info = parseAgentContext(contextContent);
setAgentInfo(info);
return;
}
// Only load for non-backlog features
if (feature.status === "backlog") {
setAgentInfo(null);
return;
@@ -220,7 +211,6 @@ export const KanbanCard = memo(function KanbanCard({
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) return;
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(
currentProject.path,
@@ -232,7 +222,6 @@ export const KanbanCard = memo(function KanbanCard({
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);
@@ -242,14 +231,12 @@ export const KanbanCard = memo(function KanbanCard({
}
}
} catch {
// Context file might not exist
console.debug("[KanbanCard] No context file for feature:", feature.id);
}
};
loadContext();
// Reload context periodically while feature is running
if (isCurrentAutoTask) {
const interval = setInterval(loadContext, 3000);
return () => clearInterval(interval);
@@ -265,12 +252,6 @@ export const KanbanCard = memo(function KanbanCard({
onDelete();
};
// Dragging logic:
// - Backlog items can always be dragged
// - skipTests items can be dragged even when in_progress or verified (unless currently running)
// - waiting_approval items can always be dragged (to allow manual verification via drag)
// - verified items can always be dragged (to allow moving back to waiting_approval or backlog)
// - Non-skipTests (TDD) items in progress cannot be dragged (they are running)
const isDraggable =
feature.status === "backlog" ||
feature.status === "waiting_approval" ||
@@ -294,15 +275,11 @@ export const KanbanCard = memo(function KanbanCard({
opacity: isDragging ? 0.5 : undefined,
};
// Calculate border style based on enabled state and opacity
const borderStyle: React.CSSProperties = { ...style };
if (!cardBorderEnabled) {
(borderStyle as Record<string, string>).borderWidth = "0px";
(borderStyle as Record<string, string>).borderColor = "transparent";
} else if (cardBorderOpacity !== 100) {
// Apply border opacity using color-mix to blend the border color with transparent
// The --border variable uses oklch format, so we use color-mix in oklch space
// Ensure border width is set (1px is the default Tailwind border width)
(borderStyle as Record<string, string>).borderWidth = "1px";
(
borderStyle as Record<string, string>
@@ -314,28 +291,27 @@ export const KanbanCard = memo(function KanbanCard({
ref={setNodeRef}
style={isCurrentAutoTask ? style : borderStyle}
className={cn(
"cursor-grab active:cursor-grabbing transition-all relative kanban-card-content select-none",
// Apply border class when border is enabled and opacity is 100%
// When opacity is not 100%, we use inline styles for border color
// Skip border classes when animated border is active (isCurrentAutoTask)
"cursor-grab active:cursor-grabbing relative kanban-card-content select-none",
"transition-all duration-200 ease-out",
// Premium shadow system
"shadow-sm hover:shadow-md hover:shadow-black/10",
// Subtle lift on hover
"hover:-translate-y-0.5",
!isCurrentAutoTask &&
cardBorderEnabled &&
cardBorderOpacity === 100 &&
"border-border",
// When border is enabled but opacity is not 100%, we still need border width
"border-border/50",
!isCurrentAutoTask &&
cardBorderEnabled &&
cardBorderOpacity !== 100 &&
"border",
// Remove default background when using opacity overlay
!isDragging && "bg-transparent",
// Remove default backdrop-blur-sm from Card component when glassmorphism is disabled
!glassmorphism && "backdrop-blur-[0px]!",
isDragging && "scale-105 shadow-lg",
// Error state border (only when not in progress)
isDragging && "scale-105 shadow-xl shadow-black/20 rotate-1",
// Error state - using CSS variable
feature.error &&
!isCurrentAutoTask &&
"border-red-500 border-2 shadow-red-500/30 shadow-lg",
"border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg",
!isDraggable && "cursor-default"
)}
data-testid={`kanban-card-${feature.id}`}
@@ -343,7 +319,7 @@ export const KanbanCard = memo(function KanbanCard({
{...attributes}
{...(isDraggable ? listeners : {})}
>
{/* Background overlay with opacity - only affects background, not content */}
{/* Background overlay with opacity */}
{!isDragging && (
<div
className={cn(
@@ -353,61 +329,78 @@ export const KanbanCard = memo(function KanbanCard({
style={{ opacity: opacity / 100 }}
/>
)}
{/* Skip Tests indicator badge */}
{/* Skip Tests (Manual) indicator badge */}
{feature.skipTests && !feature.error && (
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
"top-2 left-2",
"bg-orange-500/20 border border-orange-500/50 text-orange-400"
)}
data-testid={`skip-tests-badge-${feature.id}`}
title="Manual verification required"
>
<Hand className="w-3 h-3" />
<span>Manual</span>
</div>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
"top-2 left-2",
"bg-[var(--status-warning-bg)] border border-[var(--status-warning)]/40 text-[var(--status-warning)]"
)}
data-testid={`skip-tests-badge-${feature.id}`}
>
<Hand className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
<p>Manual verification required</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Error indicator badge */}
{feature.error && (
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
"top-2 left-2",
"bg-red-500/20 border border-red-500/50 text-red-400"
)}
data-testid={`error-badge-${feature.id}`}
title={feature.error}
>
<AlertCircle className="w-3 h-3" />
<span>Errored</span>
</div>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
"top-2 left-2",
"bg-[var(--status-error-bg)] border border-[var(--status-error)]/40 text-[var(--status-error)]"
)}
data-testid={`error-badge-${feature.id}`}
>
<AlertCircle className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs max-w-[250px]">
<p>{feature.error}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Just Finished indicator badge - shows when agent just completed work (for 2 minutes) */}
{/* Just Finished indicator badge */}
{isJustFinished && (
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
feature.skipTests ? "top-8 left-2" : "top-2 left-2",
"bg-green-500/20 border border-green-500/50 text-green-400 animate-pulse"
"bg-[var(--status-success-bg)] border border-[var(--status-success)]/40 text-[var(--status-success)]",
"animate-pulse"
)}
data-testid={`just-finished-badge-${feature.id}`}
title="Agent just finished working on this feature"
>
<Sparkles className="w-3 h-3" />
<span>Fresh Baked</span>
</div>
)}
{/* Branch badge - show when feature has a worktree */}
{/* Branch badge */}
{hasWorktree && !isCurrentAutoTask && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10 cursor-default",
"bg-purple-500/20 border border-purple-500/50 text-purple-400",
// Position below other badges if present, otherwise use normal position
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10 cursor-default",
"bg-[var(--status-info-bg)] border border-[var(--status-info)]/40 text-[var(--status-info)]",
feature.error || feature.skipTests || isJustFinished
? "top-8 left-2"
: "top-2 left-2"
@@ -415,9 +408,6 @@ export const KanbanCard = memo(function KanbanCard({
data-testid={`branch-badge-${feature.id}`}
>
<GitBranch className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[80px]">
{feature.branchName?.replace("feature/", "")}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[300px]">
@@ -428,71 +418,147 @@ export const KanbanCard = memo(function KanbanCard({
</Tooltip>
</TooltipProvider>
)}
<CardHeader
className={cn(
"p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout
// Add extra top padding when badges are present to prevent text overlap
"p-3 pb-2 block",
(feature.skipTests || feature.error || isJustFinished) && "pt-10",
// Add even more top padding when both badges and branch are shown
hasWorktree &&
(feature.skipTests || feature.error || isJustFinished) &&
"pt-14"
)}
>
{isCurrentAutoTask && (
<div className="absolute top-2 right-2 flex items-center justify-center gap-2 bg-running-indicator/20 border border-running-indicator rounded px-2 py-0.5">
<Loader2 className="w-4 h-4 text-running-indicator animate-spin" />
<span className="text-xs text-running-indicator font-medium">
<div className="absolute top-2 right-2 flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
<Loader2 className="w-3.5 h-3.5 text-[var(--status-in-progress)] animate-spin" />
<span className="text-[10px] text-[var(--status-in-progress)] font-medium">
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
{feature.startedAt && (
<CountUpTimer
startedAt={feature.startedAt}
className="text-running-indicator"
className="text-[var(--status-in-progress)] text-[10px]"
/>
)}
</div>
)}
{!isCurrentAutoTask && (
{!isCurrentAutoTask && feature.status === "backlog" && (
<div className="absolute top-2 right-2">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(e);
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-backlog-${feature.id}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
{!isCurrentAutoTask &&
(feature.status === "waiting_approval" ||
feature.status === "verified") && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`edit-${
feature.status === "waiting_approval" ? "waiting" : "verified"
}-${feature.id}`}
title="Edit"
>
<Edit className="w-4 h-4" />
</Button>
{onViewOutput && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`logs-${
feature.status === "waiting_approval"
? "waiting"
: "verified"
}-${feature.id}`}
title="Logs"
>
<FileText className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(e);
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-${
feature.status === "waiting_approval" ? "waiting" : "verified"
}-${feature.id}`}
title="Delete"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
{!isCurrentAutoTask && feature.status === "in_progress" && (
<div className="absolute top-2 right-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10"
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`menu-${feature.id}`}
>
<MoreVertical className="w-4 h-4" />
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
data-testid={`edit-feature-${feature.id}`}
className="text-xs"
>
<Edit className="w-3 h-3 mr-2" />
Edit
</DropdownMenuItem>
{onViewOutput && feature.status !== "backlog" && (
{onViewOutput && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
data-testid={`view-logs-${feature.id}`}
className="text-xs"
>
<FileText className="w-3 h-3 mr-2" />
Logs
View Logs
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
className="text-xs text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(e as unknown as React.MouseEvent);
@@ -509,22 +575,21 @@ export const KanbanCard = memo(function KanbanCard({
<div className="flex items-start gap-2">
{isDraggable && (
<div
className="-ml-2 -mt-1 p-2 touch-none"
className="-ml-2 -mt-1 p-2 touch-none opacity-40 hover:opacity-70 transition-opacity"
data-testid={`drag-handle-${feature.id}`}
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
<GripVertical className="w-3.5 h-3.5 text-muted-foreground" />
</div>
)}
<div className="flex-1 min-w-0 overflow-hidden">
<CardTitle
className={cn(
"text-sm leading-tight break-words hyphens-auto overflow-hidden",
"text-sm leading-snug break-words hyphens-auto overflow-hidden font-medium text-foreground/90",
!isDescriptionExpanded && "line-clamp-3"
)}
>
{feature.description || feature.summary || feature.id}
</CardTitle>
{/* Show More/Less toggle - only show when description is likely truncated */}
{(feature.description || feature.summary || "").length > 100 && (
<button
onClick={(e) => {
@@ -532,41 +597,42 @@ export const KanbanCard = memo(function KanbanCard({
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"
className="flex items-center gap-0.5 text-[10px] text-muted-foreground/70 hover:text-muted-foreground mt-1.5 transition-colors"
data-testid={`toggle-description-${feature.id}`}
>
{isDescriptionExpanded ? (
<>
<ChevronUp className="w-3 h-3" />
<span>Show Less</span>
<span>Less</span>
</>
) : (
<>
<ChevronDown className="w-3 h-3" />
<span>Show More</span>
<span>More</span>
</>
)}
</button>
)}
<CardDescription className="text-xs mt-1 truncate">
<CardDescription className="text-[11px] mt-1.5 truncate text-muted-foreground/70">
{feature.category}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-3 pt-0">
{/* Steps Preview - Show in Standard and Detailed modes */}
{/* Steps Preview */}
{showSteps && feature.steps && feature.steps.length > 0 && (
<div className="mb-3 space-y-1">
<div className="mb-3 space-y-1.5">
{feature.steps.slice(0, 3).map((step, index) => (
<div
key={index}
className="flex items-start gap-2 text-xs text-muted-foreground"
className="flex items-start gap-2 text-[11px] text-muted-foreground/80"
>
{feature.status === "verified" ? (
<CheckCircle2 className="w-3 h-3 mt-0.5 text-green-500 shrink-0" />
<CheckCircle2 className="w-3 h-3 mt-0.5 text-[var(--status-success)] shrink-0" />
) : (
<Circle className="w-3 h-3 mt-0.5 shrink-0" />
<Circle className="w-3 h-3 mt-0.5 shrink-0 text-muted-foreground/50" />
)}
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">
{step}
@@ -574,18 +640,18 @@ export const KanbanCard = memo(function KanbanCard({
</div>
))}
{feature.steps.length > 3 && (
<p className="text-xs text-muted-foreground pl-5">
+{feature.steps.length - 3} more steps
<p className="text-[10px] text-muted-foreground/60 pl-5">
+{feature.steps.length - 3} more
</p>
)}
</div>
)}
{/* Model/Preset Info for Backlog Cards - Show in Detailed mode */}
{/* Model/Preset Info for Backlog Cards */}
{showAgentInfo && feature.status === "backlog" && (
<div className="mb-3 space-y-2 overflow-hidden">
<div className="flex items-center gap-2 text-xs flex-wrap">
<div className="flex items-center gap-1 text-cyan-400">
<div className="flex items-center gap-2 text-[11px] flex-wrap">
<div className="flex items-center gap-1 text-[var(--status-info)]">
<Cpu className="w-3 h-3" />
<span className="font-medium">
{formatModelName(feature.model ?? DEFAULT_MODEL)}
@@ -603,13 +669,12 @@ export const KanbanCard = memo(function KanbanCard({
</div>
)}
{/* Agent Info Panel - shows for in_progress, waiting_approval, verified */}
{/* Detailed mode: Show all agent info */}
{/* Agent Info Panel */}
{showAgentInfo && feature.status !== "backlog" && agentInfo && (
<div className="mb-3 space-y-2 overflow-hidden">
{/* Model & Phase */}
<div className="flex items-center gap-2 text-xs flex-wrap">
<div className="flex items-center gap-1 text-cyan-400">
<div className="flex items-center gap-2 text-[11px] flex-wrap">
<div className="flex items-center gap-1 text-[var(--status-info)]">
<Cpu className="w-3 h-3" />
<span className="font-medium">
{formatModelName(feature.model ?? DEFAULT_MODEL)}
@@ -618,13 +683,13 @@ export const KanbanCard = memo(function KanbanCard({
{agentInfo.currentPhase && (
<div
className={cn(
"px-1.5 py-0.5 rounded text-[10px] font-medium",
"px-1.5 py-0.5 rounded-md text-[10px] font-medium",
agentInfo.currentPhase === "planning" &&
"bg-blue-500/20 text-blue-400",
"bg-[var(--status-info-bg)] text-[var(--status-info)]",
agentInfo.currentPhase === "action" &&
"bg-amber-500/20 text-amber-400",
"bg-[var(--status-warning-bg)] text-[var(--status-warning)]",
agentInfo.currentPhase === "verification" &&
"bg-green-500/20 text-green-400"
"bg-[var(--status-success-bg)] text-[var(--status-success)]"
)}
>
{agentInfo.currentPhase}
@@ -632,10 +697,10 @@ export const KanbanCard = memo(function KanbanCard({
)}
</div>
{/* Task List Progress (if todos found) */}
{/* Task List Progress */}
{agentInfo.todos.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
<ListTodo className="w-3 h-3" />
<span>
{
@@ -652,20 +717,21 @@ export const KanbanCard = memo(function KanbanCard({
className="flex items-center gap-1.5 text-[10px]"
>
{todo.status === "completed" ? (
<CheckCircle2 className="w-2.5 h-2.5 text-green-500 shrink-0" />
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === "in_progress" ? (
<Loader2 className="w-2.5 h-2.5 text-amber-400 animate-spin shrink-0" />
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground shrink-0" />
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}
<span
className={cn(
"break-words hyphens-auto line-clamp-2 leading-relaxed",
todo.status === "completed" &&
"text-muted-foreground line-through",
todo.status === "in_progress" && "text-amber-400",
"text-muted-foreground/60 line-through",
todo.status === "in_progress" &&
"text-[var(--status-warning)]",
todo.status === "pending" &&
"text-foreground-secondary"
"text-muted-foreground/80"
)}
>
{todo.content}
@@ -673,7 +739,7 @@ export const KanbanCard = memo(function KanbanCard({
</div>
))}
{agentInfo.todos.length > 3 && (
<p className="text-[10px] text-muted-foreground pl-4">
<p className="text-[10px] text-muted-foreground/60 pl-4">
+{agentInfo.todos.length - 3} more
</p>
)}
@@ -681,16 +747,16 @@ export const KanbanCard = memo(function KanbanCard({
</div>
)}
{/* Summary for waiting_approval and verified - prioritize feature.summary from UpdateFeatureStatus */}
{/* Summary for waiting_approval and verified */}
{(feature.status === "waiting_approval" ||
feature.status === "verified") && (
<>
{(feature.summary || summary || agentInfo.summary) && (
<div className="space-y-1 pt-1 border-t border-border-glass overflow-hidden">
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 text-[10px] text-green-400 min-w-0">
<div className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0">
<Sparkles className="w-3 h-3 shrink-0" />
<span className="truncate">Summary</span>
<span className="truncate font-medium">Summary</span>
</div>
<button
onClick={(e) => {
@@ -698,31 +764,30 @@ export const KanbanCard = memo(function KanbanCard({
setIsSummaryDialogOpen(true);
}}
onPointerDown={(e) => e.stopPropagation()}
className="p-0.5 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground shrink-0"
className="p-0.5 rounded-md hover:bg-muted/80 transition-colors text-muted-foreground/60 hover:text-muted-foreground shrink-0"
title="View full summary"
data-testid={`expand-summary-${feature.id}`}
>
<Expand className="w-3 h-3" />
</button>
</div>
<p className="text-[10px] text-foreground-secondary line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden">
<p className="text-[10px] text-muted-foreground/70 line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden">
{feature.summary || summary || agentInfo.summary}
</p>
</div>
)}
{/* Show tool count even without summary */}
{!feature.summary &&
!summary &&
!agentInfo.summary &&
agentInfo.toolCallCount > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground pt-1 border-t border-border-glass">
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
</span>
{agentInfo.todos.length > 0 && (
<span className="flex items-center gap-1">
<CheckCircle2 className="w-2.5 h-2.5 text-green-500" />
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
{
agentInfo.todos.filter(
(t) => t.status === "completed"
@@ -739,14 +804,14 @@ export const KanbanCard = memo(function KanbanCard({
)}
{/* Actions */}
<div className="flex gap-2">
<div className="flex gap-1.5">
{isCurrentAutoTask && (
<>
{onViewOutput && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-action-view hover:bg-action-view-hover"
className="flex-1 h-7 text-[11px] bg-[var(--status-info)] hover:bg-[var(--status-info)]/90"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
@@ -758,7 +823,7 @@ export const KanbanCard = memo(function KanbanCard({
Logs
{shortcutKey && (
<span
className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/10 border border-primary-foreground/20"
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-white/20"
data-testid={`shortcut-key-${feature.id}`}
>
{shortcutKey}
@@ -770,7 +835,7 @@ export const KanbanCard = memo(function KanbanCard({
<Button
variant="destructive"
size="sm"
className="h-7 text-xs"
className="h-7 text-[11px] px-2"
onClick={(e) => {
e.stopPropagation();
onForceStop();
@@ -778,20 +843,18 @@ export const KanbanCard = memo(function KanbanCard({
onPointerDown={(e) => e.stopPropagation()}
data-testid={`force-stop-${feature.id}`}
>
<StopCircle className="w-3 h-3 mr-1" />
Stop
<StopCircle className="w-3 h-3" />
</Button>
)}
</>
)}
{!isCurrentAutoTask && feature.status === "in_progress" && (
<>
{/* skipTests features show manual verify button */}
{feature.skipTests && onManualVerify ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-primary hover:bg-primary/90"
className="flex-1 h-7 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onManualVerify();
@@ -806,7 +869,7 @@ export const KanbanCard = memo(function KanbanCard({
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-action-verify hover:bg-action-verify-hover"
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
onClick={(e) => {
e.stopPropagation();
onResume();
@@ -821,7 +884,7 @@ export const KanbanCard = memo(function KanbanCard({
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-action-verify hover:bg-action-verify-hover"
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
onClick={(e) => {
e.stopPropagation();
onVerify();
@@ -837,7 +900,7 @@ export const KanbanCard = memo(function KanbanCard({
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
className="h-7 text-[11px] px-2"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
@@ -845,20 +908,19 @@ export const KanbanCard = memo(function KanbanCard({
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-inprogress-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
Logs
<FileText className="w-3 h-3" />
</Button>
)}
</>
)}
{!isCurrentAutoTask && feature.status === "verified" && (
<>
{/* Logs button if context exists */}
{hasContext && onViewOutput && (
{/* Logs button - styled like Refine */}
{onViewOutput && (
<Button
variant="ghost"
variant="secondary"
size="sm"
className="h-7 text-xs"
className="flex-1 h-7 text-xs min-w-0"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
@@ -866,15 +928,31 @@ export const KanbanCard = memo(function KanbanCard({
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-verified-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
Logs
<FileText className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Logs</span>
</Button>
)}
{/* Complete button */}
{onComplete && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs min-w-0 bg-brand-500 hover:bg-brand-600"
onClick={(e) => {
e.stopPropagation();
onComplete();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`complete-${feature.id}`}
>
<Archive className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Complete</span>
</Button>
)}
</>
)}
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
<>
{/* Revert button - only show when worktree exists (icon only to save space) */}
{hasWorktree && onRevert && (
<TooltipProvider delayDuration={300}>
<Tooltip>
@@ -882,7 +960,7 @@ export const KanbanCard = memo(function KanbanCard({
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/20 shrink-0"
className="h-7 w-7 p-0 text-[var(--status-error)] hover:text-[var(--status-error)] hover:bg-[var(--status-error-bg)] shrink-0"
onClick={(e) => {
e.stopPropagation();
setIsRevertDialogOpen(true);
@@ -893,18 +971,18 @@ export const KanbanCard = memo(function KanbanCard({
<Undo2 className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<TooltipContent side="top" className="text-xs">
<p>Revert changes</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Follow-up prompt button */}
{/* Refine prompt button */}
{onFollowUp && (
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-xs min-w-0"
className="flex-1 h-7 text-[11px] min-w-0"
onClick={(e) => {
e.stopPropagation();
onFollowUp();
@@ -912,16 +990,15 @@ export const KanbanCard = memo(function KanbanCard({
onPointerDown={(e) => e.stopPropagation()}
data-testid={`follow-up-${feature.id}`}
>
<MessageSquare className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Follow-up</span>
<Wand2 className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Refine</span>
</Button>
)}
{/* Merge button - only show when worktree exists */}
{hasWorktree && onMerge && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-purple-600 hover:bg-purple-700 min-w-0"
className="flex-1 h-7 text-[11px] bg-[var(--status-info)] hover:bg-[var(--status-info)]/90 min-w-0"
onClick={(e) => {
e.stopPropagation();
onMerge();
@@ -934,12 +1011,11 @@ export const KanbanCard = memo(function KanbanCard({
<span className="truncate">Merge</span>
</Button>
)}
{/* Commit and verify button - show when no worktree */}
{!hasWorktree && onCommit && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs"
className="flex-1 h-7 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onCommit();
@@ -953,6 +1029,40 @@ export const KanbanCard = memo(function KanbanCard({
)}
</>
)}
{!isCurrentAutoTask && feature.status === "backlog" && (
<>
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`edit-backlog-${feature.id}`}
>
<Edit className="w-3 h-3 mr-1" />
Edit
</Button>
{onImplement && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onImplement();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`make-${feature.id}`}
>
<PlayCircle className="w-3 h-3 mr-1" />
Make
</Button>
)}
</>
)}
</div>
</CardContent>
@@ -975,7 +1085,7 @@ export const KanbanCard = memo(function KanbanCard({
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-green-400" />
<Sparkles className="w-5 h-5 text-[var(--status-success)]" />
Implementation Summary
</DialogTitle>
<DialogDescription
@@ -991,7 +1101,7 @@ export const KanbanCard = memo(function KanbanCard({
})()}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-4 bg-card rounded-lg border border-border">
<div className="flex-1 overflow-y-auto p-4 bg-card rounded-lg border border-border/50">
<Markdown>
{feature.summary ||
summary ||
@@ -1015,7 +1125,7 @@ export const KanbanCard = memo(function KanbanCard({
<Dialog open={isRevertDialogOpen} onOpenChange={setIsRevertDialogOpen}>
<DialogContent data-testid="revert-confirmation-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-400">
<DialogTitle className="flex items-center gap-2 text-[var(--status-error)]">
<Undo2 className="w-5 h-5" />
Revert Changes
</DialogTitle>
@@ -1025,13 +1135,13 @@ export const KanbanCard = memo(function KanbanCard({
{feature.branchName && (
<span className="block mt-2 font-medium">
Branch{" "}
<code className="bg-muted px-1 py-0.5 rounded">
<code className="bg-muted px-1.5 py-0.5 rounded text-[11px]">
{feature.branchName}
</code>{" "}
will be deleted.
</span>
)}
<span className="block mt-2 text-red-400 font-medium">
<span className="block mt-2 text-[var(--status-error)] font-medium">
This action cannot be undone.
</span>
</DialogDescription>

View File

@@ -8,19 +8,19 @@ import type { ReactNode } from "react";
interface KanbanColumnProps {
id: string;
title: string;
color: string;
colorClass: string;
count: number;
children: ReactNode;
headerAction?: ReactNode;
opacity?: number; // Opacity percentage (0-100) - only affects background
showBorder?: boolean; // Whether to show column border
hideScrollbar?: boolean; // Whether to hide the column scrollbar
opacity?: number;
showBorder?: boolean;
hideScrollbar?: boolean;
}
export const KanbanColumn = memo(function KanbanColumn({
id,
title,
color,
colorClass,
count,
children,
headerAction,
@@ -34,45 +34,53 @@ export const KanbanColumn = memo(function KanbanColumn({
<div
ref={setNodeRef}
className={cn(
"relative flex flex-col h-full rounded-lg transition-colors w-72",
showBorder && "border border-border"
"relative flex flex-col h-full rounded-xl transition-all duration-200 w-72",
showBorder && "border border-border/60",
isOver && "ring-2 ring-primary/30 ring-offset-1 ring-offset-background"
)}
data-testid={`kanban-column-${id}`}
>
{/* Background layer with opacity - only this layer is affected by opacity */}
{/* Background layer with opacity */}
<div
className={cn(
"absolute inset-0 rounded-lg backdrop-blur-sm transition-colors",
isOver ? "bg-accent" : "bg-card"
"absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200",
isOver ? "bg-accent/80" : "bg-card/80"
)}
style={{ opacity: opacity / 100 }}
/>
{/* Column Header - positioned above the background */}
{/* Column Header */}
<div
className={cn(
"relative z-10 flex items-center gap-2 p-3",
showBorder && "border-b border-border"
"relative z-10 flex items-center gap-3 px-3 py-2.5",
showBorder && "border-b border-border/40"
)}
>
<div className={cn("w-3 h-3 rounded-full", color)} />
<h3 className="font-medium text-sm flex-1">{title}</h3>
<div className={cn("w-2.5 h-2.5 rounded-full shrink-0", colorClass)} />
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
{headerAction}
<span className="text-xs text-muted-foreground bg-background px-2 py-0.5 rounded-full">
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
{count}
</span>
</div>
{/* Column Content - positioned above the background */}
{/* Column Content */}
<div
className={cn(
"relative z-10 flex-1 overflow-y-auto p-2 space-y-2",
"relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5",
hideScrollbar &&
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
// Smooth scrolling
"scroll-smooth"
)}
>
{children}
</div>
{/* Drop zone indicator when dragging over */}
{isOver && (
<div className="absolute inset-0 rounded-xl bg-primary/5 pointer-events-none z-5 border-2 border-dashed border-primary/20" />
)}
</div>
);
});

View File

@@ -1,19 +1,10 @@
"use client";
import { useState, useMemo, useCallback, useEffect } from "react";
import { useState, useMemo, useCallback } from "react";
import {
useAppStore,
AIProfile,
AgentModel,
ThinkingLevel,
ModelProvider,
} from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn, modelSupportsThinking } from "@/lib/utils";
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
@@ -23,26 +14,12 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
UserCircle,
Plus,
Pencil,
Trash2,
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
GripVertical,
Lock,
Check,
} from "lucide-react";
import { Sparkles } from "lucide-react";
import { toast } from "sonner";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import {
DndContext,
DragEndEvent,
@@ -53,406 +30,13 @@ import {
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
// Icon mapping for profiles
const PROFILE_ICONS: Record<
string,
React.ComponentType<{ className?: string }>
> = {
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
};
// Available icons for selection
const ICON_OPTIONS = [
{ name: "Brain", icon: Brain },
{ name: "Zap", icon: Zap },
{ name: "Scale", icon: Scale },
{ name: "Cpu", icon: Cpu },
{ name: "Rocket", icon: Rocket },
{ name: "Sparkles", icon: Sparkles },
];
// Model options for the form
const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
{ id: "haiku", label: "Claude Haiku" },
{ id: "sonnet", label: "Claude Sonnet" },
{ id: "opus", label: "Claude Opus" },
];
const CODEX_MODELS: { id: AgentModel; label: string }[] = [
{ id: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
{ id: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
{ id: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex Mini" },
{ id: "gpt-5.1", label: "GPT-5.1" },
];
const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
{ id: "none", label: "None" },
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "ultrathink", label: "Ultrathink" },
];
// Helper to determine provider from model
function getProviderFromModel(model: AgentModel): ModelProvider {
if (model.startsWith("gpt")) {
return "codex";
}
return "claude";
}
// Sortable Profile Card Component
function SortableProfileCard({
profile,
onEdit,
onDelete,
}: {
profile: AIProfile;
onEdit: () => void;
onDelete: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: profile.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
const isCodex = profile.provider === "codex";
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"group relative flex items-start gap-4 p-4 rounded-xl border bg-card transition-all",
isDragging && "shadow-lg",
profile.isBuiltIn
? "border-border/50"
: "border-border hover:border-primary/50 hover:shadow-sm"
)}
data-testid={`profile-card-${profile.id}`}
>
{/* Drag Handle */}
<button
{...attributes}
{...listeners}
className="p-1 rounded hover:bg-accent cursor-grab active:cursor-grabbing flex-shrink-0 mt-1"
data-testid={`profile-drag-handle-${profile.id}`}
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
{/* Icon */}
<div
className={cn(
"flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center",
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
)}
>
{IconComponent && (
<IconComponent
className={cn(
"w-5 h-5",
isCodex ? "text-emerald-500" : "text-primary"
)}
/>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-foreground">{profile.name}</h3>
{profile.isBuiltIn && (
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
<Lock className="w-2.5 h-2.5" />
Built-in
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
{profile.description}
</p>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full border",
isCodex
? "border-emerald-500/30 text-emerald-600 dark:text-emerald-400 bg-emerald-500/10"
: "border-primary/30 text-primary bg-primary/10"
)}
>
{profile.model}
</span>
{profile.thinkingLevel !== "none" && (
<span className="text-xs px-2 py-0.5 rounded-full border border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/10">
{profile.thinkingLevel}
</span>
)}
</div>
</div>
{/* Actions */}
{!profile.isBuiltIn && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={onEdit}
className="h-8 w-8 p-0"
data-testid={`edit-profile-${profile.id}`}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
data-testid={`delete-profile-${profile.id}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
</div>
);
}
// Profile Form Component
function ProfileForm({
profile,
onSave,
onCancel,
isEditing,
hotkeyActive,
}: {
profile: Partial<AIProfile>;
onSave: (profile: Omit<AIProfile, "id">) => void;
onCancel: () => void;
isEditing: boolean;
hotkeyActive: boolean;
}) {
const [formData, setFormData] = useState({
name: profile.name || "",
description: profile.description || "",
model: profile.model || ("opus" as AgentModel),
thinkingLevel: profile.thinkingLevel || ("none" as ThinkingLevel),
icon: profile.icon || "Brain",
});
const provider = getProviderFromModel(formData.model);
const supportsThinking = modelSupportsThinking(formData.model);
const handleModelChange = (model: AgentModel) => {
const newProvider = getProviderFromModel(model);
setFormData({
...formData,
model,
// Reset thinking level when switching to Codex (doesn't support thinking)
thinkingLevel: newProvider === "codex" ? "none" : formData.thinkingLevel,
});
};
const handleSubmit = () => {
if (!formData.name.trim()) {
toast.error("Please enter a profile name");
return;
}
onSave({
name: formData.name.trim(),
description: formData.description.trim(),
model: formData.model,
thinkingLevel: supportsThinking ? formData.thinkingLevel : "none",
provider,
isBuiltIn: false,
icon: formData.icon,
});
};
return (
<div className="space-y-4">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Heavy Task, Quick Fix"
data-testid="profile-name-input"
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="profile-description">Description</Label>
<Textarea
id="profile-description"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="Describe when to use this profile..."
rows={2}
data-testid="profile-description-input"
/>
</div>
{/* Icon Selection */}
<div className="space-y-2">
<Label>Icon</Label>
<div className="flex gap-2 flex-wrap">
{ICON_OPTIONS.map(({ name, icon: Icon }) => (
<button
key={name}
type="button"
onClick={() => setFormData({ ...formData, icon: name })}
className={cn(
"w-10 h-10 rounded-lg flex items-center justify-center border transition-colors",
formData.icon === name
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`icon-select-${name}`}
>
<Icon className="w-5 h-5" />
</button>
))}
</div>
</div>
{/* Model Selection - Claude */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-primary" />
Claude Models
</Label>
<div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => handleModelChange(id)}
className={cn(
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
formData.model === id
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`model-select-${id}`}
>
{label.replace("Claude ", "")}
</button>
))}
</div>
</div>
{/* Model Selection - Codex */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Zap className="w-4 h-4 text-emerald-500" />
Codex Models
</Label>
<div className="flex gap-2 flex-wrap">
{CODEX_MODELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => handleModelChange(id)}
className={cn(
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
formData.model === id
? "bg-emerald-600 text-white border-emerald-500"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`model-select-${id}`}
>
{label.replace("GPT-5.1 ", "").replace("Codex ", "")}
</button>
))}
</div>
</div>
{/* Thinking Level - Only for Claude models */}
{supportsThinking && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-amber-500" />
Thinking Level
</Label>
<div className="flex gap-2 flex-wrap">
{THINKING_LEVELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => {
setFormData({ ...formData, thinkingLevel: id });
if (id === "ultrathink") {
toast.warning("Ultrathink uses extensive reasoning", {
description:
"Best for complex architecture, migrations, or deep debugging (~$0.48/task).",
duration: 4000,
});
}
}}
className={cn(
"flex-1 min-w-[70px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
formData.thinkingLevel === id
? "bg-amber-500 text-white border-amber-400"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`thinking-select-${id}`}
>
{label}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
Higher levels give more time to reason through complex problems.
</p>
</div>
)}
{/* Actions */}
<DialogFooter className="pt-4">
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
<HotkeyButton
onClick={handleSubmit}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={hotkeyActive}
data-testid="save-profile-button"
>
{isEditing ? "Save Changes" : "Create Profile"}
</HotkeyButton>
</DialogFooter>
</div>
);
}
import {
SortableProfileCard,
ProfileForm,
ProfilesHeader,
} from "./profiles-view/components";
export function ProfilesView() {
const {
@@ -461,11 +45,13 @@ export function ProfilesView() {
updateAIProfile,
removeAIProfile,
reorderAIProfiles,
resetAIProfiles,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingProfile, setEditingProfile] = useState<AIProfile | null>(null);
const [profileToDelete, setProfileToDelete] = useState<AIProfile | null>(null);
// Sensors for drag-and-drop
const sensors = useSensors(
@@ -520,12 +106,20 @@ export function ProfilesView() {
}
};
const handleDeleteProfile = (profile: AIProfile) => {
if (profile.isBuiltIn) return;
const confirmDeleteProfile = () => {
if (!profileToDelete) return;
removeAIProfile(profile.id);
removeAIProfile(profileToDelete.id);
toast.success("Profile deleted", {
description: `Deleted "${profile.name}" profile`,
description: `Deleted "${profileToDelete.name}" profile`,
});
setProfileToDelete(null);
};
const handleResetProfiles = () => {
resetAIProfiles();
toast.success("Profiles refreshed", {
description: "Default profiles have been updated to the latest version",
});
};
@@ -552,34 +146,11 @@ export function ProfilesView() {
data-testid="profiles-view"
>
{/* Header Section */}
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
<div className="px-8 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
<UserCircle className="w-5 h-5 text-primary-foreground" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">
AI Profiles
</h1>
<p className="text-sm text-muted-foreground">
Create and manage model configuration presets
</p>
</div>
</div>
<HotkeyButton
onClick={() => setShowAddDialog(true)}
hotkey={shortcuts.addProfile}
hotkeyActive={false}
data-testid="add-profile-button"
>
<Plus className="w-4 h-4 mr-2" />
New Profile
</HotkeyButton>
</div>
</div>
</div>
<ProfilesHeader
onResetProfiles={handleResetProfiles}
onAddProfile={() => setShowAddDialog(true)}
addProfileHotkey={shortcuts.addProfile}
/>
{/* Content */}
<div className="flex-1 overflow-y-auto p-8">
@@ -595,19 +166,14 @@ export function ProfilesView() {
</span>
</div>
{customProfiles.length === 0 ? (
<div className="rounded-xl border border-dashed border-border p-8 text-center">
<Sparkles className="w-10 h-10 text-muted-foreground mx-auto mb-3 opacity-50" />
<p className="text-muted-foreground">
<div
className="group rounded-xl border border-dashed border-border p-8 text-center transition-all duration-300 hover:border-primary hover:bg-primary/5 cursor-pointer"
onClick={() => setShowAddDialog(true)}
>
<Sparkles className="w-10 h-10 text-muted-foreground mx-auto mb-3 opacity-50 transition-all duration-300 group-hover:text-primary group-hover:opacity-100 group-hover:scale-110 group-hover:rotate-12" />
<p className="text-muted-foreground transition-colors duration-300 group-hover:text-foreground">
No custom profiles yet. Create one to get started!
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => setShowAddDialog(true)}
>
<Plus className="w-4 h-4 mr-2" />
Create Profile
</Button>
</div>
) : (
<DndContext
@@ -625,7 +191,7 @@ export function ProfilesView() {
key={profile.id}
profile={profile}
onEdit={() => setEditingProfile(profile)}
onDelete={() => handleDeleteProfile(profile)}
onDelete={() => setProfileToDelete(profile)}
/>
))}
</div>
@@ -675,8 +241,8 @@ export function ProfilesView() {
{/* Add Profile Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent data-testid="add-profile-dialog">
<DialogHeader>
<DialogContent data-testid="add-profile-dialog" className="flex flex-col max-h-[calc(100vh-4rem)]">
<DialogHeader className="shrink-0">
<DialogTitle>Create New Profile</DialogTitle>
<DialogDescription>
Define a reusable model configuration preset.
@@ -697,8 +263,8 @@ export function ProfilesView() {
open={!!editingProfile}
onOpenChange={() => setEditingProfile(null)}
>
<DialogContent data-testid="edit-profile-dialog">
<DialogHeader>
<DialogContent data-testid="edit-profile-dialog" className="flex flex-col max-h-[calc(100vh-4rem)]">
<DialogHeader className="shrink-0">
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Modify your profile settings.</DialogDescription>
</DialogHeader>
@@ -713,6 +279,22 @@ export function ProfilesView() {
)}
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<DeleteConfirmDialog
open={!!profileToDelete}
onOpenChange={(open) => !open && setProfileToDelete(null)}
onConfirm={confirmDeleteProfile}
title="Delete Profile"
description={
profileToDelete
? `Are you sure you want to delete "${profileToDelete.name}"? This action cannot be undone.`
: ""
}
confirmText="Delete Profile"
testId="delete-profile-confirm-dialog"
confirmTestId="confirm-delete-profile-button"
/>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { SortableProfileCard } from "./sortable-profile-card";
export { ProfileForm } from "./profile-form";
export { ProfilesHeader } from "./profiles-header";

View File

@@ -0,0 +1,204 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn, modelSupportsThinking } from "@/lib/utils";
import { DialogFooter } from "@/components/ui/dialog";
import { Brain } from "lucide-react";
import { toast } from "sonner";
import type { AIProfile, AgentModel, ThinkingLevel } from "@/store/app-store";
import { CLAUDE_MODELS, THINKING_LEVELS, ICON_OPTIONS } from "../constants";
import { getProviderFromModel } from "../utils";
interface ProfileFormProps {
profile: Partial<AIProfile>;
onSave: (profile: Omit<AIProfile, "id">) => void;
onCancel: () => void;
isEditing: boolean;
hotkeyActive: boolean;
}
export function ProfileForm({
profile,
onSave,
onCancel,
isEditing,
hotkeyActive,
}: ProfileFormProps) {
const [formData, setFormData] = useState({
name: profile.name || "",
description: profile.description || "",
model: profile.model || ("opus" as AgentModel),
thinkingLevel: profile.thinkingLevel || ("none" as ThinkingLevel),
icon: profile.icon || "Brain",
});
const provider = getProviderFromModel(formData.model);
const supportsThinking = modelSupportsThinking(formData.model);
const handleModelChange = (model: AgentModel) => {
setFormData({
...formData,
model,
});
};
const handleSubmit = () => {
if (!formData.name.trim()) {
toast.error("Please enter a profile name");
return;
}
onSave({
name: formData.name.trim(),
description: formData.description.trim(),
model: formData.model,
thinkingLevel: supportsThinking ? formData.thinkingLevel : "none",
provider,
isBuiltIn: false,
icon: formData.icon,
});
};
return (
<>
<div className="overflow-y-auto flex-1 min-h-0 space-y-4 pr-3 -mr-3 pl-1">
{/* Name */}
<div className="mt-2 space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Heavy Task, Quick Fix"
data-testid="profile-name-input"
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="profile-description">Description</Label>
<Textarea
id="profile-description"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="Describe when to use this profile..."
rows={2}
data-testid="profile-description-input"
/>
</div>
{/* Icon Selection */}
<div className="space-y-2">
<Label>Icon</Label>
<div className="flex gap-2 flex-wrap">
{ICON_OPTIONS.map(({ name, icon: Icon }) => (
<button
key={name}
type="button"
onClick={() => setFormData({ ...formData, icon: name })}
className={cn(
"w-10 h-10 rounded-lg flex items-center justify-center border transition-colors",
formData.icon === name
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-border"
)}
data-testid={`icon-select-${name}`}
>
<Icon className="w-5 h-5" />
</button>
))}
</div>
</div>
{/* Model Selection */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-primary" />
Model
</Label>
<div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => handleModelChange(id)}
className={cn(
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
formData.model === id
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-border"
)}
data-testid={`model-select-${id}`}
>
{label.replace("Claude ", "")}
</button>
))}
</div>
</div>
{/* Thinking Level */}
{supportsThinking && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-amber-500" />
Thinking Level
</Label>
<div className="flex gap-2 flex-wrap">
{THINKING_LEVELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => {
setFormData({ ...formData, thinkingLevel: id });
if (id === "ultrathink") {
toast.warning("Ultrathink uses extensive reasoning", {
description:
"Best for complex architecture, migrations, or deep debugging (~$0.48/task).",
duration: 4000,
});
}
}}
className={cn(
"flex-1 min-w-[70px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
formData.thinkingLevel === id
? "bg-amber-500 text-white border-amber-400"
: "bg-background hover:bg-accent border-border"
)}
data-testid={`thinking-select-${id}`}
>
{label}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
Higher levels give more time to reason through complex problems.
</p>
</div>
)}
</div>
{/* Actions */}
<DialogFooter className="pt-4 border-t border-border mt-4 shrink-0">
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
<HotkeyButton
onClick={handleSubmit}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={hotkeyActive}
data-testid="save-profile-button"
>
{isEditing ? "Save Changes" : "Create Profile"}
</HotkeyButton>
</DialogFooter>
</>
);
}

View File

@@ -0,0 +1,58 @@
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { UserCircle, Plus, RefreshCw } from "lucide-react";
interface ProfilesHeaderProps {
onResetProfiles: () => void;
onAddProfile: () => void;
addProfileHotkey: string;
}
export function ProfilesHeader({
onResetProfiles,
onAddProfile,
addProfileHotkey,
}: ProfilesHeaderProps) {
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 justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
<UserCircle className="w-5 h-5 text-primary-foreground" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">
AI Profiles
</h1>
<p className="text-sm text-muted-foreground">
Create and manage model configuration presets
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={onResetProfiles}
data-testid="refresh-profiles-button"
className="gap-2"
>
<RefreshCw className="w-4 h-4" />
Refresh Defaults
</Button>
<HotkeyButton
onClick={onAddProfile}
hotkey={addProfileHotkey}
hotkeyActive={false}
data-testid="add-profile-button"
>
<Plus className="w-4 h-4 mr-2" />
New Profile
</HotkeyButton>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { GripVertical, Lock, Pencil, Trash2, Brain } from "lucide-react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { AIProfile } from "@/store/app-store";
import { PROFILE_ICONS } from "../constants";
interface SortableProfileCardProps {
profile: AIProfile;
onEdit: () => void;
onDelete: () => void;
}
export function SortableProfileCard({
profile,
onEdit,
onDelete,
}: SortableProfileCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: profile.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"group relative flex items-start gap-4 p-4 rounded-xl border bg-card transition-all",
isDragging && "shadow-lg",
profile.isBuiltIn
? "border-border/50"
: "border-border hover:border-primary/50 hover:shadow-sm"
)}
data-testid={`profile-card-${profile.id}`}
>
{/* Drag Handle */}
<button
{...attributes}
{...listeners}
className="p-1 rounded hover:bg-accent cursor-grab active:cursor-grabbing flex-shrink-0 mt-1"
data-testid={`profile-drag-handle-${profile.id}`}
onClick={(e) => e.stopPropagation()}
aria-label={`Reorder ${profile.name} profile`}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
{/* Icon */}
<div
className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center bg-primary/10"
>
{IconComponent && (
<IconComponent className="w-5 h-5 text-primary" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-foreground">{profile.name}</h3>
{profile.isBuiltIn && (
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
<Lock className="w-2.5 h-2.5" />
Built-in
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
{profile.description}
</p>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<span
className="text-xs px-2 py-0.5 rounded-full border border-primary/30 text-primary bg-primary/10"
>
{profile.model}
</span>
{profile.thinkingLevel !== "none" && (
<span className="text-xs px-2 py-0.5 rounded-full border border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/10">
{profile.thinkingLevel}
</span>
)}
</div>
</div>
{/* Actions */}
{!profile.isBuiltIn && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={onEdit}
className="h-8 w-8 p-0"
data-testid={`edit-profile-${profile.id}`}
aria-label={`Edit ${profile.name} profile`}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
data-testid={`delete-profile-${profile.id}`}
aria-label={`Delete ${profile.name} profile`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,48 @@
import {
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
} from "lucide-react";
import type { AgentModel, ThinkingLevel } from "@/store/app-store";
// Icon mapping for profiles
export const PROFILE_ICONS: Record<
string,
React.ComponentType<{ className?: string }>
> = {
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
};
// Available icons for selection
export const ICON_OPTIONS = [
{ name: "Brain", icon: Brain },
{ name: "Zap", icon: Zap },
{ name: "Scale", icon: Scale },
{ name: "Cpu", icon: Cpu },
{ name: "Rocket", icon: Rocket },
{ name: "Sparkles", icon: Sparkles },
];
// Model options for the form
export const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
{ id: "haiku", label: "Claude Haiku" },
{ id: "sonnet", label: "Claude Sonnet" },
{ id: "opus", label: "Claude Opus" },
];
export const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
{ id: "none", label: "None" },
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "ultrathink", label: "Ultrathink" },
];

View File

@@ -0,0 +1,7 @@
import type { AgentModel, ModelProvider } from "@/store/app-store";
// Helper to determine provider from model
export function getProviderFromModel(model: AgentModel): ModelProvider {
return "claude";
}

View File

@@ -7,7 +7,6 @@ import {
Key,
Palette,
Terminal,
Atom,
FlaskConical,
Trash2,
Settings2,
@@ -24,7 +23,6 @@ import { DeleteProjectDialog } from "./settings-view/components/delete-project-d
import { SettingsNavigation } from "./settings-view/components/settings-navigation";
import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section";
import { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status";
import { CodexCliStatus } from "./settings-view/cli-status/codex-cli-status";
import { AppearanceSection } from "./settings-view/appearance/appearance-section";
import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section";
import { FeatureDefaultsSection } from "./settings-view/feature-defaults/feature-defaults-section";
@@ -39,7 +37,6 @@ import type { Project as ElectronProject } from "@/lib/electron";
const NAV_ITEMS = [
{ id: "api-keys", label: "API Keys", icon: Key },
{ id: "claude", label: "Claude", icon: Terminal },
{ id: "codex", label: "Codex", icon: Atom },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "audio", label: "Audio", icon: Volume2 },
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
@@ -96,11 +93,8 @@ export function SettingsView() {
// Use CLI status hook
const {
claudeCliStatus,
codexCliStatus,
isCheckingClaudeCli,
isCheckingCodexCli,
handleRefreshClaudeCli,
handleRefreshCodexCli,
} = useCliStatus();
// Use scroll tracking hook
@@ -147,15 +141,6 @@ export function SettingsView() {
/>
)}
{/* Codex CLI Status Section */}
{codexCliStatus && (
<CodexCliStatus
status={codexCliStatus}
isChecking={isCheckingCodexCli}
onRefresh={handleRefreshCodexCli}
/>
)}
{/* Appearance Section */}
<AppearanceSection
effectiveTheme={effectiveTheme}
@@ -172,47 +157,47 @@ export function SettingsView() {
{/* Audio Section */}
<div
id="audio"
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
className="rounded-2xl border border-border/50 bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl shadow-sm shadow-black/5 overflow-hidden scroll-mt-6"
>
<div className="p-6 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<Volume2 className="w-5 h-5 text-brand-500" />
<h2 className="text-lg font-semibold text-foreground">
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Volume2 className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Audio
</h2>
</div>
<p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground/80 ml-12">
Configure audio and notification settings.
</p>
</div>
<div className="p-6 space-y-4">
{/* Mute Done Sound Setting */}
<div className="space-y-3">
<div className="flex items-start space-x-3">
<Checkbox
id="mute-done-sound"
checked={muteDoneSound}
onCheckedChange={(checked) =>
setMuteDoneSound(checked === true)
}
className="mt-0.5"
data-testid="mute-done-sound-checkbox"
/>
<div className="space-y-1">
<Label
htmlFor="mute-done-sound"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<VolumeX className="w-4 h-4 text-brand-500" />
Mute notification sound when agents complete
</Label>
<p className="text-xs text-muted-foreground">
When enabled, disables the &quot;ding&quot; sound that
plays when an agent completes a feature. The feature
will still move to the completed column, but without
audio notification.
</p>
</div>
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="mute-done-sound"
checked={muteDoneSound}
onCheckedChange={(checked) =>
setMuteDoneSound(checked === true)
}
className="mt-1"
data-testid="mute-done-sound-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="mute-done-sound"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<VolumeX className="w-4 h-4 text-brand-500" />
Mute notification sound when agents complete
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, disables the &quot;ding&quot; sound that
plays when an agent completes a feature. The feature
will still move to the completed column, but without
audio notification.
</p>
</div>
</div>
</div>

View File

@@ -1,35 +1,80 @@
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 { Key, CheckCircle2, Settings, Trash2, Loader2 } 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";
import { cn } from "@/lib/utils";
import { useState, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
export function ApiKeysSection() {
const { apiKeys } = useAppStore();
const { claudeAuthStatus, codexAuthStatus } = useSetupStore();
const { apiKeys, setApiKeys } = useAppStore();
const { claudeAuthStatus, setClaudeAuthStatus, setSetupComplete } = useSetupStore();
const [isDeletingAnthropicKey, setIsDeletingAnthropicKey] = useState(false);
const { providerConfigParams, apiKeyStatus, handleSave, saved } =
useApiKeyManagement();
const providerConfigs = buildProviderConfigs(providerConfigParams);
// Delete Anthropic API key
const deleteAnthropicKey = useCallback(async () => {
setIsDeletingAnthropicKey(true);
try {
const api = getElectronAPI();
if (!api.setup?.deleteApiKey) {
toast.error("Delete API not available");
return;
}
const result = await api.setup.deleteApiKey("anthropic");
if (result.success) {
setApiKeys({ ...apiKeys, anthropic: "" });
setClaudeAuthStatus({
authenticated: false,
method: "none",
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
});
toast.success("Anthropic API key deleted");
} else {
toast.error(result.error || "Failed to delete API key");
}
} catch (error) {
toast.error("Failed to delete API key");
} finally {
setIsDeletingAnthropicKey(false);
}
}, [apiKeys, setApiKeys, claudeAuthStatus, setClaudeAuthStatus]);
// Open setup wizard
const openSetupWizard = useCallback(() => {
setSetupComplete(false);
}, [setSetupComplete]);
return (
<div
id="api-keys"
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
className={cn(
"rounded-2xl overflow-hidden scroll-mt-6",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
)}
>
<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 className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Key className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">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 className="text-sm text-muted-foreground/80 ml-12">
Configure your AI provider API keys. Keys are stored locally in your browser.
</p>
</div>
<div className="p-6 space-y-6">
@@ -41,7 +86,6 @@ export function ApiKeysSection() {
{/* Authentication Status Display */}
<AuthenticationStatusDisplay
claudeAuthStatus={claudeAuthStatus}
codexAuthStatus={codexAuthStatus}
apiKeyStatus={apiKeyStatus}
apiKeys={apiKeys}
/>
@@ -49,12 +93,20 @@ export function ApiKeysSection() {
{/* Security Notice */}
<SecurityNotice />
{/* Save Button */}
<div className="flex items-center gap-4 pt-2">
{/* Action Buttons */}
<div className="flex flex-wrap items-center gap-3 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"
className={cn(
"min-w-[140px] h-10",
"bg-gradient-to-r from-brand-500 to-brand-600",
"hover:from-brand-600 hover:to-brand-600",
"text-white font-medium border-0",
"shadow-md shadow-brand-500/20 hover:shadow-lg hover:shadow-brand-500/25",
"transition-all duration-200 ease-out",
"hover:scale-[1.02] active:scale-[0.98]"
)}
>
{saved ? (
<>
@@ -65,6 +117,33 @@ export function ApiKeysSection() {
"Save API Keys"
)}
</Button>
<Button
onClick={openSetupWizard}
variant="outline"
className="h-10 border-border"
data-testid="run-setup-wizard"
>
<Settings className="w-4 h-4 mr-2" />
Run Setup Wizard
</Button>
{apiKeys.anthropic && (
<Button
onClick={deleteAnthropicKey}
disabled={isDeletingAnthropicKey}
variant="outline"
className="h-10 border-red-500/30 text-red-500 hover:bg-red-500/10 hover:border-red-500/50"
data-testid="delete-anthropic-key"
>
{isDeletingAnthropicKey ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Trash2 className="w-4 h-4 mr-2" />
)}
Delete Anthropic Key
</Button>
)}
</div>
</div>
</div>

View File

@@ -4,29 +4,24 @@ import {
AlertCircle,
Info,
Terminal,
Atom,
Sparkles,
} from "lucide-react";
import type { ClaudeAuthStatus, CodexAuthStatus } from "@/store/setup-store";
import type { ClaudeAuthStatus } 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) {
@@ -53,14 +48,14 @@ export function AuthenticationStatusDisplay({
<>
<div className="flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
<span className="text-green-400 font-medium">Authenticated</span>
<span className="text-green-400 font-medium">
Authenticated
</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="w-3 h-3 shrink-0" />
<span>
{claudeAuthStatus.method === "oauth_token_env"
? "Using CLAUDE_CODE_OAUTH_TOKEN"
: claudeAuthStatus.method === "oauth_token"
{claudeAuthStatus.method === "oauth_token"
? "Using stored OAuth token (subscription)"
: claudeAuthStatus.method === "api_key_env"
? "Using ANTHROPIC_API_KEY"
@@ -70,7 +65,9 @@ export function AuthenticationStatusDisplay({
? "Using credentials file"
: claudeAuthStatus.method === "cli_authenticated"
? "Using Claude CLI authentication"
: `Using ${claudeAuthStatus.method || "detected"} authentication`}
: `Using ${
claudeAuthStatus.method || "detected"
} authentication`}
</span>
</div>
</>
@@ -92,96 +89,6 @@ export function AuthenticationStatusDisplay({
)}
</div>
</div>
{/* Codex/OpenAI Authentication Status */}
<div className="p-3 rounded-lg bg-card border border-border">
<div className="flex items-center gap-2 mb-1.5">
<Atom className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium text-foreground">
Codex (OpenAI)
</span>
</div>
<div className="space-y-1.5 text-xs min-h-12">
{codexAuthStatus?.authenticated ? (
<>
<div className="flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
<span className="text-green-400 font-medium">Authenticated</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="w-3 h-3 shrink-0" />
<span>
{codexAuthStatus.method === "subscription"
? "Using Codex subscription (Plus/Team)"
: codexAuthStatus.method === "cli_verified" ||
codexAuthStatus.method === "cli_tokens"
? "Using CLI login (OpenAI account)"
: codexAuthStatus.method === "api_key"
? "Using stored API key"
: codexAuthStatus.method === "env"
? "Using OPENAI_API_KEY"
: `Using ${codexAuthStatus.method || "unknown"} authentication`}
</span>
</div>
</>
) : apiKeyStatus?.hasOpenAIKey ? (
<div className="flex items-center gap-2 text-blue-400">
<Info className="w-3 h-3 shrink-0" />
<span>Using environment variable (OPENAI_API_KEY)</span>
</div>
) : apiKeys.openai ? (
<div className="flex items-center gap-2 text-blue-400">
<Info className="w-3 h-3 shrink-0" />
<span>Using manual API key from settings</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-yellow-500 py-0.5">
<AlertCircle className="w-3 h-3 shrink-0" />
<span className="text-xs">Not configured</span>
</div>
)}
</div>
</div>
{/* Google/Gemini Authentication Status */}
<div className="p-3 rounded-lg bg-card border border-border">
<div className="flex items-center gap-2 mb-1.5">
<Sparkles className="w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-foreground">
Gemini (Google)
</span>
</div>
<div className="space-y-1.5 text-xs min-h-12">
{apiKeyStatus?.hasGoogleKey ? (
<>
<div className="flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
<span className="text-green-400 font-medium">Authenticated</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="w-3 h-3 shrink-0" />
<span>Using GOOGLE_API_KEY</span>
</div>
</>
) : apiKeys.google ? (
<>
<div className="flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
<span className="text-green-400 font-medium">Authenticated</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="w-3 h-3 shrink-0" />
<span>Using stored API key</span>
</div>
</>
) : (
<div className="flex items-center gap-1.5 text-yellow-500 py-0.5">
<AlertCircle className="w-3 h-3 shrink-0" />
<span className="text-xs">Not configured</span>
</div>
)}
</div>
</div>
</div>
</div>
);

View File

@@ -10,7 +10,6 @@ interface TestResult {
interface ApiKeyStatus {
hasAnthropicKey: boolean;
hasOpenAIKey: boolean;
hasGoogleKey: boolean;
}
@@ -24,12 +23,10 @@ export function useApiKeyManagement() {
// 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);
@@ -38,10 +35,6 @@ export function useApiKeyManagement() {
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);
@@ -53,7 +46,6 @@ export function useApiKeyManagement() {
useEffect(() => {
setAnthropicKey(apiKeys.anthropic);
setGoogleKey(apiKeys.google);
setOpenaiKey(apiKeys.openai);
}, [apiKeys]);
// Check API key status from environment on mount
@@ -66,7 +58,6 @@ export function useApiKeyManagement() {
if (status.success) {
setApiKeyStatus({
hasAnthropicKey: status.hasAnthropicKey,
hasOpenAIKey: status.hasOpenAIKey,
hasGoogleKey: status.hasGoogleKey,
});
}
@@ -152,68 +143,11 @@ export function useApiKeyManagement() {
}
};
// 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);
@@ -240,15 +174,6 @@ export function useApiKeyManagement() {
onTest: handleTestGeminiConnection,
result: geminiTestResult,
},
openai: {
value: openaiKey,
setValue: setOpenaiKey,
show: showOpenaiKey,
setShow: setShowOpenaiKey,
testing: testingOpenaiConnection,
onTest: handleTestOpenaiConnection,
result: openaiTestResult,
},
};
return {

View File

@@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Palette } from "lucide-react";
import { themeOptions } from "@/config/theme-options";
import { cn } from "@/lib/utils";
import type { Theme, Project } from "../shared/types";
interface AppearanceSectionProps {
@@ -18,39 +19,65 @@ export function AppearanceSection({
return (
<div
id="appearance"
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
className={cn(
"rounded-2xl overflow-hidden scroll-mt-6",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
)}
>
<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 className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Palette className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Appearance</h2>
</div>
<p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground/80 ml-12">
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">
<div className="space-y-4">
<Label className="text-foreground font-medium">
Theme{" "}
{currentProject ? `(for ${currentProject.name})` : "(Global)"}
<span className="text-muted-foreground font-normal">
{currentProject ? `(for ${currentProject.name})` : "(Global)"}
</span>
</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
<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" : ""
}`}
className={cn(
"group flex items-center justify-center gap-2.5 px-4 py-3.5 rounded-xl",
"text-sm font-medium transition-all duration-200 ease-out",
isActive
? [
"bg-gradient-to-br from-brand-500/15 to-brand-600/10",
"border-2 border-brand-500/40",
"text-foreground",
"shadow-md shadow-brand-500/10",
]
: [
"bg-accent/30 hover:bg-accent/50",
"border border-border/50 hover:border-border",
"text-muted-foreground hover:text-foreground",
"hover:shadow-sm",
],
"hover:scale-[1.02] active:scale-[0.98]"
)}
data-testid={testId}
>
<Icon className="w-4 h-4" />
<span className="font-medium text-sm">{label}</span>
</Button>
<Icon className={cn(
"w-4 h-4 transition-all duration-200",
isActive ? "text-brand-500" : "group-hover:text-brand-400"
)} />
<span>{label}</span>
</button>
);
})}
</div>

View File

@@ -5,6 +5,7 @@ import {
AlertCircle,
RefreshCw,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { CliStatus } from "../shared/types";
interface CliStatusProps {
@@ -23,13 +24,20 @@ export function ClaudeCliStatus({
return (
<div
id="claude"
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
className={cn(
"rounded-2xl overflow-hidden scroll-mt-6",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
)}
>
<div className="p-6 border-b border-border">
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<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">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Terminal className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Claude Code CLI
</h2>
</div>
@@ -40,13 +48,18 @@ export function ClaudeCliStatus({
disabled={isChecking}
data-testid="refresh-claude-cli"
title="Refresh Claude CLI detection"
className={cn(
"h-9 w-9 rounded-lg",
"hover:bg-accent/50 hover:scale-105",
"transition-all duration-200"
)}
>
<RefreshCw
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
className={cn("w-4 h-4", isChecking && "animate-spin")}
/>
</Button>
</div>
<p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground/80 ml-12">
Claude Code CLI provides better performance for long-running tasks,
especially with ultrathink.
</p>
@@ -54,13 +67,15 @@ export function ClaudeCliStatus({
<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">
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">
Claude Code CLI Installed
</p>
<div className="text-xs text-green-400/80 mt-1 space-y-1">
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
{status.method && (
<p>
Method: <span className="font-mono">{status.method}</span>
@@ -68,71 +83,65 @@ export function ClaudeCliStatus({
)}
{status.version && (
<p>
Version:{" "}
<span className="font-mono">{status.version}</span>
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>
Path: <span className="font-mono text-[10px]">{status.path}</span>
</p>
)}
</div>
</div>
</div>
{status.recommendation && (
<p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground/70 ml-1">
{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="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-yellow-400">
<p className="text-sm font-medium text-amber-400">
Claude Code CLI Not Detected
</p>
<p className="text-xs text-yellow-400/80 mt-1">
<p className="text-xs text-amber-400/70 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">
<div className="space-y-3">
<p className="text-xs font-medium text-foreground/80">
Installation Commands:
</p>
<div className="space-y-1">
<div className="space-y-2">
{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">
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">npm</p>
<code className="text-xs text-foreground/80 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">
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">macOS/Linux</p>
<code className="text-xs text-foreground/80 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">
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">Windows (PowerShell)</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.windows}
</code>
</div>

View File

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

View File

@@ -1,4 +1,5 @@
import { Settings } from "lucide-react";
import { cn } from "@/lib/utils";
interface SettingsHeaderProps {
title?: string;
@@ -10,15 +11,24 @@ export function SettingsHeader({
description = "Configure your API keys and preferences",
}: SettingsHeaderProps) {
return (
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
<div className={cn(
"shrink-0",
"border-b border-border/50",
"bg-gradient-to-r from-card/90 via-card/70 to-card/80 backdrop-blur-xl"
)}>
<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 className="flex items-center gap-4">
<div className={cn(
"w-12 h-12 rounded-2xl flex items-center justify-center",
"bg-gradient-to-br from-brand-500 to-brand-600",
"shadow-lg shadow-brand-500/25",
"ring-1 ring-white/10"
)}>
<Settings className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
<p className="text-sm text-muted-foreground">{description}</p>
<h1 className="text-2xl font-bold text-foreground tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground/80 mt-0.5">{description}</p>
</div>
</div>
</div>

View File

@@ -16,8 +16,12 @@ export function SettingsNavigation({
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">
<nav className={cn(
"hidden lg:block w-52 shrink-0",
"border-r border-border/50",
"bg-gradient-to-b from-card/80 via-card/60 to-card/40 backdrop-blur-xl"
)}>
<div className="sticky top-0 p-4 space-y-1.5">
{navItems
.filter((item) => item.id !== "danger" || currentProject)
.map((item) => {
@@ -28,16 +32,32 @@ export function SettingsNavigation({
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",
"group w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ease-out text-left relative overflow-hidden",
isActive
? "bg-brand-500/10 text-brand-500 border border-brand-500/20"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
? [
"bg-gradient-to-r from-brand-500/15 via-brand-500/10 to-brand-600/5",
"text-foreground",
"border border-brand-500/25",
"shadow-sm shadow-brand-500/5",
]
: [
"text-muted-foreground hover:text-foreground",
"hover:bg-accent/50",
"border border-transparent hover:border-border/40",
],
"hover:scale-[1.01] active:scale-[0.98]"
)}
>
{/* Active indicator bar */}
{isActive && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-gradient-to-b from-brand-400 via-brand-500 to-brand-600 rounded-r-full" />
)}
<Icon
className={cn(
"w-4 h-4 shrink-0",
isActive ? "text-brand-500" : ""
"w-4 h-4 shrink-0 transition-all duration-200",
isActive
? "text-brand-500"
: "group-hover:text-brand-400 group-hover:scale-110"
)}
/>
<span className="truncate">{item.label}</span>

View File

@@ -2,7 +2,6 @@ import type { LucideIcon } from "lucide-react";
import {
Key,
Terminal,
Atom,
Palette,
LayoutGrid,
Settings2,
@@ -20,7 +19,6 @@ export interface NavigationItem {
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 },

View File

@@ -1,5 +1,6 @@
import { Button } from "@/components/ui/button";
import { Trash2, Folder } from "lucide-react";
import { Trash2, Folder, AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Project } from "../shared/types";
interface DangerZoneSectionProps {
@@ -16,28 +17,35 @@ export function DangerZoneSection({
return (
<div
id="danger"
className="rounded-xl border border-destructive/30 bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
className={cn(
"rounded-2xl overflow-hidden scroll-mt-6",
"border border-destructive/30",
"bg-gradient-to-br from-destructive/5 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-destructive/5"
)}
>
<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 className="p-6 border-b border-destructive/20 bg-gradient-to-r from-destructive/5 via-transparent to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-destructive/20 to-destructive/10 flex items-center justify-center border border-destructive/20">
<AlertTriangle className="w-5 h-5 text-destructive" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Danger Zone</h2>
</div>
<p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground/80 ml-12">
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">
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
<div className="flex items-center gap-3.5 min-w-0">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/15 to-brand-600/10 border border-brand-500/20 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">
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">
{project.path}
</p>
</div>
@@ -46,6 +54,12 @@ export function DangerZoneSection({
variant="destructive"
onClick={onDeleteClick}
data-testid="delete-project-button"
className={cn(
"shrink-0",
"shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25",
"transition-all duration-200 ease-out",
"hover:scale-[1.02] active:scale-[0.98]"
)}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Project

View File

@@ -1,6 +1,7 @@
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { FlaskConical, Settings2, TestTube, GitBranch } from "lucide-react";
import { cn } from "@/lib/utils";
interface FeatureDefaultsSectionProps {
showProfilesOnly: boolean;
@@ -22,111 +23,116 @@ export function FeatureDefaultsSection({
return (
<div
id="defaults"
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
className={cn(
"rounded-2xl overflow-hidden scroll-mt-6",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
)}
>
<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">
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<FlaskConical className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Feature Defaults
</h2>
</div>
<p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground/80 ml-12">
Configure default settings for new features.
</p>
</div>
<div className="p-6 space-y-4">
<div className="p-6 space-y-5">
{/* 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 className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="show-profiles-only"
checked={showProfilesOnly}
onCheckedChange={(checked) =>
onShowProfilesOnlyChange(checked === true)
}
className="mt-1"
data-testid="show-profiles-only-checkbox"
/>
<div className="space-y-1.5">
<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/80 leading-relaxed">
When enabled, the Add Feature dialog will show only AI profiles
and hide advanced model tweaking options. This creates a cleaner, less
overwhelming UI.
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border" />
<div className="border-t border-border/30" />
{/* Automated Testing 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" />
Enable automated testing by default
</Label>
<p className="text-xs text-muted-foreground">
When enabled, new features will use TDD (test-driven
development) with automated tests. When disabled, features will
require manual verification. You can still override this for
individual features.
</p>
</div>
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="default-skip-tests"
checked={!defaultSkipTests}
onCheckedChange={(checked) =>
onDefaultSkipTestsChange(checked !== true)
}
className="mt-1"
data-testid="default-skip-tests-checkbox"
/>
<div className="space-y-1.5">
<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" />
Enable automated testing by default
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, new features will use TDD with automated tests. When disabled, features will
require manual verification.
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* 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 className="group flex items-start space-x-3 p-3 rounded-xl transition-colors duration-200 -mx-3 opacity-60">
<Checkbox
id="use-worktrees"
checked={useWorktrees}
onCheckedChange={(checked) =>
onUseWorktreesChange(checked === true)
}
disabled={true}
className="mt-1"
data-testid="use-worktrees-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="use-worktrees"
className="text-foreground font-medium flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Enable Git Worktree Isolation
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-amber-500/15 text-amber-500 border border-amber-500/20 font-medium">
experimental
</span>
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Creates isolated git branches for each feature. When disabled,
agents work directly in the main project directory.
</p>
<p className="text-xs text-orange-500/80 leading-relaxed font-medium">
This feature is still under development and temporarily disabled.
</p>
</div>
</div>
</div>

View File

@@ -18,25 +18,17 @@ interface CliStatusResult {
error?: string;
}
interface CodexCliStatusResult extends CliStatusResult {
hasApiKey?: boolean;
}
/**
* Custom hook for managing Claude and Codex CLI status
* Custom hook for managing Claude CLI status
* Handles checking CLI installation, authentication, and refresh functionality
*/
export function useCliStatus() {
const { setClaudeAuthStatus, setCodexAuthStatus } = useSetupStore();
const { setClaudeAuthStatus } = 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(() => {
@@ -53,16 +45,6 @@ export function useCliStatus() {
}
}
// 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 {
@@ -95,47 +77,10 @@ export function useCliStatus() {
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) {
// Cast to extended type that includes server-added fields
const auth = result.auth as typeof result.auth & {
hasSubscription?: boolean;
cliLoggedIn?: boolean;
hasEnvApiKey?: boolean;
};
// Map server method names to client method types
// Server returns: subscription, cli_verified, cli_tokens, api_key, env, none
const validMethods = ["subscription", "cli_verified", "cli_tokens", "api_key", "env", "none"] as const;
type CodexMethod = typeof validMethods[number];
const method: CodexMethod = validMethods.includes(auth.method as CodexMethod)
? (auth.method as CodexMethod)
: auth.authenticated ? "api_key" : "none"; // Default authenticated to api_key
const authStatus = {
authenticated: auth.authenticated,
method,
// Only set apiKeyValid for actual API key methods, not CLI login or subscription
apiKeyValid:
method === "cli_verified" || method === "cli_tokens" || method === "subscription"
? undefined
: auth.hasAuthFile || auth.hasEnvKey || auth.hasEnvApiKey,
hasSubscription: auth.hasSubscription,
cliLoggedIn: auth.cliLoggedIn,
};
setCodexAuthStatus(authStatus);
}
} catch (error) {
console.error("Failed to check Codex auth status:", error);
}
}
};
checkCliStatus();
}, [setClaudeAuthStatus, setCodexAuthStatus]);
}, [setClaudeAuthStatus]);
// Refresh Claude CLI status
const handleRefreshClaudeCli = useCallback(async () => {
@@ -153,28 +98,9 @@ export function useCliStatus() {
}
}, []);
// Refresh Codex CLI status
const handleRefreshCodexCli = useCallback(async () => {
setIsCheckingCodexCli(true);
try {
const api = getElectronAPI();
if (api?.checkCodexCli) {
const status = await api.checkCodexCli();
setCodexCliStatus(status);
}
} catch (error) {
console.error("Failed to refresh Codex CLI status:", error);
} finally {
setIsCheckingCodexCli(false);
}
}, []);
return {
claudeCliStatus,
codexCliStatus,
isCheckingClaudeCli,
isCheckingCodexCli,
handleRefreshClaudeCli,
handleRefreshCodexCli,
};
}

View File

@@ -1,5 +1,6 @@
import { Button } from "@/components/ui/button";
import { Settings2, Keyboard } from "lucide-react";
import { cn } from "@/lib/utils";
interface KeyboardShortcutsSectionProps {
onOpenKeyboardMap: () => void;
@@ -11,43 +12,58 @@ export function KeyboardShortcutsSection({
return (
<div
id="keyboard"
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
className={cn(
"rounded-2xl overflow-hidden scroll-mt-6",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
)}
>
<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">
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Settings2 className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Keyboard Shortcuts
</h2>
</div>
<p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground/80 ml-12">
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="flex flex-col items-center justify-center py-12 text-center space-y-5">
<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 className="w-20 h-20 rounded-2xl bg-gradient-to-br from-brand-500/10 to-brand-600/5 flex items-center justify-center border border-brand-500/20">
<Keyboard className="w-10 h-10 text-brand-500/60" />
</div>
<div className="absolute inset-0 bg-brand-500/10 blur-2xl rounded-full -z-10" />
</div>
<div className="space-y-2 max-w-md">
<h3 className="text-lg font-semibold text-foreground">
Use the Visual Keyboard Map
</h3>
<p className="text-sm text-muted-foreground">
Click the &quot;View Keyboard Map&quot; button above to customize
your keyboard shortcuts. The visual interface shows all available
keys and lets you easily edit shortcuts with single-modifier
restrictions.
<p className="text-sm text-muted-foreground/80">
Click the button below to customize your keyboard shortcuts. The visual
interface shows all available keys and lets you easily edit shortcuts.
</p>
</div>
<Button
variant="default"
size="lg"
onClick={onOpenKeyboardMap}
className="gap-2 mt-4"
className={cn(
"gap-2.5 mt-2 h-11 px-6",
"bg-gradient-to-r from-brand-500 to-brand-600",
"hover:from-brand-600 hover:to-brand-600",
"text-white font-medium border-0",
"shadow-md shadow-brand-500/20 hover:shadow-lg hover:shadow-brand-500/25",
"transition-all duration-200 ease-out",
"hover:scale-[1.02] active:scale-[0.98]"
)}
>
<Keyboard className="w-5 h-5" />
Open Keyboard Map

View File

@@ -7,7 +7,6 @@ import {
WelcomeStep,
CompleteStep,
ClaudeSetupStep,
CodexSetupStep,
} from "./setup-view/steps";
// Main Setup View
@@ -17,17 +16,14 @@ export function SetupView() {
setCurrentStep,
completeSetup,
setSkipClaudeSetup,
setSkipCodexSetup,
} = useSetupStore();
const { setCurrentView } = useAppStore();
const steps = ["welcome", "claude", "codex", "complete"] as const;
const steps = ["welcome", "claude", "complete"] as const;
type StepName = (typeof steps)[number];
const getStepName = (): StepName => {
if (currentStep === "claude_detect" || currentStep === "claude_auth")
return "claude";
if (currentStep === "codex_detect" || currentStep === "codex_auth")
return "codex";
if (currentStep === "welcome") return "welcome";
return "complete";
};
@@ -46,10 +42,6 @@ export function SetupView() {
setCurrentStep("claude_detect");
break;
case "claude":
console.log("[Setup Flow] Moving to codex_detect step");
setCurrentStep("codex_detect");
break;
case "codex":
console.log("[Setup Flow] Moving to complete step");
setCurrentStep("complete");
break;
@@ -62,21 +54,12 @@ export function SetupView() {
case "claude":
setCurrentStep("welcome");
break;
case "codex":
setCurrentStep("claude_detect");
break;
}
};
const handleSkipClaude = () => {
console.log("[Setup Flow] Skipping Claude setup");
setSkipClaudeSetup(true);
setCurrentStep("codex_detect");
};
const handleSkipCodex = () => {
console.log("[Setup Flow] Skipping Codex setup");
setSkipCodexSetup(true);
setCurrentStep("complete");
};
@@ -127,15 +110,6 @@ export function SetupView() {
/>
)}
{(currentStep === "codex_detect" ||
currentStep === "codex_auth") && (
<CodexSetupStep
onNext={() => handleNext("codex")}
onBack={() => handleBack("codex")}
onSkip={handleSkipCodex}
/>
)}
{currentStep === "complete" && (
<CompleteStep onFinish={handleFinish} />
)}

View File

@@ -1,4 +1,4 @@
import { CheckCircle2, XCircle, Loader2 } from "lucide-react";
import { CheckCircle2, XCircle, Loader2, AlertCircle } from "lucide-react";
interface StatusBadgeProps {
status:
@@ -6,7 +6,9 @@ interface StatusBadgeProps {
| "not_installed"
| "checking"
| "authenticated"
| "not_authenticated";
| "not_authenticated"
| "error"
| "unverified";
label: string;
}
@@ -25,11 +27,21 @@ export function StatusBadge({ status, label }: StatusBadgeProps) {
icon: <XCircle className="w-4 h-4" />,
className: "bg-red-500/10 text-red-500 border-red-500/20",
};
case "error":
return {
icon: <XCircle className="w-4 h-4" />,
className: "bg-red-500/10 text-red-500 border-red-500/20",
};
case "checking":
return {
icon: <Loader2 className="w-4 h-4 animate-spin" />,
className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
};
case "unverified":
return {
icon: <AlertCircle className="w-4 h-4" />,
className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
};
}
};

View File

@@ -1,2 +1,2 @@
// Re-export all setup dialog components for easier imports
export { SetupTokenModal } from "./setup-token-modal";
// (SetupTokenModal was removed - setup flow now uses inline API key entry)

View File

@@ -1,262 +0,0 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Loader2,
Terminal,
CheckCircle2,
XCircle,
Copy,
RotateCcw,
} from "lucide-react";
import { toast } from "sonner";
import { useOAuthAuthentication } from "../hooks";
interface SetupTokenModalProps {
open: boolean;
onClose: () => void;
onTokenObtained: (token: string) => void;
}
export function SetupTokenModal({
open,
onClose,
onTokenObtained,
}: SetupTokenModalProps) {
// Use the OAuth authentication hook
const { authState, output, token, error, startAuth, reset } =
useOAuthAuthentication({ cliType: "claude" });
const [manualToken, setManualToken] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when output changes
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [output]);
// Reset state when modal opens/closes
useEffect(() => {
if (open) {
reset();
setManualToken("");
}
}, [open, reset]);
const handleUseToken = useCallback(() => {
const tokenToUse = token || manualToken;
if (tokenToUse.trim()) {
onTokenObtained(tokenToUse.trim());
onClose();
}
}, [token, manualToken, onTokenObtained, onClose]);
const copyCommand = useCallback(() => {
navigator.clipboard.writeText("claude setup-token");
toast.success("Command copied to clipboard");
}, []);
const handleRetry = useCallback(() => {
reset();
setManualToken("");
}, [reset]);
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
className="max-w-2xl bg-card border-border"
data-testid="setup-token-modal"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<Terminal className="w-5 h-5 text-brand-500" />
Claude Subscription Authentication
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{authState === "idle" &&
"Click Start to begin the authentication process."}
{authState === "running" &&
"Complete the sign-in in your browser..."}
{authState === "success" &&
"Authentication successful! Your token has been captured."}
{authState === "error" &&
"Authentication failed. Please try again or enter the token manually."}
{authState === "manual" &&
"Copy the token from your terminal and paste it below."}
</DialogDescription>
</DialogHeader>
{/* Terminal Output */}
<div
ref={scrollRef}
className="bg-zinc-900 rounded-lg p-4 font-mono text-sm max-h-48 overflow-y-auto border border-border mt-3"
>
{output.map((line, index) => (
<div key={index} className="text-zinc-300 whitespace-pre-wrap">
{line.startsWith("Error") || line.startsWith("⚠") ? (
<span className="text-yellow-400">{line}</span>
) : line.startsWith("✓") ? (
<span className="text-green-400">{line}</span>
) : (
line
)}
</div>
))}
{output.length === 0 && (
<div className="text-zinc-500 italic">Waiting to start...</div>
)}
{authState === "running" && (
<div className="flex items-center gap-2 text-brand-400 mt-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span>Waiting for authentication...</span>
</div>
)}
</div>
{/* Manual Token Input (for fallback) */}
{(authState === "manual" || authState === "error") && (
<div className="space-y-3 pt-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Run this command in your terminal:</span>
<code className="bg-muted px-2 py-1 rounded font-mono text-foreground">
claude setup-token
</code>
<Button
variant="ghost"
size="icon"
onClick={copyCommand}
className="h-7 w-7"
>
<Copy className="w-4 h-4" />
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="manual-token" className="text-foreground">
Paste your token:
</Label>
<Input
id="manual-token"
type="password"
placeholder="Paste token here..."
value={manualToken}
onChange={(e) => setManualToken(e.target.value)}
className="bg-input border-border text-foreground"
data-testid="manual-token-input"
/>
</div>
</div>
)}
{/* Success State */}
{authState === "success" && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-6 h-6 text-green-500 shrink-0" />
<div>
<p className="font-medium text-foreground">
Token captured successfully!
</p>
<p className="text-sm text-muted-foreground">
Click &quot;Use Token&quot; to save and continue.
</p>
</div>
</div>
)}
{/* Error State */}
{error && authState === "error" && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
<XCircle className="w-6 h-6 text-red-500 shrink-0" />
<div>
<p className="font-medium text-foreground">Error</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
)}
<DialogFooter className="mt-5 flex gap-2">
<Button
variant="outline"
onClick={onClose}
className="text-muted-foreground hover:text-foreground"
>
Cancel
</Button>
{authState === "idle" && (
<Button
onClick={startAuth}
className="bg-brand-500 hover:bg-brand-600 text-white"
data-testid="start-auth-button"
>
<Terminal className="w-4 h-4 mr-2" />
Start Authentication
</Button>
)}
{authState === "running" && (
<Button disabled className="bg-brand-500">
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Authenticating...
</Button>
)}
{authState === "success" && (
<Button
onClick={handleUseToken}
className="bg-green-500 hover:bg-green-600 text-white"
data-testid="use-token-button"
>
<CheckCircle2 className="w-4 h-4 mr-2" />
Use Token
</Button>
)}
{authState === "manual" && (
<Button
onClick={handleUseToken}
disabled={!manualToken.trim()}
className="bg-brand-500 hover:bg-brand-600 text-white disabled:opacity-50"
data-testid="use-manual-token-button"
>
<CheckCircle2 className="w-4 h-4 mr-2" />
Use Token
</Button>
)}
{authState === "error" && (
<>
{manualToken.trim() && (
<Button
onClick={handleUseToken}
className="bg-green-500 hover:bg-green-600 text-white"
>
Use Manual Token
</Button>
)}
<Button
onClick={handleRetry}
className="bg-brand-500 hover:bg-brand-600 text-white"
>
<RotateCcw className="w-4 h-4 mr-2" />
Retry
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,5 +1,4 @@
// Re-export all hooks for easier imports
export { useCliStatus } from "./use-cli-status";
export { useCliInstallation } from "./use-cli-installation";
export { useOAuthAuthentication } from "./use-oauth-authentication";
export { useTokenSave } from "./use-token-save";

View File

@@ -2,7 +2,7 @@ import { useState, useCallback } from "react";
import { toast } from "sonner";
interface UseCliInstallationOptions {
cliType: "claude" | "codex";
cliType: "claude";
installApi: () => Promise<any>;
onProgressEvent?: (callback: (progress: any) => void) => (() => void) | undefined;
onSuccess?: () => void;

View File

@@ -1,7 +1,7 @@
import { useState, useCallback } from "react";
interface UseCliStatusOptions {
cliType: "claude" | "codex";
cliType: "claude";
statusApi: () => Promise<any>;
setCliStatus: (status: any) => void;
setAuthStatus: (status: any) => void;
@@ -33,65 +33,35 @@ export function useCliStatus({
setCliStatus(cliStatus);
if (result.auth) {
if (cliType === "claude") {
// Validate method is one of the expected values, default to "none"
const validMethods = [
"oauth_token_env",
"oauth_token",
"api_key",
"api_key_env",
"credentials_file",
"cli_authenticated",
"none",
] as const;
type AuthMethod = (typeof validMethods)[number];
const method: AuthMethod = validMethods.includes(
result.auth.method as AuthMethod
)
? (result.auth.method as AuthMethod)
: "none";
const authStatus = {
authenticated: result.auth.authenticated,
method,
hasCredentialsFile: false,
oauthTokenValid:
result.auth.hasStoredOAuthToken ||
result.auth.hasEnvOAuthToken,
apiKeyValid:
result.auth.hasStoredApiKey || result.auth.hasEnvApiKey,
hasEnvOAuthToken: result.auth.hasEnvOAuthToken,
hasEnvApiKey: result.auth.hasEnvApiKey,
};
setAuthStatus(authStatus);
} else {
// Codex auth status mapping
const mapAuthMethod = (method?: string): any => {
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 method = mapAuthMethod(result.auth.method);
const authStatus = {
authenticated: result.auth.authenticated,
method,
apiKeyValid:
method === "cli_verified" || method === "cli_tokens"
? undefined
: result.auth.authenticated,
};
console.log(`[${cliType} Setup] Auth Status:`, authStatus);
setAuthStatus(authStatus);
}
// Validate method is one of the expected values, default to "none"
const validMethods = [
"oauth_token_env",
"oauth_token",
"api_key",
"api_key_env",
"credentials_file",
"cli_authenticated",
"none",
] as const;
type AuthMethod = (typeof validMethods)[number];
const method: AuthMethod = validMethods.includes(
result.auth.method as AuthMethod
)
? (result.auth.method as AuthMethod)
: "none";
const authStatus = {
authenticated: result.auth.authenticated,
method,
hasCredentialsFile: false,
oauthTokenValid:
result.auth.hasStoredOAuthToken ||
result.auth.hasEnvOAuthToken,
apiKeyValid:
result.auth.hasStoredApiKey || result.auth.hasEnvApiKey,
hasEnvOAuthToken: result.auth.hasEnvOAuthToken,
hasEnvApiKey: result.auth.hasEnvApiKey,
};
setAuthStatus(authStatus);
}
}
} catch (error) {

View File

@@ -1,177 +0,0 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { getElectronAPI } from "@/lib/electron";
type AuthState = "idle" | "running" | "success" | "error" | "manual";
interface UseOAuthAuthenticationOptions {
cliType: "claude" | "codex";
enabled?: boolean;
}
export function useOAuthAuthentication({
cliType,
enabled = true,
}: UseOAuthAuthenticationOptions) {
const [authState, setAuthState] = useState<AuthState>("idle");
const [output, setOutput] = useState<string[]>([]);
const [token, setToken] = useState("");
const [error, setError] = useState<string | null>(null);
const unsubscribeRef = useRef<(() => void) | null>(null);
// Reset state when disabled
useEffect(() => {
if (!enabled) {
setAuthState("idle");
setOutput([]);
setToken("");
setError(null);
// Cleanup subscription
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
}
}, [enabled]);
const startAuth = useCallback(async () => {
const api = getElectronAPI();
if (!api.setup) {
setError("Setup API not available");
setAuthState("error");
return;
}
setAuthState("running");
setOutput([
"Starting authentication...",
`Running ${cliType} CLI in an embedded terminal so you don't need to copy/paste.`,
"When your browser opens, complete sign-in and return here.",
"",
]);
setError(null);
setToken("");
// Subscribe to progress events
if (api.setup.onAuthProgress) {
unsubscribeRef.current = api.setup.onAuthProgress((progress) => {
if (progress.cli === cliType && progress.data) {
// Split by newlines and add each line
const normalized = progress.data.replace(/\r/g, "\n");
const lines = normalized
.split("\n")
.map((line: string) => line.trimEnd())
.filter((line: string) => line.length > 0);
if (lines.length > 0) {
setOutput((prev) => [...prev, ...lines]);
}
}
});
}
try {
// Call the appropriate auth API based on cliType
const result =
cliType === "claude"
? await api.setup.authClaude()
: await api.setup.authCodex?.();
// Cleanup subscription
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
if (!result) {
setError("Authentication API not available");
setAuthState("error");
return;
}
// Check for token (only available for Claude)
const resultToken =
cliType === "claude" && "token" in result ? result.token : undefined;
const resultTerminalOpened =
cliType === "claude" && "terminalOpened" in result
? result.terminalOpened
: false;
if (result.success && resultToken && typeof resultToken === "string") {
setToken(resultToken);
setAuthState("success");
setOutput((prev) => [
...prev,
"",
"✓ Authentication successful!",
"✓ Token captured automatically.",
]);
} else if (result.requiresManualAuth) {
// Terminal was opened - user needs to copy token manually
setAuthState("manual");
// Don't add extra messages if terminalOpened - the progress messages already explain
if (!resultTerminalOpened) {
const extraMessages = [
"",
"⚠ Could not capture token automatically.",
];
if (result.error) {
extraMessages.push(result.error);
}
setOutput((prev) => [
...prev,
...extraMessages,
"Please copy the token from above and paste it below.",
]);
}
} else {
setError(result.error || "Authentication failed");
setAuthState("error");
}
} catch (err: unknown) {
// Cleanup subscription
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
const errorMessage =
err instanceof Error
? err.message
: typeof err === "object" && err !== null && "error" in err
? String((err as { error: unknown }).error)
: "Authentication failed";
// Check if we should fall back to manual mode
if (
typeof err === "object" &&
err !== null &&
"requiresManualAuth" in err &&
(err as { requiresManualAuth: boolean }).requiresManualAuth
) {
setAuthState("manual");
setOutput((prev) => [
...prev,
"",
"⚠ " + errorMessage,
"Please copy the token manually and paste it below.",
]);
} else {
setError(errorMessage);
setAuthState("error");
}
}
}, [cliType]);
const reset = useCallback(() => {
setAuthState("idle");
setOutput([]);
setToken("");
setError(null);
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
}, []);
return { authState, output, token, error, startAuth, reset };
}

View File

@@ -1,445 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useSetupStore } from "@/store/setup-store";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import {
CheckCircle2,
Loader2,
Terminal,
Key,
ArrowRight,
ArrowLeft,
ExternalLink,
Copy,
AlertCircle,
RefreshCw,
Download,
} from "lucide-react";
import { toast } from "sonner";
import { StatusBadge, TerminalOutput } from "../components";
import {
useCliStatus,
useCliInstallation,
useTokenSave,
} from "../hooks";
interface CodexSetupStepProps {
onNext: () => void;
onBack: () => void;
onSkip: () => void;
}
export function CodexSetupStep({
onNext,
onBack,
onSkip,
}: CodexSetupStepProps) {
const {
codexCliStatus,
codexAuthStatus,
setCodexCliStatus,
setCodexAuthStatus,
setCodexInstallProgress,
} = useSetupStore();
const { setApiKeys, apiKeys } = useAppStore();
const [showApiKeyInput, setShowApiKeyInput] = useState(false);
const [apiKey, setApiKey] = useState("");
// Memoize API functions to prevent infinite loops
const statusApi = useCallback(
() => getElectronAPI().setup?.getCodexStatus() || Promise.reject(),
[]
);
const installApi = useCallback(
() => getElectronAPI().setup?.installCodex() || Promise.reject(),
[]
);
// Use custom hooks
const { isChecking, checkStatus } = useCliStatus({
cliType: "codex",
statusApi,
setCliStatus: setCodexCliStatus,
setAuthStatus: setCodexAuthStatus,
});
const onInstallSuccess = useCallback(() => {
checkStatus();
}, [checkStatus]);
const { isInstalling, installProgress, install } = useCliInstallation({
cliType: "codex",
installApi,
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
onSuccess: onInstallSuccess,
});
const { isSaving: isSavingKey, saveToken: saveApiKeyToken } = useTokenSave({
provider: "openai",
onSuccess: () => {
setCodexAuthStatus({
authenticated: true,
method: "api_key",
apiKeyValid: true,
});
setApiKeys({ ...apiKeys, openai: apiKey });
setShowApiKeyInput(false);
checkStatus();
},
});
// Sync install progress to store
useEffect(() => {
setCodexInstallProgress({
isInstalling,
output: installProgress.output,
});
}, [isInstalling, installProgress, setCodexInstallProgress]);
// Check status on mount
useEffect(() => {
checkStatus();
}, [checkStatus]);
const copyCommand = (command: string) => {
navigator.clipboard.writeText(command);
toast.success("Command copied to clipboard");
};
const isAuthenticated = codexAuthStatus?.authenticated || apiKeys.openai;
const getAuthMethodLabel = () => {
if (!isAuthenticated) return null;
if (apiKeys.openai) return "API Key (Manual)";
if (codexAuthStatus?.method === "api_key") return "API Key (Auth File)";
if (codexAuthStatus?.method === "env") return "API Key (Environment)";
if (codexAuthStatus?.method === "cli_verified")
return "CLI Login (ChatGPT)";
return "Authenticated";
};
return (
<div className="space-y-6">
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-xl bg-green-500/10 flex items-center justify-center mx-auto mb-4">
<Terminal className="w-8 h-8 text-green-500" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">
Codex CLI Setup
</h2>
<p className="text-muted-foreground">
OpenAI&apos;s GPT-5.1 Codex for advanced code generation
</p>
</div>
{/* Status Card */}
<Card className="bg-card border-border">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Installation Status</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={checkStatus}
disabled={isChecking}
>
<RefreshCw
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
/>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-foreground">CLI Installation</span>
{isChecking ? (
<StatusBadge status="checking" label="Checking..." />
) : codexCliStatus?.installed ? (
<StatusBadge status="installed" label="Installed" />
) : (
<StatusBadge status="not_installed" label="Not Installed" />
)}
</div>
{codexCliStatus?.version && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Version</span>
<span className="text-sm font-mono text-foreground">
{codexCliStatus.version}
</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm text-foreground">Authentication</span>
{isAuthenticated ? (
<div className="flex items-center gap-2">
<StatusBadge status="authenticated" label="Authenticated" />
{getAuthMethodLabel() && (
<span className="text-xs text-muted-foreground">
({getAuthMethodLabel()})
</span>
)}
</div>
) : (
<StatusBadge
status="not_authenticated"
label="Not Authenticated"
/>
)}
</div>
</CardContent>
</Card>
{/* Installation Section */}
{!codexCliStatus?.installed && (
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Download className="w-5 h-5" />
Install Codex CLI
</CardTitle>
<CardDescription>
Install via npm (Node.js required)
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
npm (Global installation)
</Label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
npm install -g @openai/codex
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("npm install -g @openai/codex")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
{isInstalling && (
<TerminalOutput lines={installProgress.output} />
)}
<div className="flex gap-2">
<Button
onClick={install}
disabled={isInstalling}
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
data-testid="install-codex-button"
>
{isInstalling ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Installing...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Auto Install
</>
)}
</Button>
</div>
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-yellow-500 mt-0.5" />
<p className="text-xs text-yellow-600 dark:text-yellow-400">
Requires Node.js to be installed. If the auto-install fails,
try running the command manually in your terminal.
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Authentication Section */}
{!isAuthenticated && (
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Key className="w-5 h-5" />
Authentication
</CardTitle>
<CardDescription>Codex requires an OpenAI API key</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{codexCliStatus?.installed && (
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<div className="flex items-start gap-3">
<Terminal className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<p className="font-medium text-foreground">
Authenticate via CLI
</p>
<p className="text-sm text-muted-foreground mb-2">
Run this command in your terminal:
</p>
<div className="flex items-center gap-2">
<code className="bg-muted px-3 py-1 rounded text-sm font-mono text-foreground">
codex auth login
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("codex auth login")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
)}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">
or enter API key
</span>
</div>
</div>
{showApiKeyInput ? (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="openai-key" className="text-foreground">
OpenAI API Key
</Label>
<Input
id="openai-key"
type="password"
placeholder="sk-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="bg-input border-border text-foreground"
data-testid="openai-api-key-input"
/>
<p className="text-xs text-muted-foreground">
Get your API key from{" "}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-green-500 hover:underline"
>
platform.openai.com
<ExternalLink className="w-3 h-3 inline ml-1" />
</a>
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setShowApiKeyInput(false)}
className="border-border"
>
Cancel
</Button>
<Button
onClick={() => saveApiKeyToken(apiKey)}
disabled={isSavingKey || !apiKey.trim()}
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
data-testid="save-openai-key-button"
>
{isSavingKey ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Save API Key"
)}
</Button>
</div>
</div>
) : (
<Button
variant="outline"
onClick={() => setShowApiKeyInput(true)}
className="w-full border-border"
data-testid="use-openai-key-button"
>
<Key className="w-4 h-4 mr-2" />
Enter OpenAI API Key
</Button>
)}
</CardContent>
</Card>
)}
{/* Success State */}
{isAuthenticated && (
<Card className="bg-green-500/5 border-green-500/20">
<CardContent className="py-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center">
<CheckCircle2 className="w-6 h-6 text-green-500" />
</div>
<div>
<p className="font-medium text-foreground">
Codex is ready to use!
</p>
<p className="text-sm text-muted-foreground">
{getAuthMethodLabel() &&
`Authenticated via ${getAuthMethodLabel()}. `}
You can proceed to complete setup
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Navigation */}
<div className="flex justify-between pt-4">
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<div className="flex gap-2">
<Button
variant="ghost"
onClick={onSkip}
className="text-muted-foreground"
>
Skip for now
</Button>
<Button
onClick={onNext}
className="bg-green-500 hover:bg-green-600 text-white"
data-testid="codex-next-button"
>
Continue
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,11 +1,6 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
CheckCircle2,
AlertCircle,
Shield,
Sparkles,
} from "lucide-react";
import { CheckCircle2, AlertCircle, Shield, Sparkles } from "lucide-react";
import { useSetupStore } from "@/store/setup-store";
import { useAppStore } from "@/store/app-store";
@@ -14,16 +9,12 @@ interface CompleteStepProps {
}
export function CompleteStep({ onFinish }: CompleteStepProps) {
const { claudeCliStatus, claudeAuthStatus, codexCliStatus, codexAuthStatus } =
useSetupStore();
const { claudeCliStatus, claudeAuthStatus } = useSetupStore();
const { apiKeys } = useAppStore();
const claudeReady =
(claudeCliStatus?.installed && claudeAuthStatus?.authenticated) ||
apiKeys.anthropic;
const codexReady =
(codexCliStatus?.installed && codexAuthStatus?.authenticated) ||
apiKeys.openai;
return (
<div className="text-center space-y-6">
@@ -41,66 +32,6 @@ export function CompleteStep({ onFinish }: CompleteStepProps) {
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto">
<Card
className={`bg-card/50 border ${
claudeReady ? "border-green-500/50" : "border-yellow-500/50"
}`}
>
<CardContent className="py-4">
<div className="flex items-center gap-3">
{claudeReady ? (
<CheckCircle2 className="w-6 h-6 text-green-500" />
) : (
<AlertCircle className="w-6 h-6 text-yellow-500" />
)}
<div className="text-left">
<p className="font-medium text-foreground">Claude</p>
<p className="text-sm text-muted-foreground">
{claudeReady ? "Ready to use" : "Configure later in settings"}
</p>
</div>
</div>
</CardContent>
</Card>
<Card
className={`bg-card/50 border ${
codexReady ? "border-green-500/50" : "border-yellow-500/50"
}`}
>
<CardContent className="py-4">
<div className="flex items-center gap-3">
{codexReady ? (
<CheckCircle2 className="w-6 h-6 text-green-500" />
) : (
<AlertCircle className="w-6 h-6 text-yellow-500" />
)}
<div className="text-left">
<p className="font-medium text-foreground">Codex</p>
<p className="text-sm text-muted-foreground">
{codexReady ? "Ready to use" : "Configure later in settings"}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="p-4 rounded-lg bg-muted/50 border border-border max-w-md mx-auto">
<div className="flex items-start gap-3">
<Shield className="w-5 h-5 text-brand-500 mt-0.5" />
<div className="text-left">
<p className="text-sm font-medium text-foreground">
Your credentials are secure
</p>
<p className="text-xs text-muted-foreground">
API keys are stored locally and never sent to our servers
</p>
</div>
</div>
</div>
<Button
size="lg"
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"

View File

@@ -2,4 +2,3 @@
export { WelcomeStep } from "./welcome-step";
export { CompleteStep } from "./complete-step";
export { ClaudeSetupStep } from "./claude-setup-step";
export { CodexSetupStep } from "./codex-setup-step";

View File

@@ -19,42 +19,11 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
Welcome to Automaker
</h2>
<p className="text-muted-foreground max-w-md mx-auto">
Let&apos;s set up your development environment. We&apos;ll check for
required CLI tools and help you configure them.
To get started, we&apos;ll need to verify either claude code cli is
installed or you have Anthropic api keys
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto">
<Card className="bg-card/50 border-border hover:border-brand-500/50 transition-colors">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Terminal className="w-5 h-5 text-brand-500" />
Claude CLI
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Anthropic&apos;s powerful AI assistant for code generation and
analysis
</p>
</CardContent>
</Card>
<Card className="bg-card/50 border-border hover:border-brand-500/50 transition-colors">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Terminal className="w-5 h-5 text-green-500" />
Codex CLI
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
OpenAI&apos;s GPT-5.1 Codex for advanced code generation tasks
</p>
</CardContent>
</Card>
</div>
<Button
size="lg"
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"

File diff suppressed because it is too large Load Diff

View File

@@ -236,7 +236,13 @@ export function WelcomeView() {
const projectPath = `${parentDir}/${projectName}`;
// Create project directory
await api.mkdir(projectPath);
const mkdirResult = await api.mkdir(projectPath);
if (!mkdirResult.success) {
toast.error("Failed to create project directory", {
description: mkdirResult.error || "Unknown error occurred",
});
return;
}
// Initialize .automaker directory with all necessary files
const initResult = await initializeProject(projectPath);
@@ -249,6 +255,7 @@ export function WelcomeView() {
}
// Update the app_spec.txt with the project name
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
@@ -346,6 +353,7 @@ export function WelcomeView() {
}
// Update the app_spec.txt with template-specific info
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
@@ -450,6 +458,7 @@ export function WelcomeView() {
}
// Update the app_spec.txt with basic info
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
@@ -521,17 +530,17 @@ export function WelcomeView() {
return (
<div className="flex-1 flex flex-col content-bg" data-testid="welcome-view">
{/* Header Section */}
<div className="flex-shrink-0 border-b border-border bg-glass backdrop-blur-md">
<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 mb-2">
<div className="w-10 h-10 rounded-xl flex items-center justify-center">
<img src="/logo.png" alt="Automaker Logo" className="w-10 h-10" />
<div className="flex items-center gap-4 animate-in fade-in slide-in-from-top-2 duration-500">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shadow-lg shadow-brand-500/10">
<img src="/logo.png" alt="Automaker Logo" className="w-8 h-8" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">
<h1 className="text-2xl font-bold text-foreground tracking-tight">
Welcome to Automaker
</h1>
<p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground mt-0.5">
Your autonomous AI development studio
</p>
</div>
@@ -541,24 +550,25 @@ export function WelcomeView() {
{/* Content Area */}
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-6xl mx-auto">
<div className="max-w-5xl mx-auto">
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-12 animate-in fade-in slide-in-from-bottom-4 duration-500 delay-100">
{/* New Project Card */}
<div
className="group relative overflow-hidden rounded-xl border border-border bg-card backdrop-blur-md hover:bg-card/70 hover:border-border-glass transition-all duration-200"
className="group relative rounded-xl border border-border bg-card/80 backdrop-blur-sm hover:bg-card hover:border-brand-500/30 hover:shadow-xl hover:shadow-brand-500/5 transition-all duration-300 hover:-translate-y-1"
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="absolute inset-0 rounded-xl bg-gradient-to-br from-brand-500/5 via-transparent to-purple-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<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">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/25 flex items-center justify-center group-hover:scale-105 group-hover:shadow-brand-500/40 transition-all duration-300 shrink-0">
<Plus className="w-6 h-6 text-white" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-foreground mb-1">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground mb-1.5">
New Project
</h3>
<p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground leading-relaxed">
Create a new project from scratch with AI-powered
development
</p>
@@ -567,7 +577,7 @@ export function WelcomeView() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
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"
className="w-full mt-5 bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white border-0 shadow-md shadow-brand-500/20 hover:shadow-brand-500/30 transition-all"
data-testid="create-new-project"
>
<Plus className="w-4 h-4 mr-2" />
@@ -595,29 +605,30 @@ export function WelcomeView() {
</div>
</div>
{/* Open Project Card */}
<div
className="group relative overflow-hidden rounded-xl border border-border bg-card backdrop-blur-md hover:bg-card/70 hover:border-border-glass transition-all duration-200 cursor-pointer"
className="group relative rounded-xl border border-border bg-card/80 backdrop-blur-sm hover:bg-card hover:border-blue-500/30 hover:shadow-xl hover:shadow-blue-500/5 transition-all duration-300 cursor-pointer hover:-translate-y-1"
onClick={handleOpenProject}
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="absolute inset-0 rounded-xl bg-gradient-to-br from-blue-500/5 via-transparent to-cyan-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<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 className="w-12 h-12 rounded-xl bg-muted border border-border flex items-center justify-center group-hover:bg-blue-500/10 group-hover:border-blue-500/30 group-hover:scale-105 transition-all duration-300 shrink-0">
<FolderOpen className="w-6 h-6 text-muted-foreground group-hover:text-blue-500 transition-colors duration-300" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-foreground mb-1">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground mb-1.5">
Open Project
</h3>
<p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground leading-relaxed">
Open an existing project folder to continue working
</p>
</div>
</div>
<Button
variant="secondary"
className="w-full mt-4 bg-secondary hover:bg-secondary/80 text-foreground border border-border hover:border-border-glass"
className="w-full mt-5 bg-secondary/80 hover:bg-secondary text-foreground border border-border hover:border-blue-500/30 transition-all"
data-testid="open-existing-project"
>
<FolderOpen className="w-4 h-4 mr-2" />
@@ -629,36 +640,39 @@ export function WelcomeView() {
{/* Recent Projects */}
{recentProjects.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-4">
<Clock className="w-5 h-5 text-muted-foreground" />
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500 delay-200">
<div className="flex items-center gap-2.5 mb-5">
<div className="w-8 h-8 rounded-lg bg-muted/50 flex items-center justify-center">
<Clock className="w-4 h-4 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold text-foreground">
Recent Projects
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{recentProjects.map((project) => (
{recentProjects.map((project, index) => (
<div
key={project.id}
className="group relative overflow-hidden rounded-xl border border-border bg-card backdrop-blur-md hover:bg-card/70 hover:border-brand-500/50 transition-all duration-200 cursor-pointer"
className="group relative rounded-xl border border-border bg-card/60 backdrop-blur-sm hover:bg-card hover:border-brand-500/40 hover:shadow-lg hover:shadow-brand-500/5 transition-all duration-300 cursor-pointer hover:-translate-y-0.5"
onClick={() => handleRecentProjectClick(project)}
data-testid={`recent-project-${project.id}`}
style={{ animationDelay: `${index * 50}ms` }}
>
<div className="absolute inset-0 bg-gradient-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all"></div>
<div className="absolute inset-0 rounded-xl bg-gradient-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all duration-300" />
<div className="relative p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-muted border border-border flex items-center justify-center group-hover:border-brand-500/50 transition-colors">
<Folder className="w-5 h-5 text-muted-foreground group-hover:text-brand-500 transition-colors" />
<div className="w-10 h-10 rounded-lg bg-muted/80 border border-border flex items-center justify-center group-hover:bg-brand-500/10 group-hover:border-brand-500/30 transition-all duration-300 shrink-0">
<Folder className="w-5 h-5 text-muted-foreground group-hover:text-brand-500 transition-colors duration-300" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate group-hover:text-brand-500 transition-colors">
<p className="font-medium text-foreground truncate group-hover:text-brand-500 transition-colors duration-300">
{project.name}
</p>
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">
<p className="text-xs text-muted-foreground/70 truncate mt-1">
{project.path}
</p>
{project.lastOpened && (
<p className="text-xs text-muted-foreground mt-1">
<p className="text-xs text-muted-foreground mt-1.5">
{new Date(
project.lastOpened
).toLocaleDateString()}
@@ -675,14 +689,14 @@ export function WelcomeView() {
{/* Empty State for No Projects */}
{recentProjects.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-16 h-16 rounded-2xl bg-muted border border-border flex items-center justify-center mb-4">
<Sparkles className="w-8 h-8 text-muted-foreground" />
<div className="flex flex-col items-center justify-center py-16 text-center animate-in fade-in duration-500 delay-200">
<div className="w-20 h-20 rounded-2xl bg-muted/50 border border-border flex items-center justify-center mb-5">
<Sparkles className="w-10 h-10 text-muted-foreground/50" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
<h3 className="text-xl font-semibold text-foreground mb-2">
No projects yet
</h3>
<p className="text-sm text-zinc-400 max-w-md">
<p className="text-sm text-muted-foreground max-w-md leading-relaxed">
Get started by creating a new project or opening an existing one
</p>
</div>
@@ -703,35 +717,37 @@ export function WelcomeView() {
{/* Project Initialization Dialog */}
<Dialog open={showInitDialog} onOpenChange={setShowInitDialog}>
<DialogContent
className="bg-card border-border"
className="bg-card border-border shadow-xl"
data-testid="project-init-dialog"
>
<DialogHeader>
<DialogTitle className="text-foreground flex items-center gap-2">
<Sparkles className="w-5 h-5 text-brand-500" />
<DialogTitle className="text-foreground flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-brand-500/10 flex items-center justify-center">
<Sparkles className="w-4 h-4 text-brand-500" />
</div>
{initStatus?.isNewProject
? "Project Initialized"
: "Project Updated"}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
<DialogDescription className="text-muted-foreground mt-1">
{initStatus?.isNewProject
? `Created .automaker directory structure for ${initStatus?.projectName}`
: `Updated missing files in .automaker for ${initStatus?.projectName}`}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="space-y-2">
<div className="space-y-3">
<p className="text-sm text-foreground font-medium">
Created files:
</p>
<ul className="space-y-1.5">
<ul className="space-y-2">
{initStatus?.createdFiles.map((file) => (
<li
key={file}
className="flex items-center gap-2 text-sm text-muted-foreground"
className="flex items-center gap-2.5 text-sm text-muted-foreground"
>
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
<code className="text-xs bg-muted px-2 py-0.5 rounded">
<div className="w-2 h-2 rounded-full bg-green-500" />
<code className="text-xs bg-muted px-2.5 py-1 rounded-md font-mono">
{file}
</code>
</li>
@@ -740,18 +756,18 @@ export function WelcomeView() {
</div>
{initStatus?.isNewProject && (
<div className="mt-4 p-3 rounded-lg bg-muted/50 border border-border-glass">
<div className="mt-5 p-4 rounded-xl bg-muted/50 border border-border">
{isAnalyzing ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-3">
<Loader2 className="w-4 h-4 text-brand-500 animate-spin" />
<p className="text-sm text-brand-400">
<p className="text-sm text-brand-500">
AI agent is analyzing your project structure...
</p>
</div>
) : (
<p className="text-sm text-muted-foreground">
<span className="text-brand-400">Tip:</span> Edit the{" "}
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
<p className="text-sm text-muted-foreground leading-relaxed">
<span className="text-brand-500 font-medium">Tip:</span> Edit the{" "}
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">
app_spec.txt
</code>{" "}
file to describe your project. The AI agent will use this to
@@ -764,7 +780,7 @@ export function WelcomeView() {
<DialogFooter>
<Button
onClick={() => setShowInitDialog(false)}
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white border-0 shadow-md shadow-brand-500/20"
data-testid="close-init-dialog"
>
Get Started
@@ -786,8 +802,8 @@ export function WelcomeView() {
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm"
data-testid="project-opening-overlay"
>
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-card border border-border">
<Loader2 className="w-8 h-8 text-brand-500 animate-spin" />
<div className="flex flex-col items-center gap-4 p-8 rounded-2xl bg-card border border-border shadow-2xl">
<Loader2 className="w-10 h-10 text-brand-500 animate-spin" />
<p className="text-foreground font-medium">
Initializing project...
</p>

View File

@@ -200,7 +200,7 @@ export function WikiView() {
{
icon: Cpu,
title: "Multi-Model Support",
description: "Claude Haiku/Sonnet/Opus + OpenAI Codex models. Choose the right model for each task.",
description: "Claude Haiku/Sonnet/Opus models. Choose the right model for each task.",
},
{
icon: Brain,

View File

@@ -2,7 +2,7 @@ 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 type ProviderKey = "anthropic" | "google";
export interface ProviderConfig {
key: ProviderKey;
@@ -51,26 +51,16 @@ export interface ProviderConfigParams {
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)",
label: "Anthropic API Key",
inputId: "anthropic-key",
placeholder: "sk-ant-...",
value: anthropic.value,
@@ -92,58 +82,32 @@ export const buildProviderConfigs = ({
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",
descriptionSuffix: ".",
},
// {
// 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",
// },
];

View File

@@ -1,6 +0,0 @@
/**
* Marketing mode flag
* When set to true, displays "https://automaker.app" with "maker" in theme color
*/
export const IS_MARKETING = process.env.NEXT_PUBLIC_IS_MARKETING === "true";

View File

@@ -0,0 +1,94 @@
/**
* Model Configuration - Centralized model settings for the app
*
* Models can be overridden via environment variables:
* - AUTOMAKER_MODEL_CHAT: Model for chat interactions
* - AUTOMAKER_MODEL_DEFAULT: Fallback model for all operations
*/
/**
* Claude model aliases for convenience
*/
export const CLAUDE_MODEL_MAP: Record<string, string> = {
haiku: "claude-haiku-4-5",
sonnet: "claude-sonnet-4-20250514",
opus: "claude-opus-4-5-20251101",
} as const;
/**
* Default models per use case
*/
export const DEFAULT_MODELS = {
chat: "claude-opus-4-5-20251101",
default: "claude-opus-4-5-20251101",
} as const;
/**
* Resolve a model alias to a full model string
*/
export function resolveModelString(
modelKey?: string,
defaultModel: string = DEFAULT_MODELS.default
): string {
if (!modelKey) {
return defaultModel;
}
// Full Claude model string - pass through
if (modelKey.includes("claude-")) {
return modelKey;
}
// Check alias map
const resolved = CLAUDE_MODEL_MAP[modelKey];
if (resolved) {
return resolved;
}
// Unknown key - use default
return defaultModel;
}
/**
* Get the model for chat operations
*
* Priority:
* 1. Explicit model parameter
* 2. AUTOMAKER_MODEL_CHAT environment variable
* 3. AUTOMAKER_MODEL_DEFAULT environment variable
* 4. Default chat model
*/
export function getChatModel(explicitModel?: string): string {
if (explicitModel) {
return resolveModelString(explicitModel);
}
const envModel =
process.env.AUTOMAKER_MODEL_CHAT || process.env.AUTOMAKER_MODEL_DEFAULT;
if (envModel) {
return resolveModelString(envModel);
}
return DEFAULT_MODELS.chat;
}
/**
* Default allowed tools for chat interactions
*/
export const CHAT_TOOLS = [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
] as const;
/**
* Default max turns for chat
*/
export const CHAT_MAX_TURNS = 1000;

View File

@@ -1,11 +1,18 @@
"use client";
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
import {
createContext,
useContext,
useState,
useCallback,
type ReactNode,
} from "react";
import { FileBrowserDialog } from "@/components/dialogs/file-browser-dialog";
interface FileBrowserOptions {
title?: string;
description?: string;
initialPath?: string;
}
interface FileBrowserContextValue {
@@ -16,36 +23,47 @@ const FileBrowserContext = createContext<FileBrowserContextValue | null>(null);
export function FileBrowserProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const [resolver, setResolver] = useState<((value: string | null) => void) | null>(null);
const [resolver, setResolver] = useState<
((value: string | null) => void) | null
>(null);
const [dialogOptions, setDialogOptions] = useState<FileBrowserOptions>({});
const openFileBrowser = useCallback((options?: FileBrowserOptions): Promise<string | null> => {
return new Promise((resolve) => {
setDialogOptions(options || {});
setIsOpen(true);
setResolver(() => resolve);
});
}, []);
const openFileBrowser = useCallback(
(options?: FileBrowserOptions): Promise<string | null> => {
return new Promise((resolve) => {
setDialogOptions(options || {});
setIsOpen(true);
setResolver(() => resolve);
});
},
[]
);
const handleSelect = useCallback((path: string) => {
if (resolver) {
resolver(path);
setResolver(null);
}
setIsOpen(false);
setDialogOptions({});
}, [resolver]);
const handleOpenChange = useCallback((open: boolean) => {
if (!open && resolver) {
resolver(null);
setResolver(null);
}
setIsOpen(open);
if (!open) {
const handleSelect = useCallback(
(path: string) => {
if (resolver) {
resolver(path);
setResolver(null);
}
setIsOpen(false);
setDialogOptions({});
}
}, [resolver]);
},
[resolver]
);
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open && resolver) {
resolver(null);
setResolver(null);
}
setIsOpen(open);
if (!open) {
setDialogOptions({});
}
},
[resolver]
);
return (
<FileBrowserContext.Provider value={{ openFileBrowser }}>
@@ -56,6 +74,7 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
onSelect={handleSelect}
title={dialogOptions.title}
description={dialogOptions.description}
initialPath={dialogOptions.initialPath}
/>
</FileBrowserContext.Provider>
);
@@ -70,9 +89,13 @@ export function useFileBrowser() {
}
// Global reference for non-React code (like HttpApiClient)
let globalFileBrowserFn: ((options?: FileBrowserOptions) => Promise<string | null>) | null = null;
let globalFileBrowserFn:
| ((options?: FileBrowserOptions) => Promise<string | null>)
| null = null;
export function setGlobalFileBrowser(fn: (options?: FileBrowserOptions) => Promise<string | null>) {
export function setGlobalFileBrowser(
fn: (options?: FileBrowserOptions) => Promise<string | null>
) {
globalFileBrowserFn = fn;
}

View File

@@ -33,16 +33,21 @@ export function useAutoMode() {
);
// Helper to look up project ID from path
const getProjectIdFromPath = useCallback((path: string): string | undefined => {
const project = projects.find(p => p.path === path);
return project?.id;
}, [projects]);
const getProjectIdFromPath = useCallback(
(path: string): string | undefined => {
const project = projects.find((p) => p.path === path);
return project?.id;
},
[projects]
);
// Get project-specific auto mode state
const projectId = currentProject?.id;
const projectAutoModeState = useMemo(() => {
if (!projectId) return { isRunning: false, runningTasks: [] };
return autoModeByProject[projectId] || { isRunning: false, runningTasks: [] };
return (
autoModeByProject[projectId] || { isRunning: false, runningTasks: [] }
);
}, [autoModeByProject, projectId]);
const isAutoModeRunning = projectAutoModeState.isRunning;
@@ -62,10 +67,10 @@ export function useAutoMode() {
// Events include projectPath from backend - use it to look up project ID
// Fall back to current projectId if not provided in event
let eventProjectId: string | undefined;
if ('projectPath' in event && event.projectPath) {
if ("projectPath" in event && event.projectPath) {
eventProjectId = getProjectIdFromPath(event.projectPath);
}
if (!eventProjectId && 'projectId' in event && event.projectId) {
if (!eventProjectId && "projectId" in event && event.projectId) {
eventProjectId = event.projectId;
}
if (!eventProjectId) {
@@ -74,7 +79,10 @@ export function useAutoMode() {
// Skip event if we couldn't determine the project
if (!eventProjectId) {
console.warn("[AutoMode] Could not determine project for event:", event);
console.warn(
"[AutoMode] Could not determine project for event:",
event
);
return;
}
@@ -111,20 +119,41 @@ export function useAutoMode() {
}
break;
case "auto_mode_complete":
// All features completed for this project
case "auto_mode_stopped":
// Auto mode was explicitly stopped (by user or error)
setAutoModeRunning(eventProjectId, false);
clearRunningTasks(eventProjectId);
console.log("[AutoMode] All features completed!");
console.log("[AutoMode] Auto mode stopped");
break;
case "auto_mode_started":
// Auto mode started - ensure UI reflects running state
console.log("[AutoMode] Auto mode started:", event.message);
break;
case "auto_mode_idle":
// Auto mode is running but has no pending features to pick up
// This is NOT a stop - auto mode keeps running and will pick up new features
console.log("[AutoMode] Auto mode idle - waiting for new features");
break;
case "auto_mode_complete":
// Legacy event - only handle if it looks like a stop (for backwards compatibility)
if (event.message === "Auto mode stopped") {
setAutoModeRunning(eventProjectId, false);
clearRunningTasks(eventProjectId);
console.log("[AutoMode] Auto mode stopped (legacy event)");
}
break;
case "auto_mode_error":
console.error("[AutoMode Error]", event.error);
if (event.featureId && event.error) {
// Check for authentication errors and provide a more helpful message
const isAuthError = event.errorType === "authentication" ||
event.error.includes("Authentication failed") ||
event.error.includes("Invalid API key");
const isAuthError =
event.errorType === "authentication" ||
event.error.includes("Authentication failed") ||
event.error.includes("Invalid API key");
const errorMessage = isAuthError
? `Authentication failed: Please check your API key in Settings or run 'claude login' in terminal to re-authenticate.`
@@ -202,11 +231,12 @@ export function useAutoMode() {
if (!api?.autoMode) return;
// Find all projects that have auto mode marked as running
const projectsToRestart: Array<{ projectId: string; projectPath: string }> = [];
const projectsToRestart: Array<{ projectId: string; projectPath: string }> =
[];
for (const [projectId, state] of Object.entries(autoModeByProject)) {
if (state.isRunning) {
// Find the project path for this project ID
const project = projects.find(p => p.id === projectId);
const project = projects.find((p) => p.id === projectId);
if (project) {
projectsToRestart.push({ projectId, projectPath: project.path });
}
@@ -216,18 +246,27 @@ export function useAutoMode() {
// Restart auto mode for each project
for (const { projectId, projectPath } of projectsToRestart) {
console.log(`[AutoMode] Restoring auto mode for project: ${projectPath}`);
api.autoMode.start(projectPath, maxConcurrency).then(result => {
if (!result.success) {
console.error(`[AutoMode] Failed to restore auto mode for ${projectPath}:`, result.error);
// Mark as not running if we couldn't restart
api.autoMode
.start(projectPath, maxConcurrency)
.then((result) => {
if (!result.success) {
console.error(
`[AutoMode] Failed to restore auto mode for ${projectPath}:`,
result.error
);
// Mark as not running if we couldn't restart
setAutoModeRunning(projectId, false);
} else {
console.log(`[AutoMode] Restored auto mode for ${projectPath}`);
}
})
.catch((error) => {
console.error(
`[AutoMode] Error restoring auto mode for ${projectPath}:`,
error
);
setAutoModeRunning(projectId, false);
} else {
console.log(`[AutoMode] Restored auto mode for ${projectPath}`);
}
}).catch(error => {
console.error(`[AutoMode] Error restoring auto mode for ${projectPath}:`, error);
setAutoModeRunning(projectId, false);
});
});
}
// Only run once on mount - intentionally empty dependency array
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -246,11 +285,16 @@ export function useAutoMode() {
throw new Error("Auto mode API not available");
}
const result = await api.autoMode.start(currentProject.path, maxConcurrency);
const result = await api.autoMode.start(
currentProject.path,
maxConcurrency
);
if (result.success) {
setAutoModeRunning(currentProject.id, true);
console.log(`[AutoMode] Started successfully with maxConcurrency: ${maxConcurrency}`);
console.log(
`[AutoMode] Started successfully with maxConcurrency: ${maxConcurrency}`
);
} else {
console.error("[AutoMode] Failed to start:", result.error);
throw new Error(result.error || "Failed to start auto mode");
@@ -285,7 +329,9 @@ export function useAutoMode() {
// Stopping auto mode only turns off the toggle to prevent new features
// from being picked up. Running tasks will complete naturally and be
// removed via the auto_mode_feature_complete event.
console.log("[AutoMode] Stopped successfully - running tasks will continue");
console.log(
"[AutoMode] Stopped successfully - running tasks will continue"
);
} else {
console.error("[AutoMode] Failed to stop:", result.error);
throw new Error(result.error || "Failed to stop auto mode");

View File

@@ -133,22 +133,32 @@ export interface SuggestionsAPI {
// Spec Regeneration types
export type SpecRegenerationEvent =
| { type: "spec_regeneration_progress"; content: string }
| { type: "spec_regeneration_tool"; tool: string; input: unknown }
| { type: "spec_regeneration_complete"; message: string }
| { type: "spec_regeneration_error"; error: string };
| { type: "spec_regeneration_progress"; content: string; projectPath: string }
| {
type: "spec_regeneration_tool";
tool: string;
input: unknown;
projectPath: string;
}
| { type: "spec_regeneration_complete"; message: string; projectPath: string }
| { type: "spec_regeneration_error"; error: string; projectPath: string };
export interface SpecRegenerationAPI {
create: (
projectPath: string,
projectOverview: string,
generateFeatures?: boolean
generateFeatures?: boolean,
analyzeProject?: boolean,
maxFeatures?: number
) => Promise<{ success: boolean; error?: string }>;
generate: (
projectPath: string,
projectDefinition: string
projectDefinition: string,
generateFeatures?: boolean,
analyzeProject?: boolean,
maxFeatures?: number
) => Promise<{ success: boolean; error?: string }>;
generateFeatures: (projectPath: string) => Promise<{
generateFeatures: (projectPath: string, maxFeatures?: number) => Promise<{
success: boolean;
error?: string;
}>;
@@ -287,22 +297,6 @@ export interface ElectronAPI {
};
error?: string;
}>;
checkCodexCli?: () => Promise<{
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;
}>;
model?: {
getAvailable: () => Promise<{
success: boolean;
@@ -315,11 +309,6 @@ export interface ElectronAPI {
error?: string;
}>;
};
testOpenAIConnection?: (apiKey?: string) => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
worktree?: WorktreeAPI;
git?: GitAPI;
suggestions?: SuggestionsAPI;
@@ -347,32 +336,11 @@ export interface ElectronAPI {
};
error?: string;
}>;
getCodexStatus: () => Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
auth?: {
authenticated: boolean;
method: string; // Can be: "cli_verified", "cli_tokens", "auth_file", "env_var", "none"
hasAuthFile: boolean;
hasEnvKey: boolean;
hasStoredApiKey?: boolean;
hasEnvApiKey?: boolean;
};
error?: string;
}>;
installClaude: () => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
installCodex: () => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
authClaude: () => Promise<{
success: boolean;
token?: string;
@@ -383,25 +351,18 @@ export interface ElectronAPI {
message?: string;
output?: string;
}>;
authCodex: (apiKey?: string) => Promise<{
success: boolean;
requiresManualAuth?: boolean;
command?: string;
error?: string;
}>;
storeApiKey: (
provider: string,
apiKey: string
) => Promise<{ success: boolean; error?: string }>;
deleteApiKey: (
provider: string
) => Promise<{ success: boolean; error?: string; message?: string }>;
getApiKeys: () => Promise<{
success: boolean;
hasAnthropicKey: boolean;
hasOpenAIKey: boolean;
hasGoogleKey: boolean;
}>;
configureCodexMcp: (
projectPath: string
) => Promise<{ success: boolean; configPath?: string; error?: string }>;
getPlatform: () => Promise<{
success: boolean;
platform: string;
@@ -411,6 +372,11 @@ export interface ElectronAPI {
isMac: boolean;
isLinux: boolean;
}>;
verifyClaudeAuth: (authMethod?: "cli" | "api_key") => Promise<{
success: boolean;
authenticated: boolean;
error?: string;
}>;
onInstallProgress?: (callback: (progress: any) => void) => () => void;
onAuthProgress?: (callback: (progress: any) => void) => () => void;
};
@@ -838,22 +804,11 @@ const getMockElectronAPI = (): ElectronAPI => {
recommendation: "Claude CLI checks are unavailable in the web preview.",
}),
checkCodexCli: async () => ({
success: false,
status: "not_installed",
recommendation: "Codex CLI checks are unavailable in the web preview.",
}),
model: {
getAvailable: async () => ({ success: true, models: [] }),
checkProviders: async () => ({ success: true, providers: {} }),
},
testOpenAIConnection: async () => ({
success: false,
error: "OpenAI connection test is only available in the Electron app.",
}),
// Mock Setup API
setup: createMockSetupAPI(),
@@ -903,32 +858,11 @@ interface SetupAPI {
};
error?: string;
}>;
getCodexStatus: () => Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
auth?: {
authenticated: boolean;
method: string; // Can be: "cli_verified", "cli_tokens", "auth_file", "env_var", "none"
hasAuthFile: boolean;
hasEnvKey: boolean;
hasStoredApiKey?: boolean;
hasEnvApiKey?: boolean;
};
error?: string;
}>;
installClaude: () => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
installCodex: () => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
authClaude: () => Promise<{
success: boolean;
token?: string;
@@ -939,12 +873,6 @@ interface SetupAPI {
message?: string;
output?: string;
}>;
authCodex: (apiKey?: string) => Promise<{
success: boolean;
requiresManualAuth?: boolean;
command?: string;
error?: string;
}>;
storeApiKey: (
provider: string,
apiKey: string
@@ -952,12 +880,11 @@ interface SetupAPI {
getApiKeys: () => Promise<{
success: boolean;
hasAnthropicKey: boolean;
hasOpenAIKey: boolean;
hasGoogleKey: boolean;
}>;
configureCodexMcp: (
projectPath: string
) => Promise<{ success: boolean; configPath?: string; error?: string }>;
deleteApiKey: (
provider: string
) => Promise<{ success: boolean; error?: string; message?: string }>;
getPlatform: () => Promise<{
success: boolean;
platform: string;
@@ -967,6 +894,11 @@ interface SetupAPI {
isMac: boolean;
isLinux: boolean;
}>;
verifyClaudeAuth: (authMethod?: "cli" | "api_key") => Promise<{
success: boolean;
authenticated: boolean;
error?: string;
}>;
onInstallProgress?: (callback: (progress: any) => void) => () => void;
onAuthProgress?: (callback: (progress: any) => void) => () => void;
}
@@ -991,20 +923,6 @@ function createMockSetupAPI(): SetupAPI {
};
},
getCodexStatus: async () => {
console.log("[Mock] Getting Codex status");
return {
success: true,
status: "not_installed",
auth: {
authenticated: false,
method: "none",
hasAuthFile: false,
hasEnvKey: false,
},
};
},
installClaude: async () => {
console.log("[Mock] Installing Claude CLI");
// Simulate installation delay
@@ -1016,16 +934,6 @@ function createMockSetupAPI(): SetupAPI {
};
},
installCodex: async () => {
console.log("[Mock] Installing Codex CLI");
await new Promise((resolve) => setTimeout(resolve, 1000));
return {
success: false,
error:
"CLI installation is only available in the Electron app. Please run the command manually.",
};
},
authClaude: async () => {
console.log("[Mock] Auth Claude CLI");
return {
@@ -1035,18 +943,6 @@ function createMockSetupAPI(): SetupAPI {
};
},
authCodex: async (apiKey?: string) => {
console.log("[Mock] Auth Codex CLI", { hasApiKey: !!apiKey });
if (apiKey) {
return { success: true };
}
return {
success: true,
requiresManualAuth: true,
command: "codex auth login",
};
},
storeApiKey: async (provider: string, apiKey: string) => {
console.log("[Mock] Storing API key for:", provider);
// In mock mode, we just pretend to store it (it's already in the app store)
@@ -1058,17 +954,13 @@ function createMockSetupAPI(): SetupAPI {
return {
success: true,
hasAnthropicKey: false,
hasOpenAIKey: false,
hasGoogleKey: false,
};
},
configureCodexMcp: async (projectPath: string) => {
console.log("[Mock] Configuring Codex MCP for:", projectPath);
return {
success: true,
configPath: `${projectPath}/.codex/config.toml`,
};
deleteApiKey: async (provider: string) => {
console.log("[Mock] Deleting API key for:", provider);
return { success: true, message: `API key for ${provider} deleted` };
},
getPlatform: async () => {
@@ -1083,6 +975,16 @@ function createMockSetupAPI(): SetupAPI {
};
},
verifyClaudeAuth: async (authMethod?: "cli" | "api_key") => {
console.log("[Mock] Verifying Claude auth with method:", authMethod);
// Mock always returns not authenticated
return {
success: true,
authenticated: false,
error: "Mock environment - authentication not available",
};
},
onInstallProgress: (callback) => {
// Mock progress events
return () => {};
@@ -1967,7 +1869,9 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
create: async (
projectPath: string,
projectOverview: string,
generateFeatures = true
generateFeatures = true,
_analyzeProject?: boolean,
maxFeatures?: number
) => {
if (mockSpecRegenerationRunning) {
return { success: false, error: "Spec creation is already running" };
@@ -1975,7 +1879,7 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
mockSpecRegenerationRunning = true;
console.log(
`[Mock] Creating initial spec for: ${projectPath}, generateFeatures: ${generateFeatures}`
`[Mock] Creating initial spec for: ${projectPath}, generateFeatures: ${generateFeatures}, maxFeatures: ${maxFeatures}`
);
// Simulate async spec creation
@@ -1984,7 +1888,13 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
return { success: true };
},
generate: async (projectPath: string, projectDefinition: string) => {
generate: async (
projectPath: string,
projectDefinition: string,
generateFeatures = false,
_analyzeProject?: boolean,
maxFeatures?: number
) => {
if (mockSpecRegenerationRunning) {
return {
success: false,
@@ -1993,15 +1903,21 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
}
mockSpecRegenerationRunning = true;
console.log(`[Mock] Regenerating spec for: ${projectPath}`);
console.log(
`[Mock] Regenerating spec for: ${projectPath}, generateFeatures: ${generateFeatures}, maxFeatures: ${maxFeatures}`
);
// Simulate async spec regeneration
simulateSpecRegeneration(projectPath, projectDefinition);
simulateSpecRegeneration(
projectPath,
projectDefinition,
generateFeatures
);
return { success: true };
},
generateFeatures: async (projectPath: string) => {
generateFeatures: async (projectPath: string, maxFeatures?: number) => {
if (mockSpecRegenerationRunning) {
return {
success: false,
@@ -2011,7 +1927,7 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
mockSpecRegenerationRunning = true;
console.log(
`[Mock] Generating features from existing spec for: ${projectPath}`
`[Mock] Generating features from existing spec for: ${projectPath}, maxFeatures: ${maxFeatures}`
);
// Simulate async feature generation
@@ -2062,6 +1978,7 @@ async function simulateSpecCreation(
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: initialization] Starting project analysis...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -2074,6 +1991,7 @@ async function simulateSpecCreation(
type: "spec_regeneration_tool",
tool: "Glob",
input: { pattern: "**/*.{json,ts,tsx}" },
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -2085,6 +2003,7 @@ async function simulateSpecCreation(
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: analysis] Detecting tech stack...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -2128,6 +2047,7 @@ async function simulateSpecCreation(
emitSpecRegenerationEvent({
type: "spec_regeneration_complete",
message: "All tasks completed!",
projectPath: projectPath,
});
mockSpecRegenerationRunning = false;
@@ -2137,12 +2057,14 @@ async function simulateSpecCreation(
async function simulateSpecRegeneration(
projectPath: string,
projectDefinition: string
projectDefinition: string,
generateFeatures = false
) {
mockSpecRegenerationPhase = "initialization";
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: initialization] Starting spec regeneration...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -2154,6 +2076,7 @@ async function simulateSpecRegeneration(
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: analysis] Analyzing codebase...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -2184,10 +2107,30 @@ async function simulateSpecRegeneration(
</core_capabilities>
</project_specification>`;
if (generateFeatures) {
mockSpecRegenerationPhase = "spec_complete";
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content:
"[Phase: spec_complete] Spec regenerated! Generating features...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
mockSpecRegenerationTimeout = setTimeout(resolve, 500);
});
if (!mockSpecRegenerationRunning) return;
// Simulate feature generation
await simulateFeatureGeneration(projectPath);
if (!mockSpecRegenerationRunning) return;
}
mockSpecRegenerationPhase = "complete";
emitSpecRegenerationEvent({
type: "spec_regeneration_complete",
message: "All tasks completed!",
projectPath: projectPath,
});
mockSpecRegenerationRunning = false;
@@ -2201,6 +2144,7 @@ async function simulateFeatureGeneration(projectPath: string) {
type: "spec_regeneration_progress",
content:
"[Phase: initialization] Starting feature generation from existing app_spec.txt...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -2211,6 +2155,7 @@ async function simulateFeatureGeneration(projectPath: string) {
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: feature_generation] Reading implementation roadmap...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -2222,6 +2167,7 @@ async function simulateFeatureGeneration(projectPath: string) {
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: feature_generation] Creating features from roadmap...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -2233,11 +2179,13 @@ async function simulateFeatureGeneration(projectPath: string) {
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: complete] All tasks completed!\n",
projectPath: projectPath,
});
emitSpecRegenerationEvent({
type: "spec_regeneration_complete",
message: "All tasks completed!",
projectPath: projectPath,
});
mockSpecRegenerationRunning = false;

View File

@@ -371,25 +371,6 @@ export class HttpApiClient implements ElectronAPI {
return this.get("/api/setup/claude-status");
}
async checkCodexCli(): Promise<{
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;
}> {
return this.get("/api/setup/codex-status");
}
// Model API
model = {
getAvailable: async (): Promise<{
@@ -408,14 +389,6 @@ export class HttpApiClient implements ElectronAPI {
},
};
async testOpenAIConnection(apiKey?: string): Promise<{
success: boolean;
message?: string;
error?: string;
}> {
return this.post("/api/setup/test-openai", { apiKey });
}
// Setup API
setup = {
getClaudeStatus: (): Promise<{
@@ -440,35 +413,12 @@ export class HttpApiClient implements ElectronAPI {
error?: string;
}> => this.get("/api/setup/claude-status"),
getCodexStatus: (): Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
auth?: {
authenticated: boolean;
method: string;
hasAuthFile: boolean;
hasEnvKey: boolean;
hasStoredApiKey?: boolean;
hasEnvApiKey?: boolean;
};
error?: string;
}> => this.get("/api/setup/codex-status"),
installClaude: (): Promise<{
success: boolean;
message?: string;
error?: string;
}> => this.post("/api/setup/install-claude"),
installCodex: (): Promise<{
success: boolean;
message?: string;
error?: string;
}> => this.post("/api/setup/install-codex"),
authClaude: (): Promise<{
success: boolean;
token?: string;
@@ -480,15 +430,6 @@ export class HttpApiClient implements ElectronAPI {
output?: string;
}> => this.post("/api/setup/auth-claude"),
authCodex: (
apiKey?: string
): Promise<{
success: boolean;
requiresManualAuth?: boolean;
command?: string;
error?: string;
}> => this.post("/api/setup/auth-codex", { apiKey }),
storeApiKey: (
provider: string,
apiKey: string
@@ -497,21 +438,20 @@ export class HttpApiClient implements ElectronAPI {
error?: string;
}> => this.post("/api/setup/store-api-key", { provider, apiKey }),
deleteApiKey: (
provider: string
): Promise<{
success: boolean;
error?: string;
message?: string;
}> => this.post("/api/setup/delete-api-key", { provider }),
getApiKeys: (): Promise<{
success: boolean;
hasAnthropicKey: boolean;
hasOpenAIKey: boolean;
hasGoogleKey: boolean;
}> => this.get("/api/setup/api-keys"),
configureCodexMcp: (
projectPath: string
): Promise<{
success: boolean;
configPath?: string;
error?: string;
}> => this.post("/api/setup/configure-codex-mcp", { projectPath }),
getPlatform: (): Promise<{
success: boolean;
platform: string;
@@ -522,6 +462,12 @@ export class HttpApiClient implements ElectronAPI {
isLinux: boolean;
}> => this.get("/api/setup/platform"),
verifyClaudeAuth: (authMethod?: "cli" | "api_key"): Promise<{
success: boolean;
authenticated: boolean;
error?: string;
}> => this.post("/api/setup/verify-claude-auth", { authMethod }),
onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent("agent:stream", callback);
},
@@ -649,20 +595,33 @@ export class HttpApiClient implements ElectronAPI {
create: (
projectPath: string,
projectOverview: string,
generateFeatures?: boolean
generateFeatures?: boolean,
analyzeProject?: boolean,
maxFeatures?: number
) =>
this.post("/api/spec-regeneration/create", {
projectPath,
projectOverview,
generateFeatures,
analyzeProject,
maxFeatures,
}),
generate: (projectPath: string, projectDefinition: string) =>
generate: (
projectPath: string,
projectDefinition: string,
generateFeatures?: boolean,
analyzeProject?: boolean,
maxFeatures?: number
) =>
this.post("/api/spec-regeneration/generate", {
projectPath,
projectDefinition,
generateFeatures,
analyzeProject,
maxFeatures,
}),
generateFeatures: (projectPath: string) =>
this.post("/api/spec-regeneration/generate-features", { projectPath }),
generateFeatures: (projectPath: string, maxFeatures?: number) =>
this.post("/api/spec-regeneration/generate-features", { projectPath, maxFeatures }),
stop: () => this.post("/api/spec-regeneration/stop"),
status: () => this.get("/api/spec-regeneration/status"),
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {

View File

@@ -29,7 +29,9 @@ const REQUIRED_STRUCTURE: {
".automaker/features",
".automaker/images",
],
files: {},
files: {
".automaker/categories.json": "[]",
},
};
/**

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