Compare commits

...

62 Commits

Author SHA1 Message Date
webdevcody
f3f5d05349 chore: release v0.10.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:16:44 -05:00
Web Dev Cody
0c4b833b07 Merge pull request #405 from AutoMaker-Org/v0.10.0rc
V0.10.0rc
2026-01-12 16:11:56 -05:00
DhanushSantosh
fbab1d323f test: align app-spec and enhancement mode tests 2026-01-13 00:11:11 +05:30
Shirone
f50520c93f feat(delete): enhance branch deletion handling and validation
- Introduced a flag to track if a branch was successfully deleted, improving response clarity.
- Updated the response structure to include the new branchDeleted flag.
- Enhanced projectPath validation in init-script to ensure it is a non-empty string before processing.
2026-01-12 19:21:37 +01:00
Dhanush Santosh
cebf57ffd3 Merge pull request #426 from stefandevo/opencode-dynamic-providers
feat: add dynamic model discovery and routing for OpenCode provider
2026-01-12 23:51:06 +05:30
DhanushSantosh
6020219fda fix(opencode): address review feedback 2026-01-12 23:44:21 +05:30
DhanushSantosh
8094941385 feat(opencode): persist dynamic model selection 2026-01-12 23:44:21 +05:30
DhanushSantosh
9ce3cfee7d feat(opencode): drop bedrock defaults 2026-01-12 23:44:05 +05:30
DhanushSantosh
6184440441 fix(ui): tie dynamic models to connected providers 2026-01-12 23:42:38 +05:30
DhanushSantosh
0cff4cf510 feat(ui): add OpenRouter icon 2026-01-12 23:42:28 +05:30
DhanushSantosh
b152f119c5 fix(ui): refresh OpenCode models on new providers 2026-01-12 23:42:27 +05:30
DhanushSantosh
9f936c6968 fix(opencode): parse api-key provider models 2026-01-12 23:42:12 +05:30
Stefan de Vogelaere
b8531cf7e8 fix: add OpenCode settings to migration for persistence
Add enabledOpencodeModels and opencodeDefaultModel to the settings
migration to ensure they are properly persisted like Cursor settings.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:41:40 +05:30
Stefan de Vogelaere
edcc4e789b fix: address CodeRabbitAI review feedback
- Replace busy-wait loop in refreshModels with Promise-based approach
- Remove duplicate error logging in opencode-models.ts handlers
- Fix multi-slash parsing in provider-icon.tsx (only handle exactly one slash)
- Use dynamic icon resolution for selected OpenCode model in trigger
- Fix misleading comment about merge precedence (static takes precedence)
- Add enabledOpencodeModels and opencodeDefaultModel to settings sync
- Add clarifying comments about session-only dynamic model settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:41:26 +05:30
Stefan de Vogelaere
20cc401238 fix: update enhancement test to include ux-reviewer mode
Test expected 4 enhancement modes but there are now 5 after adding
the ux-reviewer mode.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:41:14 +05:30
Stefan de Vogelaere
70204a2d36 fix: address code review feedback from gemini-code-assist
- Convert execFileSync to async execFile in fetchModelsFromCli and
  fetchAuthenticatedProviders to avoid blocking the event loop
- Remove unused opencode-dynamic-providers.tsx component
- Use regex for more robust model ID validation in parseModelsOutput

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:40:57 +05:30
Stefan de Vogelaere
e38325c27f fix: improve dynamic model icons and fix React reference
- Add icon detection for dynamic OpenCode provider models (provider/model format)
- Support zai-coding-plan, github-copilot, google, xai, and other providers
- Detect model type from name (glm, claude, gpt, gemini, grok, etc.)
- Fix React.useMemo → useMemo to resolve "React is not defined" error

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:40:28 +05:30
Stefan de Vogelaere
5e4b422315 fix: improve OpenCode error handling and message extraction
- Update error event interface to handle nested error objects with
  name/data/message structure from OpenCode CLI
- Extract meaningful error messages from provider errors in normalizeEvent
- Add error type handling in executeWithProvider to throw errors with
  actual provider messages instead of returning empty response

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:40:13 +05:30
Stefan de Vogelaere
6c5206daf4 feat: add dynamic model discovery and routing for OpenCode provider
- Update isOpencodeModel() to detect dynamic models with provider/model format
  (e.g., github-copilot/gpt-4o, google/gemini-2.5-pro, zai-coding-plan/glm-4.7)
- Update resolveModelString() to recognize and pass through OpenCode models
- Update enhance route to route OpenCode models to OpenCode provider
- Fix OpenCode CLI command format: use --format json (not stream-json)
- Remove unsupported -q and - flags from CLI arguments
- Update normalizeEvent() to handle actual OpenCode JSON event format
- Add dynamic model configuration UI with provider grouping
- Cache providers and models in app store for snappier navigation
- Show authenticated providers in OpenCode CLI status card

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:39:38 +05:30
Shirone
ed65f70315 Merge pull request #409 from AutoMaker-Org/feat/worktrees-init-script
feat: worktrees init script
2026-01-12 17:52:31 +00:00
Shirone
f41a42010c fix: address pr comments 2026-01-12 18:41:56 +01:00
Kacper
a0669d4262 feat(board-view): enhance feature and plan dialogs with worktree branch settings
- Added WorktreeSettingsDialog and PlanSettingsDialog components to manage worktree branch settings.
- Integrated new settings into BoardHeader for toggling worktree branch usage in feature creation.
- Updated AddFeatureDialog to utilize selected worktree branch for custom mode.
- Introduced new state management in app-store for handling worktree branch preferences.

These changes improve user control over feature creation workflows by allowing branch selection based on the current worktree context.
2026-01-11 23:05:32 +01:00
Shirone
a4a792c6b1 Merge pull request #416 from AutoMaker-Org/feat/emtpy-columns-enhancments
feat: add empty state card component and integrate AI suggestion func…
2026-01-11 21:37:17 +00:00
Shirone
6842e4c7f7 refactor: simplify EmptyStateCard and update empty state configurations
- Removed unused properties and state management from the EmptyStateCard component for cleaner code.
- Updated the EMPTY_STATE_CONFIGS to remove exampleCard entries, streamlining the empty state configuration.
- Enhanced the primary action handling in the EmptyStateCard for improved functionality.
2026-01-11 22:35:25 +01:00
webdevcody
6638c35945 refactor(sidebar): enhance sidebar responsiveness and improve layout
- Updated sidebar component to include a mobile overlay backdrop when open.
- Adjusted visibility of logo and footer elements based on sidebar state.
- Improved layout and spacing for various components within the sidebar for better usability on different screen sizes.
- Refined styles for buttons and project selectors to enhance visual consistency and responsiveness.
2026-01-11 16:02:25 -05:00
Kacper
53f5c2b2bb feat(backlog): add branchName support to apply handler and UI components
- Updated apply handler to accept an optional branchName from the request body.
- Modified BoardView and BacklogPlanDialog components to pass currentBranch to the apply API.
- Enhanced ElectronAPI and HttpApiClient to include branchName in the apply method.

This change allows users to specify a branch when applying backlog plans, improving flexibility in feature management.
2026-01-11 20:52:07 +01:00
Kacper
6e13cdd516 Merge branch: resolve conflict in worktree-actions-dropdown.tsx 2026-01-11 20:08:19 +01:00
Kacper
a48c67d271 refactor: update EmptyStateCard component for improved layout and functionality
- Removed unused props and adjusted styles for a more compact and centered design.
- Enhanced the display of the icon, title, and description for better visibility.
- Updated keyboard shortcut hint and AI suggestion action for improved user interaction.
- Refined dismiss/minimize controls to appear on hover, enhancing the user experience.
2026-01-11 19:59:01 +01:00
Shirone
43fc3de2e1 Merge pull request #423 from stefandevo/main
feat: add default IDE setting and multi-editor support with icons
2026-01-11 18:36:12 +00:00
Kacper
80081b60bf fix(platform): remove logger import to avoid circular dependency
Replace createLogger with console.warn to prevent circular import
between @automaker/platform and @automaker/utils.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:34:29 +01:00
Kacper
cbca9b68e6 fix: correct Kiro CLI command typo (kido -> kiro)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:25:26 +01:00
Shirone
b9b3695497 feat(platform): add VS Code Insiders and Kiro editor support
Added support for two new editors:
- VS Code Insiders (code-insiders command)
- Kiro (kido command) - VS Code fork

Changes:
- Added editor definitions to SUPPORTED_EDITORS list
- Added VSCodeInsidersIcon (reuses VS Code icon)
- Added KiroIcon with custom SVG logo
- Updated getEditorIcon() to handle both new commands
- Fixed logger initialization to be lazy-loaded, preventing circular
  dependency error with isBrowser variable during module initialization

Both editors were tested and successfully open directories on macOS.
2026-01-11 19:14:44 +01:00
Shirone
1b9acb1395 fix(platform): verify full Xcode installation for xed command
The xed command requires full Xcode.app, not just Command Line Tools.
This fix adds validation to ensure Xcode is properly configured before
offering it as an editor option.

Changes:
- Added isXcodeFullyInstalled() to check xcode-select points to Xcode.app
- Added helpful warning when Xcode is installed but xcode-select points to CLT
- Users see clear instructions on how to fix the configuration

Fixes issue where xed would fail with "tool 'xed' requires Xcode" error
when only Command Line Tools are configured via xcode-select.
2026-01-11 19:04:39 +01:00
DhanushSantosh
01cf81a105 fix(platform): detect Antigravity CLI aliases 2026-01-11 23:22:13 +05:30
Kacper
6d267ce0fa feat(platform): add cross-platform editor utilities and refresh functionality
- Add libs/platform/src/editor.ts with cross-platform editor detection and launching
  - Handles Windows .cmd batch scripts (cursor.cmd, code.cmd, etc.)
  - Supports macOS app bundles in /Applications and ~/Applications
  - Includes caching with 5-minute TTL for performance
- Refactor open-in-editor.ts to use @automaker/platform utilities
- Add POST /api/worktree/refresh-editors endpoint to clear cache
- Add refresh button to Settings > Account for IDE selection
- Update useAvailableEditors hook with refresh() and isRefreshing

Fixes Windows issue where "Open in Editor" was falling back to Explorer
because execFile cannot run .cmd scripts without shell:true.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 18:08:09 +01:00
Kacper
8b0b565282 Merge remote-tracking branch 'origin/v0.10.0rc' into stefandevo/main 2026-01-11 17:34:19 +01:00
DhanushSantosh
a046d1232e Merge remote-tracking branch 'upstream/v0.10.0rc' into feature/codex-cli 2026-01-11 21:59:04 +05:30
DhanushSantosh
d724e782dd fix(ui): restore startup project context 2026-01-11 21:58:36 +05:30
Shirone
a266d85ecd Merge pull request #421 from AutoMaker-Org/refactor/extract-enhance-with-ai-shared-components
refactor: extract Enhance with AI into shared components
2026-01-11 16:22:18 +00:00
Kacper
a4a111fad0 feat: add pre-enhancement description tracking for feature updates
- Introduced a new parameter `preEnhancementDescription` to capture the original description before enhancements.
- Updated the `update` method in `FeatureLoader` to handle the new parameter and maintain a history of original descriptions.
- Enhanced UI components to support tracking and restoring pre-enhancement descriptions across various dialogs.
- Improved history management in `AddFeatureDialog`, `EditFeatureDialog`, and `FollowUpDialog` to include original text for better user experience.

This change enhances the ability to revert to previous descriptions, improving the overall functionality of the feature enhancement process.
2026-01-11 17:19:39 +01:00
Stefan de Vogelaere
2a98de85a8 fix: improve cache management and editor fallback handling
Cache management improvements:
- Remove separate cachedEditor variable; derive default from cachedEditors
- Update isCacheValid() to check cachedEditors existence
- detectDefaultEditor() now always goes through detectAllEditors()
  to ensure cache TTL is respected consistently

Editor fallback improvements:
- Log warning when requested editorCommand is not found in available editors
- Include list of available editor commands in warning message
- Make fallback to default editor explicit rather than silent

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 17:08:10 +01:00
Stefan de Vogelaere
fb3a8499f3 fix: address CodeRabbitAI security and UX review comments
Security improvements in open-in-editor.ts:
- Use execFile with argument arrays instead of shell interpolation
  in commandExists() to prevent command injection
- Replace shell `test -d` commands with Node.js fs/promises access()
  in findMacApp() for safer file system checks
- Add cache TTL (5 minutes) for editor detection to prevent stale data

UX improvements in worktree-actions-dropdown.tsx:
- Add error handling for clipboard copy operation
- Show success toast when path is copied
- Show error toast if clipboard write fails

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 16:55:25 +01:00
Stefan de Vogelaere
33dd9ae347 fix: address nitpick feedback from PR #423
## Security Fix (Command Injection)
- Use `execFile` with argument arrays instead of string interpolation
- Add `safeOpenInEditor` helper that properly handles `open -a` commands
- Validate that worktreePath is an absolute path before execution
- Prevents shell metacharacter injection attacks

## Shared Type Definition
- Move `EditorInfo` interface to `@automaker/types` package
- Server and UI now import from shared package to prevent drift
- Re-export from use-available-editors.ts for convenience

## Remove Unused Code
- Remove unused `defaultEditorName` prop from WorktreeActionsDropdown
- Remove prop from WorktreeTab component interface
- Remove useDefaultEditor hook usage from WorktreePanel
- Export new hooks from hooks/index.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 16:37:05 +01:00
Stefan de Vogelaere
ac87594b5d fix: address code review feedback from PR #423
Addresses feedback from gemini-code-assist and coderabbitai reviewers:

## Duplicate Code (High Priority)
- Extract `getEffectiveDefaultEditor` logic into shared `useEffectiveDefaultEditor` hook
- Both account-section.tsx and worktree-actions-dropdown.tsx now use the shared hook

## Performance (Medium Priority)
- Refactor `detectAllEditors` to use `Promise.all` for parallel editor detection
- Replace sequential `await tryAddEditor()` calls with parallel `findEditor()` checks

## Code Quality (Medium Priority)
- Remove verbose IIFE pattern for editor icon rendering
- Pre-compute icon components before JSX return statement

## Bug Fixes
- Use `os.homedir()` instead of `~` fallback which doesn't expand in shell
- Normalize Select value to 'auto' when saved editor command not found in editors
- Add defensive check for empty editors array in useEffectiveDefaultEditor
- Improve mock openInEditor to correctly map all editor commands to display names

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 16:28:31 +01:00
Stefan de Vogelaere
32656a9662 feat: add default IDE setting and multi-editor support with icons
Add comprehensive editor detection and selection system that allows users
to configure their preferred IDE for opening branches and worktrees.

## Server-side Changes

- Add `/api/worktree/available-editors` endpoint to detect installed editors
- Support detection via CLI commands (cursor, code, zed, subl, etc.)
- Support detection via macOS app bundles in /Applications and ~/Applications
- Detect editors: Cursor, VS Code, Zed, Sublime Text, Windsurf, Trae,
  Rider, WebStorm, Xcode, Android Studio, Antigravity, and file managers

## UI Changes

### Editor Icons
- Add new `editor-icons.tsx` with SVG icons for all supported editors
- Icons: Cursor, VS Code, Zed, Sublime Text, Windsurf, Trae, Rider,
  WebStorm, Xcode, Android Studio, Antigravity, Finder
- `getEditorIcon()` helper maps editor commands to appropriate icons

### Default IDE Setting
- Add "Default IDE" selector in Settings > Account section
- Options: Auto-detect (Cursor > VS Code > first available) or explicit choice
- Setting persists via `defaultEditorCommand` in global settings

### Worktree Dropdown Improvements
- Implement split-button UX for "Open In" action
- Click main area: opens directly in default IDE (single click)
- Click chevron: shows submenu with other editors + Copy Path
- Each editor shows with its branded icon

## Type & Store Changes

- Add `defaultEditorCommand: string | null` to GlobalSettings
- Add to app-store with `setDefaultEditorCommand` action
- Add to SETTINGS_FIELDS_TO_SYNC for persistence
- Add `useAvailableEditors` hook for fetching detected editors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 16:17:05 +01:00
DhanushSantosh
785a4d2c3b fix: restore auth and auto-open last project 2026-01-11 20:43:55 +05:30
Shirone
41a6c7f712 fix: address second round of PR review feedback
- Add fallback for unknown enhancement modes in history button to prevent "Enhanced (undefined)" UI bug
- Move DescriptionHistoryEntry interface to top level in add-feature-dialog
- Import and use EnhancementMode type in edit-feature-dialog to eliminate hardcoded types
- Make FollowUpHistoryEntry extend BaseHistoryEntry for consistency

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-11 15:27:17 +01:00
Shirone
7e5d915b60 fix: address PR review feedback from Gemini Code Assist
Address three issues identified in code review:

1. Fix missing thinkingLevel parameter (Critical)
   - Added thinkingLevel parameter to enhance API call
   - Updated electron.ts type definition to match http-api-client
   - Fixes functional regression in Claude model enhancement

2. Refactor dropdown menu to use constants dynamically
   - Changed hardcoded DropdownMenuItem components to dynamic generation
   - Now iterates over ENHANCEMENT_MODE_LABELS object
   - Ensures automatic sync when new modes are added
   - Eliminates manual UI updates for new enhancement modes

3. Optimize array reversal performance
   - Added useMemo hook to memoize reversed history array
   - Prevents creating new array on every render
   - Improves performance with lengthy histories

All TypeScript errors resolved. Build verified.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-11 15:17:46 +01:00
Shirone
8321c06e16 refactor: extract Enhance with AI into shared components
Extract all "Enhance with AI" functionality into reusable shared components
following DRY principles and clean code guidelines.

Changes:
- Create shared/enhancement/ folder for related functionality
- Extract EnhanceWithAI component (AI enhancement with model override)
- Extract EnhancementHistoryButton component (version history UI)
- Extract enhancement constants and types
- Refactor add-feature-dialog.tsx to use shared components
- Refactor edit-feature-dialog.tsx to use shared components
- Refactor follow-up-dialog.tsx to use shared components
- Add history tracking to add-feature-dialog for consistency

Benefits:
- Eliminated ~527 lines of duplicated code
- Single source of truth for enhancement logic
- Consistent UX across all dialogs
- Easier maintenance and extensibility
- Better code organization

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-11 15:10:54 +01:00
Shirone
f60c18d31a Update apps/ui/src/components/views/board-view/constants.ts
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-11 12:08:33 +01:00
Shirone
e171b6a049 feat: add empty state card component and integrate AI suggestion functionality
- Introduced the EmptyStateCard component to display contextual guidance when columns are empty.
- Enhanced the KanbanBoard and BoardView components to utilize the new EmptyStateCard for improved user experience.
- Added AI suggestion functionality to the empty state configuration, allowing users to generate ideas directly from the backlog column.
- Updated constants to define empty state configurations for various column types.
2026-01-11 12:03:52 +01:00
Shirone
4a59e901e6 chore: format 2026-01-11 01:15:27 +01:00
Shirone
8ed2fa07a0 security: Fix critical vulnerabilities in worktree init script feature
Fix multiple command injection and security vulnerabilities in the worktree
initialization script system:

**Critical Fixes:**
- Add branch name validation to prevent command injection in create/delete endpoints
- Replace string interpolation with array-based command execution using spawnProcess
- Implement safe environment variable allowlist to prevent credential exposure
- Add script content validation with 1MB size limit and dangerous pattern detection

**Code Quality:**
- Centralize execGitCommand helper in common.ts using @automaker/platform's spawnProcess
- Remove duplicate isGitRepo implementation, standardize imports to @automaker/git-utils
- Follow DRY principle by reusing existing platform utilities
- Add comprehensive JSDoc documentation with security examples

This addresses 6 critical/high severity vulnerabilities identified in security audit:
1. Command injection via unsanitized branch names (delete.ts)
2. Command injection via unsanitized branch names (create.ts)
3. Missing branch validation in init script execution
4. Environment variable exposure (ANTHROPIC_API_KEY and other secrets)
5. Path injection via command substitution
6. Arbitrary script execution without content limits

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-11 01:14:07 +01:00
Kacper
385e7f5c1e fix: address pr comments 2026-01-11 00:01:23 +01:00
Kacper
861fff1aae fix: broken lock file 2026-01-10 23:48:33 +01:00
Kacper
09527b3b67 feat: Add auto-dismiss functionality for Init Script Indicator
This commit introduces an auto-dismiss feature for the Init Script Indicator, enhancing user experience by automatically hiding the indicator 5 seconds after the script completes. Key changes include:

1. **State Management**: Added `autoDismissInitScriptIndicatorByProject` to manage the auto-dismiss setting per project.
2. **UI Components**: Updated the WorktreesSection to include a toggle for enabling or disabling the auto-dismiss feature, allowing users to customize their experience.
3. **Indicator Logic**: Implemented logic in the SingleIndicator component to handle auto-dismiss based on the new setting.

These enhancements provide users with more control over the visibility of the Init Script Indicator, streamlining project management workflows.
2026-01-10 23:43:52 +01:00
Kacper
d98ff16c8f feat: Enhance CreateWorktreeDialog with user-friendly error handling
This commit introduces a new error parsing function to provide clearer, user-friendly error messages in the CreateWorktreeDialog component. Key changes include:

1. **Error Parsing**: Added `parseWorktreeError` function to interpret various git-related error messages and return structured titles and descriptions for better user feedback.
2. **State Management**: Updated the error state to store structured error objects instead of strings, allowing for more detailed error display.
3. **UI Updates**: Enhanced the error display in the dialog to show both title and description, improving clarity for users encountering issues during worktree creation.

These improvements enhance the user experience by providing more informative error messages, helping users troubleshoot issues effectively.
2026-01-10 23:37:39 +01:00
Kacper
e902e8ea4c feat: Introduce default delete branch option for worktrees
This commit adds a new feature allowing users to set a default value for the "delete branch" checkbox when deleting a worktree. Key changes include:

1. **State Management**: Introduced `defaultDeleteBranchByProject` to manage the default delete branch setting per project.
2. **UI Components**: Updated the WorktreesSection to include a toggle for the default delete branch option, enhancing user control during worktree deletion.
3. **Dialog Updates**: Modified the DeleteWorktreeDialog to respect the default delete branch setting, improving the user experience by streamlining the deletion process.

These enhancements provide users with more flexibility and control over worktree management, improving overall project workflows.
2026-01-10 23:18:39 +01:00
Kacper
aeb5bd829f feat: Add Init Script Indicator visibility feature for worktrees
This commit introduces a new feature allowing users to toggle the visibility of the Init Script Indicator for each project. Key changes include:

1. **State Management**: Added `showInitScriptIndicatorByProject` to manage the visibility state per project.
2. **UI Components**: Implemented a checkbox in the WorktreesSection to enable or disable the Init Script Indicator, enhancing user control over the UI.
3. **BoardView Updates**: Modified the BoardView to conditionally render the Init Script Indicator based on the new visibility state.

These enhancements improve the user experience by providing customizable visibility options for the Init Script Indicator, streamlining project management workflows.
2026-01-10 23:03:29 +01:00
Kacper
c24e6207d0 feat: Enhance ShellSyntaxEditor and WorktreesSection with new features
This commit introduces several improvements to the ShellSyntaxEditor and WorktreesSection components:

1. **ShellSyntaxEditor**: Added a `maxHeight` prop to allow for customizable maximum height, enhancing layout flexibility.
2. **WorktreesSection**:
   - Introduced state management for original script content and existence checks for scripts.
   - Implemented save, reset, and delete functionalities for initialization scripts, providing users with better control over their scripts.
   - Added action buttons for saving, resetting, and deleting scripts, along with loading indicators for improved user feedback.
   - Enhanced UI to indicate unsaved changes, improving user awareness of script modifications.

These changes improve the user experience by providing more robust script management capabilities and a more responsive UI.
2026-01-10 22:46:06 +01:00
Kacper
6c412cd367 feat: Add run init script functionality for worktrees
This commit introduces the ability to run initialization scripts for worktrees, enhancing the setup process. Key changes include:

1. **New API Endpoint**: Added a POST endpoint to run the init script for a specified worktree.
2. **Worktree Routes**: Updated worktree routes to include the new run init script handler.
3. **Init Script Service**: Enhanced the Init Script Service to support running scripts asynchronously and handling errors.
4. **UI Updates**: Added UI components to check for the existence of init scripts and trigger their execution, providing user feedback through toast notifications.
5. **Event Handling**: Implemented event handling for init script execution status, allowing real-time updates in the UI.

This feature streamlines the workflow for users by automating the execution of setup scripts, improving overall project management.
2026-01-10 22:36:50 +01:00
Kacper
05d96a7d6e feat: Implement worktree initialization script functionality
This commit introduces a new feature for managing worktree initialization scripts, allowing users to configure and execute scripts upon worktree creation. Key changes include:

1. **New API Endpoints**: Added endpoints for getting, setting, and deleting init scripts.
2. **Worktree Routes**: Updated worktree routes to include init script handling.
3. **Init Script Service**: Created a service to execute the init scripts asynchronously, with support for cross-platform compatibility.
4. **UI Components**: Added UI components for displaying and editing init scripts, including a dedicated section in the settings view.
5. **Event Handling**: Implemented event handling for init script execution status, providing real-time feedback in the UI.

This enhancement improves the user experience by allowing automated setup processes for new worktrees, streamlining project workflows.
2026-01-10 22:19:34 +01:00
107 changed files with 7684 additions and 1392 deletions

1
.gitignore vendored
View File

@@ -95,3 +95,4 @@ check-sync.sh
# API key files
data/.api-key
data/credentials.json
data/

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/server",
"version": "0.9.0",
"version": "0.10.0",
"description": "Backend server for Automaker - provides API for both web and Electron modes",
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",

View File

@@ -217,7 +217,7 @@ app.use('/api/sessions', createSessionsRoutes(agentService));
app.use('/api/features', createFeaturesRoutes(featureLoader));
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes());
app.use('/api/worktree', createWorktreeRoutes(events));
app.use('/api/git', createGitRoutes());
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
app.use('/api/models', createModelsRoutes());

View File

@@ -21,6 +21,12 @@ export interface WorktreeMetadata {
branch: string;
createdAt: string;
pr?: WorktreePRInfo;
/** Whether the init script has been executed for this worktree */
initScriptRan?: boolean;
/** Status of the init script execution */
initScriptStatus?: 'running' | 'success' | 'failed';
/** Error message if init script failed */
initScriptError?: string;
}
/**

File diff suppressed because it is too large Load Diff

View File

@@ -12,11 +12,22 @@ const featureLoader = new FeatureLoader();
export function createApplyHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, plan } = req.body as {
const {
projectPath,
plan,
branchName: rawBranchName,
} = req.body as {
projectPath: string;
plan: BacklogPlanResult;
branchName?: string;
};
// Validate branchName: must be undefined or a non-empty trimmed string
const branchName =
typeof rawBranchName === 'string' && rawBranchName.trim().length > 0
? rawBranchName.trim()
: undefined;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
@@ -82,6 +93,7 @@ export function createApplyHandler() {
dependencies: change.feature.dependencies,
priority: change.feature.priority,
status: 'backlog',
branchName,
});
appliedChanges.push(`added:${newFeature.id}`);

View File

@@ -12,6 +12,7 @@ import { resolveModelString } from '@automaker/model-resolver';
import {
CLAUDE_MODEL_MAP,
isCursorModel,
isOpencodeModel,
stripProviderPrefix,
ThinkingLevel,
getThinkingTokenBudget,
@@ -91,13 +92,13 @@ async function extractTextFromStream(
}
/**
* Execute enhancement using Cursor provider
* Execute enhancement using a provider (Cursor, OpenCode, etc.)
*
* @param prompt - The enhancement prompt
* @param model - The Cursor model to use
* @param model - The model to use
* @returns The enhanced text
*/
async function executeWithCursor(prompt: string, model: string): Promise<string> {
async function executeWithProvider(prompt: string, model: string): Promise<string> {
const provider = ProviderFactory.getProviderForModel(model);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(model);
@@ -110,7 +111,11 @@ async function executeWithCursor(prompt: string, model: string): Promise<string>
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
readOnly: true, // Prompt enhancement only generates text, doesn't write files
})) {
if (msg.type === 'assistant' && msg.message?.content) {
if (msg.type === 'error') {
// Throw error with the message from the provider
const errorMessage = msg.error || 'Provider returned an error';
throw new Error(errorMessage);
} else if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
@@ -212,7 +217,14 @@ export function createEnhanceHandler(
// Cursor doesn't have a separate system prompt concept, so combine them
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
enhancedText = await executeWithCursor(combinedPrompt, resolvedModel);
enhancedText = await executeWithProvider(combinedPrompt, resolvedModel);
} else if (isOpencodeModel(resolvedModel)) {
// Use OpenCode provider for OpenCode models (static and dynamic)
logger.info(`Using OpenCode provider for model: ${resolvedModel}`);
// OpenCode CLI handles the system prompt, so combine them
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
enhancedText = await executeWithProvider(combinedPrompt, resolvedModel);
} else {
// Use Claude SDK for Claude models
logger.info(`Using Claude provider for model: ${resolvedModel}`);

View File

@@ -10,14 +10,21 @@ import { getErrorMessage, logError } from '../common.js';
export function createUpdateHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, updates, descriptionHistorySource, enhancementMode } =
req.body as {
projectPath: string;
featureId: string;
updates: Partial<Feature>;
descriptionHistorySource?: 'enhance' | 'edit';
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
};
const {
projectPath,
featureId,
updates,
descriptionHistorySource,
enhancementMode,
preEnhancementDescription,
} = req.body as {
projectPath: string;
featureId: string;
updates: Partial<Feature>;
descriptionHistorySource?: 'enhance' | 'edit';
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
preEnhancementDescription?: string;
};
if (!projectPath || !featureId || !updates) {
res.status(400).json({
@@ -32,7 +39,8 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
featureId,
updates,
descriptionHistorySource,
enhancementMode
enhancementMode,
preEnhancementDescription
);
res.json({ success: true, feature: updated });
} catch (error) {

View File

@@ -24,6 +24,12 @@ import { createDeauthCursorHandler } from './routes/deauth-cursor.js';
import { createAuthOpencodeHandler } from './routes/auth-opencode.js';
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
import {
createGetOpencodeModelsHandler,
createRefreshOpencodeModelsHandler,
createGetOpencodeProvidersHandler,
createClearOpencodeCacheHandler,
} from './routes/opencode-models.js';
import {
createGetCursorConfigHandler,
createSetCursorDefaultModelHandler,
@@ -65,6 +71,12 @@ export function createSetupRoutes(): Router {
router.get('/opencode-status', createOpencodeStatusHandler());
router.post('/auth-opencode', createAuthOpencodeHandler());
router.post('/deauth-opencode', createDeauthOpencodeHandler());
// OpenCode Dynamic Model Discovery routes
router.get('/opencode/models', createGetOpencodeModelsHandler());
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());
router.get('/opencode/providers', createGetOpencodeProvidersHandler());
router.post('/opencode/cache/clear', createClearOpencodeCacheHandler());
router.get('/cursor-config', createGetCursorConfigHandler());
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
router.post('/cursor-config/models', createSetCursorModelsHandler());

View File

@@ -0,0 +1,189 @@
/**
* OpenCode Dynamic Models API Routes
*
* Provides endpoints for:
* - GET /api/setup/opencode/models - Get available models (cached or refreshed)
* - POST /api/setup/opencode/models/refresh - Force refresh models from CLI
* - GET /api/setup/opencode/providers - Get authenticated providers
*/
import type { Request, Response } from 'express';
import {
OpencodeProvider,
type OpenCodeProviderInfo,
} from '../../../providers/opencode-provider.js';
import { getErrorMessage, logError } from '../common.js';
import type { ModelDefinition } from '@automaker/types';
import { createLogger } from '@automaker/utils';
const logger = createLogger('OpenCodeModelsRoute');
// Singleton provider instance for caching
let providerInstance: OpencodeProvider | null = null;
function getProvider(): OpencodeProvider {
if (!providerInstance) {
providerInstance = new OpencodeProvider();
}
return providerInstance;
}
/**
* Response type for models endpoint
*/
interface ModelsResponse {
success: boolean;
models?: ModelDefinition[];
count?: number;
cached?: boolean;
error?: string;
}
/**
* Response type for providers endpoint
*/
interface ProvidersResponse {
success: boolean;
providers?: OpenCodeProviderInfo[];
authenticated?: OpenCodeProviderInfo[];
error?: string;
}
/**
* Creates handler for GET /api/setup/opencode/models
*
* Returns currently available models (from cache if available).
* Query params:
* - refresh=true: Force refresh from CLI before returning
*
* Note: If cache is empty, this will trigger a refresh to get dynamic models.
*/
export function createGetOpencodeModelsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const forceRefresh = req.query.refresh === 'true';
let models: ModelDefinition[];
let cached = true;
if (forceRefresh) {
models = await provider.refreshModels();
cached = false;
} else {
// Check if we have cached models
const cachedModels = provider.getAvailableModels();
// If cache only has default models (provider.hasCachedModels() would be false),
// trigger a refresh to get dynamic models
if (!provider.hasCachedModels()) {
models = await provider.refreshModels();
cached = false;
} else {
models = cachedModels;
}
}
const response: ModelsResponse = {
success: true,
models,
count: models.length,
cached,
};
res.json(response);
} catch (error) {
logError(error, 'Get OpenCode models failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ModelsResponse);
}
};
}
/**
* Creates handler for POST /api/setup/opencode/models/refresh
*
* Forces a refresh of models from the OpenCode CLI.
*/
export function createRefreshOpencodeModelsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const models = await provider.refreshModels();
const response: ModelsResponse = {
success: true,
models,
count: models.length,
cached: false,
};
res.json(response);
} catch (error) {
logError(error, 'Refresh OpenCode models failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ModelsResponse);
}
};
}
/**
* Creates handler for GET /api/setup/opencode/providers
*
* Returns authenticated providers from OpenCode CLI.
* This calls `opencode auth list` to get provider status.
*/
export function createGetOpencodeProvidersHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const providers = await provider.fetchAuthenticatedProviders();
// Filter to only authenticated providers
const authenticated = providers.filter((p) => p.authenticated);
const response: ProvidersResponse = {
success: true,
providers,
authenticated,
};
res.json(response);
} catch (error) {
logError(error, 'Get OpenCode providers failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ProvidersResponse);
}
};
}
/**
* Creates handler for POST /api/setup/opencode/cache/clear
*
* Clears the model cache, forcing a fresh fetch on next access.
*/
export function createClearOpencodeCacheHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
provider.clearModelCache();
res.json({
success: true,
message: 'OpenCode model cache cleared',
});
} catch (error) {
logError(error, 'Clear OpenCode cache failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -3,15 +3,51 @@
*/
import { createLogger } from '@automaker/utils';
import { spawnProcess } from '@automaker/platform';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
import { FeatureLoader } from '../../services/feature-loader.js';
const logger = createLogger('Worktree');
export const execAsync = promisify(exec);
const featureLoader = new FeatureLoader();
// ============================================================================
// Secure Command Execution
// ============================================================================
/**
* Execute git command with array arguments to prevent command injection.
* Uses spawnProcess from @automaker/platform for secure, cross-platform execution.
*
* @param args - Array of git command arguments (e.g., ['worktree', 'add', path])
* @param cwd - Working directory to execute the command in
* @returns Promise resolving to stdout output
* @throws Error with stderr message if command fails
*
* @example
* ```typescript
* // Safe: no injection possible
* await execGitCommand(['branch', '-D', branchName], projectPath);
*
* // Instead of unsafe:
* // await execAsync(`git branch -D ${branchName}`, { cwd });
* ```
*/
export async function execGitCommand(args: string[], cwd: string): Promise<string> {
const result = await spawnProcess({
command: 'git',
args,
cwd,
});
// spawnProcess returns { stdout, stderr, exitCode }
if (result.exitCode === 0) {
return result.stdout;
} else {
const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`;
throw new Error(errorMessage);
}
}
// ============================================================================
// Constants
@@ -99,18 +135,6 @@ export function normalizePath(p: string): string {
return p.replace(/\\/g, '/');
}
/**
* Check if a path is a git repo
*/
export async function isGitRepo(repoPath: string): Promise<boolean> {
try {
await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath });
return true;
} catch {
return false;
}
}
/**
* Check if a git repository has at least one commit (i.e., HEAD exists)
* Returns false for freshly initialized repos with no commits

View File

@@ -3,6 +3,7 @@
*/
import { Router } from 'express';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js';
import { createInfoHandler } from './routes/info.js';
@@ -24,14 +25,22 @@ import { createSwitchBranchHandler } from './routes/switch-branch.js';
import {
createOpenInEditorHandler,
createGetDefaultEditorHandler,
createGetAvailableEditorsHandler,
createRefreshEditorsHandler,
} from './routes/open-in-editor.js';
import { createInitGitHandler } from './routes/init-git.js';
import { createMigrateHandler } from './routes/migrate.js';
import { createStartDevHandler } from './routes/start-dev.js';
import { createStopDevHandler } from './routes/stop-dev.js';
import { createListDevServersHandler } from './routes/list-dev-servers.js';
import {
createGetInitScriptHandler,
createPutInitScriptHandler,
createDeleteInitScriptHandler,
createRunInitScriptHandler,
} from './routes/init-script.js';
export function createWorktreeRoutes(): Router {
export function createWorktreeRoutes(events: EventEmitter): Router {
const router = Router();
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
@@ -45,7 +54,7 @@ export function createWorktreeRoutes(): Router {
requireValidProject,
createMergeHandler()
);
router.post('/create', validatePathParams('projectPath'), createCreateHandler());
router.post('/create', validatePathParams('projectPath'), createCreateHandler(events));
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
router.post('/create-pr', createCreatePRHandler());
router.post('/pr-info', createPRInfoHandler());
@@ -77,6 +86,8 @@ export function createWorktreeRoutes(): Router {
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
router.get('/default-editor', createGetDefaultEditorHandler());
router.get('/available-editors', createGetAvailableEditorsHandler());
router.post('/refresh-editors', createRefreshEditorsHandler());
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
router.post('/migrate', createMigrateHandler());
router.post(
@@ -87,5 +98,15 @@ export function createWorktreeRoutes(): Router {
router.post('/stop-dev', createStopDevHandler());
router.post('/list-dev-servers', createListDevServersHandler());
// Init script routes
router.get('/init-script', createGetInitScriptHandler());
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
router.delete('/init-script', validatePathParams('projectPath'), createDeleteInitScriptHandler());
router.post(
'/run-init-script',
validatePathParams('projectPath', 'worktreePath'),
createRunInitScriptHandler(events)
);
return router;
}

View File

@@ -3,7 +3,8 @@
*/
import type { Request, Response, NextFunction } from 'express';
import { isGitRepo, hasCommits } from './common.js';
import { isGitRepo } from '@automaker/git-utils';
import { hasCommits } from './common.js';
interface ValidationOptions {
/** Check if the path is a git repository (default: true) */

View File

@@ -12,15 +12,19 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import type { EventEmitter } from '../../../lib/events.js';
import { isGitRepo } from '@automaker/git-utils';
import {
isGitRepo,
getErrorMessage,
logError,
normalizePath,
ensureInitialCommit,
isValidBranchName,
execGitCommand,
} from '../common.js';
import { trackBranch } from './branch-tracking.js';
import { createLogger } from '@automaker/utils';
import { runInitScript } from '../../../services/init-script-service.js';
const logger = createLogger('Worktree');
@@ -77,7 +81,7 @@ async function findExistingWorktreeForBranch(
}
}
export function createCreateHandler() {
export function createCreateHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName, baseBranch } = req.body as {
@@ -94,6 +98,26 @@ export function createCreateHandler() {
return;
}
// Validate branch name to prevent command injection
if (!isValidBranchName(branchName)) {
res.status(400).json({
success: false,
error:
'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
});
return;
}
// Validate base branch if provided
if (baseBranch && !isValidBranchName(baseBranch) && baseBranch !== 'HEAD') {
res.status(400).json({
success: false,
error:
'Invalid base branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
});
return;
}
if (!(await isGitRepo(projectPath))) {
res.status(400).json({
success: false,
@@ -143,30 +167,28 @@ export function createCreateHandler() {
// Create worktrees directory if it doesn't exist
await secureFs.mkdir(worktreesDir, { recursive: true });
// Check if branch exists
// Check if branch exists (using array arguments to prevent injection)
let branchExists = false;
try {
await execAsync(`git rev-parse --verify ${branchName}`, {
cwd: projectPath,
});
await execGitCommand(['rev-parse', '--verify', branchName], projectPath);
branchExists = true;
} catch {
// Branch doesn't exist
}
// Create worktree
let createCmd: string;
// Create worktree (using array arguments to prevent injection)
if (branchExists) {
// Use existing branch
createCmd = `git worktree add "${worktreePath}" ${branchName}`;
await execGitCommand(['worktree', 'add', worktreePath, branchName], projectPath);
} else {
// Create new branch from base or HEAD
const base = baseBranch || 'HEAD';
createCmd = `git worktree add -b ${branchName} "${worktreePath}" ${base}`;
await execGitCommand(
['worktree', 'add', '-b', branchName, worktreePath, base],
projectPath
);
}
await execAsync(createCmd, { cwd: projectPath });
// Note: We intentionally do NOT symlink .automaker to worktrees
// Features and config are always accessed from the main project path
// This avoids symlink loop issues when activating worktrees
@@ -177,6 +199,8 @@ export function createCreateHandler() {
// Resolve to absolute path for cross-platform compatibility
// normalizePath converts to forward slashes for API consistency
const absoluteWorktreePath = path.resolve(worktreePath);
// Respond immediately (non-blocking)
res.json({
success: true,
worktree: {
@@ -185,6 +209,17 @@ export function createCreateHandler() {
isNew: !branchExists,
},
});
// Trigger init script asynchronously after response
// runInitScript internally checks if script exists and hasn't already run
runInitScript({
projectPath,
worktreePath: absoluteWorktreePath,
branch: branchName,
emitter: events,
}).catch((err) => {
logger.error(`Init script failed for ${branchName}:`, err);
});
} catch (error) {
logError(error, 'Create worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -6,9 +6,11 @@ import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError } from '../common.js';
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
import { createLogger } from '@automaker/utils';
const execAsync = promisify(exec);
const logger = createLogger('Worktree');
export function createDeleteHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -46,22 +48,28 @@ export function createDeleteHandler() {
// Could not get branch name
}
// Remove the worktree
// Remove the worktree (using array arguments to prevent injection)
try {
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: projectPath,
});
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
} catch (error) {
// Try with prune if remove fails
await execAsync('git worktree prune', { cwd: projectPath });
await execGitCommand(['worktree', 'prune'], projectPath);
}
// Optionally delete the branch
let branchDeleted = false;
if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') {
try {
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
} catch {
// Branch deletion failed, not critical
// Validate branch name to prevent command injection
if (!isValidBranchName(branchName)) {
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
} else {
try {
await execGitCommand(['branch', '-D', branchName], projectPath);
branchDeleted = true;
} catch {
// Branch deletion failed, not critical
logger.warn(`Failed to delete branch: ${branchName}`);
}
}
}
@@ -69,7 +77,8 @@ export function createDeleteHandler() {
success: true,
deleted: {
worktreePath,
branch: deleteBranch ? branchName : null,
branch: branchDeleted ? branchName : null,
branchDeleted,
},
});
} catch (error) {

View File

@@ -0,0 +1,280 @@
/**
* Init Script routes - Read/write/run the worktree-init.sh file
*
* POST /init-script - Read the init script content
* PUT /init-script - Write content to the init script file
* DELETE /init-script - Delete the init script file
* POST /run-init-script - Run the init script for a worktree
*/
import type { Request, Response } from 'express';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../../../lib/events.js';
import { forceRunInitScript } from '../../../services/init-script-service.js';
const logger = createLogger('InitScript');
/** Fixed path for init script within .automaker directory */
const INIT_SCRIPT_FILENAME = 'worktree-init.sh';
/** Maximum allowed size for init scripts (1MB) */
const MAX_SCRIPT_SIZE_BYTES = 1024 * 1024;
/**
* Get the full path to the init script for a project
*/
function getInitScriptPath(projectPath: string): string {
return path.join(projectPath, '.automaker', INIT_SCRIPT_FILENAME);
}
/**
* GET /init-script - Read the init script content
*/
export function createGetInitScriptHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const rawProjectPath = req.query.projectPath;
// Validate projectPath is a non-empty string (not an array or undefined)
if (!rawProjectPath || typeof rawProjectPath !== 'string') {
res.status(400).json({
success: false,
error: 'projectPath query parameter is required',
});
return;
}
const projectPath = rawProjectPath.trim();
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath cannot be empty',
});
return;
}
const scriptPath = getInitScriptPath(projectPath);
try {
const content = await secureFs.readFile(scriptPath, 'utf-8');
res.json({
success: true,
exists: true,
content: content as string,
path: scriptPath,
});
} catch {
// File doesn't exist
res.json({
success: true,
exists: false,
content: '',
path: scriptPath,
});
}
} catch (error) {
logError(error, 'Read init script failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}
/**
* PUT /init-script - Write content to the init script file
*/
export function createPutInitScriptHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, content } = req.body as {
projectPath: string;
content: string;
};
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
if (typeof content !== 'string') {
res.status(400).json({
success: false,
error: 'content must be a string',
});
return;
}
// Validate script size to prevent disk exhaustion
const sizeBytes = Buffer.byteLength(content, 'utf-8');
if (sizeBytes > MAX_SCRIPT_SIZE_BYTES) {
res.status(400).json({
success: false,
error: `Script size (${Math.round(sizeBytes / 1024)}KB) exceeds maximum allowed size (${Math.round(MAX_SCRIPT_SIZE_BYTES / 1024)}KB)`,
});
return;
}
// Log warning if potentially dangerous patterns are detected (non-blocking)
const dangerousPatterns = [
/rm\s+-rf\s+\/(?!\s*\$)/i, // rm -rf / (not followed by variable)
/curl\s+.*\|\s*(?:bash|sh)/i, // curl | bash
/wget\s+.*\|\s*(?:bash|sh)/i, // wget | sh
];
for (const pattern of dangerousPatterns) {
if (pattern.test(content)) {
logger.warn(
`Init script contains potentially dangerous pattern: ${pattern.source}. User responsibility to verify script safety.`
);
}
}
const scriptPath = getInitScriptPath(projectPath);
const automakerDir = path.dirname(scriptPath);
// Ensure .automaker directory exists
await secureFs.mkdir(automakerDir, { recursive: true });
// Write the script content
await secureFs.writeFile(scriptPath, content, 'utf-8');
logger.info(`Wrote init script to ${scriptPath}`);
res.json({
success: true,
path: scriptPath,
});
} catch (error) {
logError(error, 'Write init script failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}
/**
* DELETE /init-script - Delete the init script file
*/
export function createDeleteInitScriptHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
const scriptPath = getInitScriptPath(projectPath);
await secureFs.rm(scriptPath, { force: true });
logger.info(`Deleted init script at ${scriptPath}`);
res.json({
success: true,
});
} catch (error) {
logError(error, 'Delete init script failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}
/**
* POST /run-init-script - Run (or re-run) the init script for a worktree
*/
export function createRunInitScriptHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, worktreePath, branch } = req.body as {
projectPath: string;
worktreePath: string;
branch: string;
};
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath is required',
});
return;
}
if (!branch) {
res.status(400).json({
success: false,
error: 'branch is required',
});
return;
}
// Validate branch name to prevent injection via environment variables
if (!isValidBranchName(branch)) {
res.status(400).json({
success: false,
error:
'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
});
return;
}
const scriptPath = getInitScriptPath(projectPath);
// Check if script exists
try {
await secureFs.access(scriptPath);
} catch {
res.status(404).json({
success: false,
error: 'No init script found. Create one in Settings > Worktrees.',
});
return;
}
logger.info(`Running init script for branch "${branch}" (forced)`);
// Run the script asynchronously (non-blocking)
forceRunInitScript({
projectPath,
worktreePath,
branch,
emitter: events,
});
// Return immediately - progress will be streamed via WebSocket events
res.json({
success: true,
message: 'Init script started',
});
} catch (error) {
logError(error, 'Run init script failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,78 +1,40 @@
/**
* POST /open-in-editor endpoint - Open a worktree directory in the default code editor
* GET /default-editor endpoint - Get the name of the default code editor
* POST /refresh-editors endpoint - Clear editor cache and re-detect available editors
*
* This module uses @automaker/platform for cross-platform editor detection and launching.
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { isAbsolute } from 'path';
import {
clearEditorCache,
detectAllEditors,
detectDefaultEditor,
openInEditor,
openInFileManager,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
const logger = createLogger('open-in-editor');
// Editor detection with caching
interface EditorInfo {
name: string;
command: string;
}
let cachedEditor: EditorInfo | null = null;
/**
* Detect which code editor is available on the system
*/
async function detectDefaultEditor(): Promise<EditorInfo> {
// Return cached result if available
if (cachedEditor) {
return cachedEditor;
}
// Try Cursor first (if user has Cursor, they probably prefer it)
try {
await execAsync('which cursor || where cursor');
cachedEditor = { name: 'Cursor', command: 'cursor' };
return cachedEditor;
} catch {
// Cursor not found
}
// Try VS Code
try {
await execAsync('which code || where code');
cachedEditor = { name: 'VS Code', command: 'code' };
return cachedEditor;
} catch {
// VS Code not found
}
// Try Zed
try {
await execAsync('which zed || where zed');
cachedEditor = { name: 'Zed', command: 'zed' };
return cachedEditor;
} catch {
// Zed not found
}
// Try Sublime Text
try {
await execAsync('which subl || where subl');
cachedEditor = { name: 'Sublime Text', command: 'subl' };
return cachedEditor;
} catch {
// Sublime not found
}
// Fallback to file manager
const platform = process.platform;
if (platform === 'darwin') {
cachedEditor = { name: 'Finder', command: 'open' };
} else if (platform === 'win32') {
cachedEditor = { name: 'Explorer', command: 'explorer' };
} else {
cachedEditor = { name: 'File Manager', command: 'xdg-open' };
}
return cachedEditor;
export function createGetAvailableEditorsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const editors = await detectAllEditors();
res.json({
success: true,
result: {
editors,
},
});
} catch (error) {
logError(error, 'Get available editors failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
export function createGetDefaultEditorHandler() {
@@ -93,11 +55,41 @@ export function createGetDefaultEditorHandler() {
};
}
/**
* Handler to refresh the editor cache and re-detect available editors
* Useful when the user has installed/uninstalled editors
*/
export function createRefreshEditorsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Clear the cache
clearEditorCache();
// Re-detect editors (this will repopulate the cache)
const editors = await detectAllEditors();
logger.info(`Editor cache refreshed, found ${editors.length} editors`);
res.json({
success: true,
result: {
editors,
message: `Found ${editors.length} available editors`,
},
});
} catch (error) {
logError(error, 'Refresh editors failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
export function createOpenInEditorHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
const { worktreePath, editorCommand } = req.body as {
worktreePath: string;
editorCommand?: string;
};
if (!worktreePath) {
@@ -108,42 +100,44 @@ export function createOpenInEditorHandler() {
return;
}
const editor = await detectDefaultEditor();
// Security: Validate that worktreePath is an absolute path
if (!isAbsolute(worktreePath)) {
res.status(400).json({
success: false,
error: 'worktreePath must be an absolute path',
});
return;
}
try {
await execAsync(`${editor.command} "${worktreePath}"`);
// Use the platform utility to open in editor
const result = await openInEditor(worktreePath, editorCommand);
res.json({
success: true,
result: {
message: `Opened ${worktreePath} in ${editor.name}`,
editorName: editor.name,
message: `Opened ${worktreePath} in ${result.editorName}`,
editorName: result.editorName,
},
});
} catch (editorError) {
// If the detected editor fails, try opening in default file manager as fallback
const platform = process.platform;
let openCommand: string;
let fallbackName: string;
// If the specified editor fails, try opening in default file manager as fallback
logger.warn(
`Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}`
);
if (platform === 'darwin') {
openCommand = `open "${worktreePath}"`;
fallbackName = 'Finder';
} else if (platform === 'win32') {
openCommand = `explorer "${worktreePath}"`;
fallbackName = 'Explorer';
} else {
openCommand = `xdg-open "${worktreePath}"`;
fallbackName = 'File Manager';
try {
const result = await openInFileManager(worktreePath);
res.json({
success: true,
result: {
message: `Opened ${worktreePath} in ${result.editorName}`,
editorName: result.editorName,
},
});
} catch (fallbackError) {
// Both editor and file manager failed
throw fallbackError;
}
await execAsync(openCommand);
res.json({
success: true,
result: {
message: `Opened ${worktreePath} in ${fallbackName}`,
editorName: fallbackName,
},
});
}
} catch (error) {
logError(error, 'Open in editor failed');

View File

@@ -308,13 +308,15 @@ export class FeatureLoader {
* @param updates - Partial feature updates
* @param descriptionHistorySource - Source of description change ('enhance' or 'edit')
* @param enhancementMode - Enhancement mode if source is 'enhance'
* @param preEnhancementDescription - Description before enhancement (for restoring original)
*/
async update(
projectPath: string,
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
): Promise<Feature> {
const feature = await this.get(projectPath, featureId);
if (!feature) {
@@ -338,9 +340,31 @@ export class FeatureLoader {
updates.description !== feature.description &&
updates.description.trim()
) {
const timestamp = new Date().toISOString();
// If this is an enhancement and we have the pre-enhancement description,
// add the original text to history first (so user can restore to it)
if (
descriptionHistorySource === 'enhance' &&
preEnhancementDescription &&
preEnhancementDescription.trim()
) {
// Check if this pre-enhancement text is different from the last history entry
const lastEntry = updatedHistory[updatedHistory.length - 1];
if (!lastEntry || lastEntry.description !== preEnhancementDescription) {
const preEnhanceEntry: DescriptionHistoryEntry = {
description: preEnhancementDescription,
timestamp,
source: updatedHistory.length === 0 ? 'initial' : 'edit',
};
updatedHistory = [...updatedHistory, preEnhanceEntry];
}
}
// Add the new/enhanced description to history
const historyEntry: DescriptionHistoryEntry = {
description: updates.description,
timestamp: new Date().toISOString(),
timestamp,
source: descriptionHistorySource || 'edit',
...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}),
};

View File

@@ -0,0 +1,360 @@
/**
* Init Script Service - Executes worktree initialization scripts
*
* Runs the .automaker/worktree-init.sh script after worktree creation.
* Uses Git Bash on Windows for cross-platform shell script compatibility.
*/
import { spawn } from 'child_process';
import path from 'path';
import { createLogger } from '@automaker/utils';
import { systemPathExists, getShellPaths, findGitBashPath } from '@automaker/platform';
import { findCommand } from '../lib/cli-detection.js';
import type { EventEmitter } from '../lib/events.js';
import { readWorktreeMetadata, writeWorktreeMetadata } from '../lib/worktree-metadata.js';
import * as secureFs from '../lib/secure-fs.js';
const logger = createLogger('InitScript');
export interface InitScriptOptions {
/** Absolute path to the project root */
projectPath: string;
/** Absolute path to the worktree directory */
worktreePath: string;
/** Branch name for this worktree */
branch: string;
/** Event emitter for streaming output */
emitter: EventEmitter;
}
interface ShellCommand {
shell: string;
args: string[];
}
/**
* Init Script Service
*
* Handles execution of worktree initialization scripts with cross-platform
* shell detection and proper streaming of output via WebSocket events.
*/
export class InitScriptService {
private cachedShellCommand: ShellCommand | null | undefined = undefined;
/**
* Get the path to the init script for a project
*/
getInitScriptPath(projectPath: string): string {
return path.join(projectPath, '.automaker', 'worktree-init.sh');
}
/**
* Check if the init script has already been run for a worktree
*/
async hasInitScriptRun(projectPath: string, branch: string): Promise<boolean> {
const metadata = await readWorktreeMetadata(projectPath, branch);
return metadata?.initScriptRan === true;
}
/**
* Find the appropriate shell for running scripts
* Uses findGitBashPath() on Windows to avoid WSL bash, then falls back to PATH
*/
async findShellCommand(): Promise<ShellCommand | null> {
// Return cached result if available
if (this.cachedShellCommand !== undefined) {
return this.cachedShellCommand;
}
if (process.platform === 'win32') {
// On Windows, prioritize Git Bash over WSL bash (C:\Windows\System32\bash.exe)
// WSL bash may not be properly configured and causes ENOENT errors
// First try known Git Bash installation paths
const gitBashPath = await findGitBashPath();
if (gitBashPath) {
logger.debug(`Found Git Bash at: ${gitBashPath}`);
this.cachedShellCommand = { shell: gitBashPath, args: [] };
return this.cachedShellCommand;
}
// Fall back to finding bash in PATH, but skip WSL bash
const bashInPath = await findCommand(['bash']);
if (bashInPath && !bashInPath.toLowerCase().includes('system32')) {
logger.debug(`Found bash in PATH at: ${bashInPath}`);
this.cachedShellCommand = { shell: bashInPath, args: [] };
return this.cachedShellCommand;
}
logger.warn('Git Bash not found. WSL bash was skipped to avoid compatibility issues.');
this.cachedShellCommand = null;
return null;
}
// Unix-like systems: use getShellPaths() and check existence
const shellPaths = getShellPaths();
const posixShells = shellPaths.filter(
(p) => p.includes('bash') || p === '/bin/sh' || p === '/usr/bin/sh'
);
for (const shellPath of posixShells) {
try {
if (systemPathExists(shellPath)) {
this.cachedShellCommand = { shell: shellPath, args: [] };
return this.cachedShellCommand;
}
} catch {
// Path not allowed or doesn't exist, continue
}
}
// Ultimate fallback
if (systemPathExists('/bin/sh')) {
this.cachedShellCommand = { shell: '/bin/sh', args: [] };
return this.cachedShellCommand;
}
this.cachedShellCommand = null;
return null;
}
/**
* Run the worktree initialization script
* Non-blocking - returns immediately after spawning
*/
async runInitScript(options: InitScriptOptions): Promise<void> {
const { projectPath, worktreePath, branch, emitter } = options;
const scriptPath = this.getInitScriptPath(projectPath);
// Check if script exists using secureFs (respects ALLOWED_ROOT_DIRECTORY)
try {
await secureFs.access(scriptPath);
} catch {
logger.debug(`No init script found at ${scriptPath}`);
return;
}
// Check if already run
if (await this.hasInitScriptRun(projectPath, branch)) {
logger.info(`Init script already ran for branch "${branch}", skipping`);
return;
}
// Get shell command
const shellCmd = await this.findShellCommand();
if (!shellCmd) {
const error =
process.platform === 'win32'
? 'Git Bash not found. Please install Git for Windows to run init scripts.'
: 'No shell found (/bin/bash or /bin/sh)';
logger.error(error);
// Update metadata with error, preserving existing metadata
const existingMetadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: existingMetadata?.createdAt || new Date().toISOString(),
pr: existingMetadata?.pr,
initScriptRan: true,
initScriptStatus: 'failed',
initScriptError: error,
});
emitter.emit('worktree:init-completed', {
projectPath,
worktreePath,
branch,
success: false,
error,
});
return;
}
logger.info(`Running init script for branch "${branch}" in ${worktreePath}`);
logger.debug(`Using shell: ${shellCmd.shell}`);
// Update metadata to mark as running
const existingMetadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: existingMetadata?.createdAt || new Date().toISOString(),
pr: existingMetadata?.pr,
initScriptRan: false,
initScriptStatus: 'running',
});
// Emit started event
emitter.emit('worktree:init-started', {
projectPath,
worktreePath,
branch,
});
// Build safe environment - only pass necessary variables, not all of process.env
// This prevents exposure of sensitive credentials like ANTHROPIC_API_KEY
const safeEnv: Record<string, string> = {
// Automaker-specific variables
AUTOMAKER_PROJECT_PATH: projectPath,
AUTOMAKER_WORKTREE_PATH: worktreePath,
AUTOMAKER_BRANCH: branch,
// Essential system variables
PATH: process.env.PATH || '',
HOME: process.env.HOME || '',
USER: process.env.USER || '',
TMPDIR: process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp',
// Shell and locale
SHELL: process.env.SHELL || '',
LANG: process.env.LANG || 'en_US.UTF-8',
LC_ALL: process.env.LC_ALL || '',
// Force color output even though we're not a TTY
FORCE_COLOR: '1',
npm_config_color: 'always',
CLICOLOR_FORCE: '1',
// Git configuration
GIT_TERMINAL_PROMPT: '0',
};
// Platform-specific additions
if (process.platform === 'win32') {
safeEnv.USERPROFILE = process.env.USERPROFILE || '';
safeEnv.APPDATA = process.env.APPDATA || '';
safeEnv.LOCALAPPDATA = process.env.LOCALAPPDATA || '';
safeEnv.SystemRoot = process.env.SystemRoot || 'C:\\Windows';
safeEnv.TEMP = process.env.TEMP || '';
}
// Spawn the script with safe environment
const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], {
cwd: worktreePath,
env: safeEnv,
stdio: ['ignore', 'pipe', 'pipe'],
});
// Stream stdout
child.stdout?.on('data', (data: Buffer) => {
const content = data.toString();
emitter.emit('worktree:init-output', {
projectPath,
branch,
type: 'stdout',
content,
});
});
// Stream stderr
child.stderr?.on('data', (data: Buffer) => {
const content = data.toString();
emitter.emit('worktree:init-output', {
projectPath,
branch,
type: 'stderr',
content,
});
});
// Handle completion
child.on('exit', async (code) => {
const success = code === 0;
const status = success ? 'success' : 'failed';
logger.info(`Init script for branch "${branch}" ${status} with exit code ${code}`);
// Update metadata
const metadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: metadata?.createdAt || new Date().toISOString(),
pr: metadata?.pr,
initScriptRan: true,
initScriptStatus: status,
initScriptError: success ? undefined : `Exit code: ${code}`,
});
// Emit completion event
emitter.emit('worktree:init-completed', {
projectPath,
worktreePath,
branch,
success,
exitCode: code,
});
});
child.on('error', async (error) => {
logger.error(`Init script error for branch "${branch}":`, error);
// Update metadata
const metadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: metadata?.createdAt || new Date().toISOString(),
pr: metadata?.pr,
initScriptRan: true,
initScriptStatus: 'failed',
initScriptError: error.message,
});
// Emit completion with error
emitter.emit('worktree:init-completed', {
projectPath,
worktreePath,
branch,
success: false,
error: error.message,
});
});
}
/**
* Force re-run the worktree initialization script
* Ignores the initScriptRan flag - useful for testing or re-setup
*/
async forceRunInitScript(options: InitScriptOptions): Promise<void> {
const { projectPath, branch } = options;
// Reset the initScriptRan flag so the script will run
const metadata = await readWorktreeMetadata(projectPath, branch);
if (metadata) {
await writeWorktreeMetadata(projectPath, branch, {
...metadata,
initScriptRan: false,
initScriptStatus: undefined,
initScriptError: undefined,
});
}
// Now run the script
await this.runInitScript(options);
}
}
// Singleton instance for convenience
let initScriptService: InitScriptService | null = null;
/**
* Get the singleton InitScriptService instance
*/
export function getInitScriptService(): InitScriptService {
if (!initScriptService) {
initScriptService = new InitScriptService();
}
return initScriptService;
}
// Export convenience functions that use the singleton
export const getInitScriptPath = (projectPath: string) =>
getInitScriptService().getInitScriptPath(projectPath);
export const hasInitScriptRun = (projectPath: string, branch: string) =>
getInitScriptService().hasInitScriptRun(projectPath, branch);
export const runInitScript = (options: InitScriptOptions) =>
getInitScriptService().runInitScript(options);
export const forceRunInitScript = (options: InitScriptOptions) =>
getInitScriptService().forceRunInitScript(options);

View File

@@ -17,6 +17,14 @@ import {
type EnhancementMode,
} from '@/lib/enhancement-prompts.js';
const ENHANCEMENT_MODES: EnhancementMode[] = [
'improve',
'technical',
'simplify',
'acceptance',
'ux-reviewer',
];
describe('enhancement-prompts.ts', () => {
describe('System Prompt Constants', () => {
it('should have non-empty improve system prompt', () => {
@@ -184,8 +192,7 @@ describe('enhancement-prompts.ts', () => {
});
it('should work with all enhancement modes', () => {
const modes: EnhancementMode[] = ['improve', 'technical', 'simplify', 'acceptance'];
modes.forEach((mode) => {
ENHANCEMENT_MODES.forEach((mode) => {
const prompt = buildUserPrompt(mode, testText);
expect(prompt).toContain(testText);
expect(prompt.length).toBeGreaterThan(100);
@@ -205,6 +212,7 @@ describe('enhancement-prompts.ts', () => {
expect(isValidEnhancementMode('technical')).toBe(true);
expect(isValidEnhancementMode('simplify')).toBe(true);
expect(isValidEnhancementMode('acceptance')).toBe(true);
expect(isValidEnhancementMode('ux-reviewer')).toBe(true);
});
it('should return false for invalid modes', () => {
@@ -216,13 +224,12 @@ describe('enhancement-prompts.ts', () => {
});
describe('getAvailableEnhancementModes', () => {
it('should return all four enhancement modes', () => {
it('should return all enhancement modes', () => {
const modes = getAvailableEnhancementModes();
expect(modes).toHaveLength(4);
expect(modes).toContain('improve');
expect(modes).toContain('technical');
expect(modes).toContain('simplify');
expect(modes).toContain('acceptance');
expect(modes).toHaveLength(ENHANCEMENT_MODES.length);
ENHANCEMENT_MODES.forEach((mode) => {
expect(modes).toContain(mode);
});
});
it('should return an array', () => {

View File

@@ -3,7 +3,7 @@ import {
OpencodeProvider,
resetToolUseIdCounter,
} from '../../../src/providers/opencode-provider.js';
import type { ProviderMessage } from '@automaker/types';
import type { ProviderMessage, ModelDefinition } from '@automaker/types';
import { collectAsyncGenerator } from '../../utils/helpers.js';
import { spawnJSONLProcess, getOpenCodeAuthIndicators } from '@automaker/platform';
@@ -51,63 +51,38 @@ describe('opencode-provider.ts', () => {
});
describe('getAvailableModels', () => {
it('should return 10 models', () => {
it('should return 5 models', () => {
const models = provider.getAvailableModels();
expect(models).toHaveLength(10);
expect(models).toHaveLength(5);
});
it('should include Claude Sonnet 4.5 (Bedrock) as default', () => {
const models = provider.getAvailableModels();
const sonnet = models.find(
(m) => m.id === 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0'
);
expect(sonnet).toBeDefined();
expect(sonnet?.name).toBe('Claude Sonnet 4.5 (Bedrock)');
expect(sonnet?.provider).toBe('opencode');
expect(sonnet?.default).toBe(true);
expect(sonnet?.modelString).toBe('amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0');
});
it('should include Claude Opus 4.5 (Bedrock)', () => {
const models = provider.getAvailableModels();
const opus = models.find(
(m) => m.id === 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0'
);
expect(opus).toBeDefined();
expect(opus?.name).toBe('Claude Opus 4.5 (Bedrock)');
expect(opus?.modelString).toBe('amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0');
});
it('should include Claude Haiku 4.5 (Bedrock)', () => {
const models = provider.getAvailableModels();
const haiku = models.find(
(m) => m.id === 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0'
);
expect(haiku).toBeDefined();
expect(haiku?.name).toBe('Claude Haiku 4.5 (Bedrock)');
expect(haiku?.tier).toBe('standard');
});
it('should include free tier Big Pickle model', () => {
it('should include Big Pickle as default', () => {
const models = provider.getAvailableModels();
const bigPickle = models.find((m) => m.id === 'opencode/big-pickle');
expect(bigPickle).toBeDefined();
expect(bigPickle?.name).toBe('Big Pickle (Free)');
expect(bigPickle?.provider).toBe('opencode');
expect(bigPickle?.default).toBe(true);
expect(bigPickle?.modelString).toBe('opencode/big-pickle');
expect(bigPickle?.tier).toBe('basic');
});
it('should include DeepSeek R1 (Bedrock)', () => {
it('should include free tier GLM model', () => {
const models = provider.getAvailableModels();
const deepseek = models.find((m) => m.id === 'amazon-bedrock/deepseek.r1-v1:0');
const glm = models.find((m) => m.id === 'opencode/glm-4.7-free');
expect(deepseek).toBeDefined();
expect(deepseek?.name).toBe('DeepSeek R1 (Bedrock)');
expect(deepseek?.tier).toBe('premium');
expect(glm).toBeDefined();
expect(glm?.name).toBe('GLM 4.7 Free');
expect(glm?.tier).toBe('basic');
});
it('should include free tier MiniMax model', () => {
const models = provider.getAvailableModels();
const minimax = models.find((m) => m.id === 'opencode/minimax-m2.1-free');
expect(minimax).toBeDefined();
expect(minimax?.name).toBe('MiniMax M2.1 Free');
expect(minimax?.tier).toBe('basic');
});
it('should have all models support tools', () => {
@@ -128,6 +103,24 @@ describe('opencode-provider.ts', () => {
});
});
describe('parseModelsOutput', () => {
it('should parse nested provider model IDs', () => {
const output = ['openrouter/anthropic/claude-3.5-sonnet', 'openai/gpt-4o'].join('\n');
const parseModelsOutput = (
provider as unknown as { parseModelsOutput: (output: string) => ModelDefinition[] }
).parseModelsOutput.bind(provider);
const models = parseModelsOutput(output);
expect(models).toHaveLength(2);
const openrouterModel = models.find((model) => model.id.startsWith('openrouter/'));
expect(openrouterModel).toBeDefined();
expect(openrouterModel?.provider).toBe('openrouter');
expect(openrouterModel?.modelString).toBe('openrouter/anthropic/claude-3.5-sonnet');
});
});
describe('supportsFeature', () => {
it("should support 'tools' feature", () => {
expect(provider.supportsFeature('tools')).toBe(true);
@@ -1243,7 +1236,7 @@ describe('opencode-provider.ts', () => {
const defaultModels = models.filter((m) => m.default === true);
expect(defaultModels).toHaveLength(1);
expect(defaultModels[0].id).toBe('amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0');
expect(defaultModels[0].id).toBe('opencode/big-pickle');
});
it('should have valid tier values for all models', () => {

View File

@@ -5,59 +5,61 @@ import {
getSpecRegenerationStatus,
} from '@/routes/app-spec/common.js';
const TEST_PROJECT_PATH = '/tmp/automaker-test-project';
describe('app-spec/common.ts', () => {
beforeEach(() => {
// Reset state before each test
setRunningState(false, null);
setRunningState(TEST_PROJECT_PATH, false, null);
});
describe('setRunningState', () => {
it('should set isRunning to true when running is true', () => {
setRunningState(true);
expect(getSpecRegenerationStatus().isRunning).toBe(true);
setRunningState(TEST_PROJECT_PATH, true);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true);
});
it('should set isRunning to false when running is false', () => {
setRunningState(true);
setRunningState(false);
expect(getSpecRegenerationStatus().isRunning).toBe(false);
setRunningState(TEST_PROJECT_PATH, true);
setRunningState(TEST_PROJECT_PATH, false);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(false);
});
it('should set currentAbortController when provided', () => {
const controller = new AbortController();
setRunningState(true, controller);
expect(getSpecRegenerationStatus().currentAbortController).toBe(controller);
setRunningState(TEST_PROJECT_PATH, true, controller);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller);
});
it('should set currentAbortController to null when not provided', () => {
const controller = new AbortController();
setRunningState(true, controller);
setRunningState(false);
expect(getSpecRegenerationStatus().currentAbortController).toBe(null);
setRunningState(TEST_PROJECT_PATH, true, controller);
setRunningState(TEST_PROJECT_PATH, false);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(null);
});
it('should set currentAbortController to null when explicitly passed null', () => {
it('should keep currentAbortController when explicitly passed null while running', () => {
const controller = new AbortController();
setRunningState(true, controller);
setRunningState(true, null);
expect(getSpecRegenerationStatus().currentAbortController).toBe(null);
setRunningState(TEST_PROJECT_PATH, true, controller);
setRunningState(TEST_PROJECT_PATH, true, null);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller);
});
it('should update state multiple times correctly', () => {
const controller1 = new AbortController();
const controller2 = new AbortController();
setRunningState(true, controller1);
expect(getSpecRegenerationStatus().isRunning).toBe(true);
expect(getSpecRegenerationStatus().currentAbortController).toBe(controller1);
setRunningState(TEST_PROJECT_PATH, true, controller1);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller1);
setRunningState(true, controller2);
expect(getSpecRegenerationStatus().isRunning).toBe(true);
expect(getSpecRegenerationStatus().currentAbortController).toBe(controller2);
setRunningState(TEST_PROJECT_PATH, true, controller2);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller2);
setRunningState(false, null);
expect(getSpecRegenerationStatus().isRunning).toBe(false);
expect(getSpecRegenerationStatus().currentAbortController).toBe(null);
setRunningState(TEST_PROJECT_PATH, false, null);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(false);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(null);
});
});

View File

@@ -8,6 +8,7 @@ import fs from 'fs/promises';
vi.mock('child_process', () => ({
spawn: vi.fn(),
execSync: vi.fn(),
execFile: vi.fn(),
}));
// Mock secure-fs

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/ui",
"version": "0.9.0",
"version": "0.10.0",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker",
"repository": {
@@ -42,6 +42,8 @@
"@automaker/dependency-resolver": "1.0.0",
"@automaker/types": "1.0.0",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/theme-one-dark": "6.1.3",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",

View File

@@ -0,0 +1,220 @@
import type { ComponentType, ComponentProps } from 'react';
import { FolderOpen } from 'lucide-react';
type IconProps = ComponentProps<'svg'>;
type IconComponent = ComponentType<IconProps>;
const ANTIGRAVITY_COMMANDS = ['antigravity', 'agy'] as const;
const [PRIMARY_ANTIGRAVITY_COMMAND, LEGACY_ANTIGRAVITY_COMMAND] = ANTIGRAVITY_COMMANDS;
/**
* Cursor editor logo icon - from LobeHub icons
*/
export function CursorIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z" />
</svg>
);
}
/**
* VS Code editor logo icon
*/
export function VSCodeIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z" />
</svg>
);
}
/**
* VS Code Insiders editor logo icon (same as VS Code)
*/
export function VSCodeInsidersIcon(props: IconProps) {
return <VSCodeIcon {...props} />;
}
/**
* Kiro editor logo icon (VS Code fork)
*/
export function KiroIcon(props: IconProps) {
return (
<svg viewBox="0 0 32 32" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M6.594.016A7.006 7.006 0 0 0 .742 3.875a6.996 6.996 0 0 0-.726 2.793C.004 6.878 0 9.93.004 16.227c.004 8.699.008 9.265.031 9.476.113.93.324 1.652.707 2.422a6.918 6.918 0 0 0 3.172 3.148c.75.372 1.508.59 2.398.692.227.027.77.027 9.688.027 8.945 0 9.457 0 9.688-.027.917-.106 1.66-.32 2.437-.707a6.918 6.918 0 0 0 3.148-3.172c.372-.75.59-1.508.692-2.398.027-.227.027-.77.027-9.665 0-9.976.004-9.53-.07-10.03a6.993 6.993 0 0 0-3.024-4.798 6.427 6.427 0 0 0-.757-.445 7.06 7.06 0 0 0-2.774-.734c-.328-.02-18.437-.02-18.773 0Zm10.789 5.406a7.556 7.556 0 0 1 6.008 3.805c.148.257.406.796.52 1.085.394 1 .632 2.157.769 3.75.035.38.05 1.965.023 2.407-.125 2.168-.625 4.183-1.515 6.078a9.77 9.77 0 0 1-.801 1.437c-.93 1.305-2.32 2.332-3.48 2.57-.895.184-1.602-.1-2.048-.827a3.42 3.42 0 0 1-.25-.528c-.035-.097-.062-.129-.086-.09-.003.008-.09.075-.191.153-.95.722-2.02 1.175-3.059 1.293-.273.03-.859.023-1.085-.016-.715-.121-1.286-.441-1.649-.93a2.563 2.563 0 0 1-.328-.632c-.117-.36-.156-.813-.117-1.227.054-.55.226-1.184.484-1.766a.48.48 0 0 0 .043-.117 2.11 2.11 0 0 0-.137.055c-.363.16-.898.305-1.308.351-.844.098-1.426-.14-1.715-.699-.106-.203-.149-.39-.16-.676-.008-.261.008-.43.066-.656.059-.23.121-.367.403-.89.382-.72.492-.946.636-1.348.328-.899.48-1.723.688-3.754.148-1.469.254-2.14.433-2.766.028-.09.078-.277.114-.414.796-3.074 3.113-5.183 6.148-5.601.129-.016.309-.04.399-.047.238-.016.96-.02 1.195 0Zm0 0" />
<path d="M16.754 11.336a.815.815 0 0 0-.375.219c-.176.18-.293.441-.356.804-.039.235-.058.602-.039.868.028.406.082.64.204.894.128.262.304.426.546.496.106.031.383.031.5 0 .422-.113.703-.531.801-1.191a4.822 4.822 0 0 0-.012-.95c-.062-.378-.183-.675-.359-.863a.808.808 0 0 0-.648-.293.804.804 0 0 0-.262.016ZM20.375 11.328a1.01 1.01 0 0 0-.363.188c-.164.144-.293.402-.364.718-.05.23-.07.426-.07.743 0 .32.02.511.07.742.11.496.352.808.688.898.121.031.379.031.5 0 .402-.105.68-.5.781-1.11.035-.198.047-.648.024-.87-.063-.63-.293-1.059-.649-1.23a1.513 1.513 0 0 0-.219-.079 1.362 1.362 0 0 0-.398 0Zm0 0" />
</svg>
);
}
/**
* Zed editor logo icon (from Simple Icons)
*/
export function ZedIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2.25 1.5a.75.75 0 0 0-.75.75v16.5H0V2.25A2.25 2.25 0 0 1 2.25 0h20.095c1.002 0 1.504 1.212.795 1.92L10.764 14.298h3.486V12.75h1.5v1.922a1.125 1.125 0 0 1-1.125 1.125H9.264l-2.578 2.578h11.689V9h1.5v9.375a1.5 1.5 0 0 1-1.5 1.5H5.185L2.562 22.5H21.75a.75.75 0 0 0 .75-.75V5.25H24v16.5A2.25 2.25 0 0 1 21.75 24H1.655C.653 24 .151 22.788.86 22.08L13.19 9.75H9.75v1.5h-1.5V9.375A1.125 1.125 0 0 1 9.375 8.25h5.314l2.625-2.625H5.625V15h-1.5V5.625a1.5 1.5 0 0 1 1.5-1.5h13.19L21.438 1.5z" />
</svg>
);
}
/**
* Sublime Text editor logo icon
*/
export function SublimeTextIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M20.953.004a.397.397 0 0 0-.18.045L3.473 8.63a.397.397 0 0 0-.033.69l4.873 3.33-5.26 2.882a.397.397 0 0 0-.006.692l17.3 9.73a.397.397 0 0 0 .593-.344V15.094a.397.397 0 0 0-.203-.346l-4.917-2.763 5.233-2.725a.397.397 0 0 0 .207-.348V.397a.397.397 0 0 0-.307-.393z" />
</svg>
);
}
/**
* macOS Finder icon
*/
export function FinderIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2.5 3A2.5 2.5 0 0 0 0 5.5v13A2.5 2.5 0 0 0 2.5 21h19a2.5 2.5 0 0 0 2.5-2.5v-13A2.5 2.5 0 0 0 21.5 3h-19zM7 8.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm10 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm-9 6c0-.276.336-.5.75-.5h6.5c.414 0 .75.224.75.5v1c0 .828-1.343 2.5-4 2.5s-4-1.672-4-2.5v-1z" />
</svg>
);
}
/**
* Windsurf editor logo icon (by Codeium) - from LobeHub icons
*/
export function WindsurfIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M23.78 5.004h-.228a2.187 2.187 0 00-2.18 2.196v4.912c0 .98-.804 1.775-1.76 1.775a1.818 1.818 0 01-1.472-.773L13.168 5.95a2.197 2.197 0 00-1.81-.95c-1.134 0-2.154.972-2.154 2.173v4.94c0 .98-.797 1.775-1.76 1.775-.57 0-1.136-.289-1.472-.773L.408 5.098C.282 4.918 0 5.007 0 5.228v4.284c0 .216.066.426.188.604l5.475 7.889c.324.466.8.812 1.351.938 1.377.316 2.645-.754 2.645-2.117V11.89c0-.98.787-1.775 1.76-1.775h.002c.586 0 1.135.288 1.472.773l4.972 7.163a2.15 2.15 0 001.81.95c1.158 0 2.151-.973 2.151-2.173v-4.939c0-.98.787-1.775 1.76-1.775h.194c.122 0 .22-.1.22-.222V5.225a.221.221 0 00-.22-.222z"
/>
</svg>
);
}
/**
* Trae editor logo icon (by ByteDance) - from LobeHub icons
*/
export function TraeIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196l-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001l-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" />
</svg>
);
}
/**
* JetBrains Rider logo icon
*/
export function RiderIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M0 0v24h24V0zm7.031 3.113A4.063 4.063 0 0 1 9.72 4.14a3.23 3.23 0 0 1 .84 2.28A3.16 3.16 0 0 1 8.4 9.54l2.46 3.6H8.28L6.12 9.9H4.38v3.24H2.16V3.12c1.61-.004 3.281.009 4.871-.007zm5.509.007h3.96c3.18 0 5.34 2.16 5.34 5.04 0 2.82-2.16 5.04-5.34 5.04h-3.96zm4.069 1.976c-.607.01-1.235.004-1.849.004v6.06h1.74a2.882 2.882 0 0 0 3.06-3 2.897 2.897 0 0 0-2.951-3.064zM4.319 5.1v2.88H6.6c1.08 0 1.68-.6 1.68-1.44 0-.96-.66-1.44-1.74-1.44zM2.16 19.5h9V21h-9Z" />
</svg>
);
}
/**
* JetBrains WebStorm logo icon
*/
export function WebStormIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M0 0v24h24V0H0zm17.889 2.889c1.444 0 2.667.444 3.667 1.278l-1.111 1.667c-.889-.611-1.722-1-2.556-1s-1.278.389-1.278.889v.056c0 .667.444.889 2.111 1.333 2 .556 3.111 1.278 3.111 3v.056c0 2-1.5 3.111-3.611 3.111-1.5-.056-3-.611-4.167-1.667l1.278-1.556c.889.722 1.833 1.222 2.944 1.222.889 0 1.389-.333 1.389-.944v-.056c0-.556-.333-.833-2-1.278-2-.5-3.222-1.056-3.222-3.056v-.056c0-1.833 1.444-3 3.444-3zm-16.111.222h2.278l1.5 5.778 1.722-5.778h1.667l1.667 5.778 1.5-5.778h2.333l-2.833 9.944H9.723L8.112 7.277l-1.667 5.778H4.612L1.779 3.111zm.5 16.389h9V21h-9v-1.5z" />
</svg>
);
}
/**
* Xcode logo icon
*/
export function XcodeIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M19.06 5.3327c.4517-.1936.7744-.2581 1.097-.1936.5163.1291.7744.5163.968.7098.1936.3872.9034.7744 1.2261.8389.2581.0645.7098-.6453 1.0325-1.2906.3227-.5808.5163-1.3552.4517-1.5488-.0645-.1936-.968-.5808-1.1616-.5808-.1291 0-.3872.1291-.8389.0645-.4517-.0645-.9034-.5808-1.1616-.968-.4517-.6453-1.097-1.0325-1.6778-1.3552-.6453-.3227-1.3552-.5163-2.065-.6453-1.0325-.2581-2.065-.4517-3.0975-.3227-.5808.0645-1.2906.1291-1.8069.3227-.0645 0-.1936.1936-.0645.1936s.5808.0645.5808.0645-.5807.1292-.5807.2583c0 .1291.0645.1291.1291.1291.0645 0 1.4842-.0645 2.065 0 .6453.1291 1.3552.4517 1.8069 1.2261.7744 1.4197.4517 2.7749.2581 3.2266-.968 2.1295-8.6472 15.2294-9.0344 16.1328-.3873.9034-.5163 1.4842.5807 2.065s1.6778.3227 2.0005-.0645c.3872-.5163 7.0339-17.1654 9.2925-18.2624zm-3.6138 8.7117h1.5488c1.0325 0 1.2261.5163 1.2261.7098.0645.5163-.1936 1.1616-1.2261 1.1616h-.968l.7744 1.2906c.4517.7744.2581 1.1616 0 1.4197-.3872.3872-1.2261.3872-1.6778-.4517l-.9034-1.5488c-.6453 1.4197-1.2906 2.9684-2.065 4.7753h4.0009c1.9359 0 3.5492-1.6133 3.5492-3.5492V6.5588c-.0645-.1291-.1936-.0645-.2581 0-.3872.4517-1.4842 2.0004-4.001 7.4856zm-9.8087 8.0019h-.3227c-2.3231 0-4.1945-1.8714-4.1945-4.1945V7.0105c0-2.3231 1.8714-4.1945 4.1945-4.1945h9.3571c-.1936-.1936-.968-.5163-1.7423-.4517-.3227 0-.968.1291-1.3552-.1291-.3872-.3227-.3227-.5163-.9034-.5163H4.9277c-2.6458 0-4.7753 2.1295-4.7753 4.7753v11.7447c0 2.6458 2.1295 4.7753 4.4527 4.7108.6452 0 .8388-.5162 1.0324-.9034zM20.4152 6.9459v10.9058c0 2.3231-1.8714 4.1945-4.1945 4.1945H11.897s-.3872 1.0325.8389 1.0325h3.8719c2.6458 0 4.7753-2.1295 4.7753-4.7753V8.8173c.0646-.9034-.7098-1.4842-.9679-1.8714zm-18.5851.0646v10.8413c0 1.9359 1.6133 3.5492 3.5492 3.5492h.5808c0-.0645.7744-1.4197 2.4522-4.2591.1936-.3872.4517-.7744.7098-1.2261H4.4114c-.5808 0-.9034-.3872-.968-.7098-.1291-.5163.1936-1.1616.9034-1.1616h2.3877l3.033-5.2916s-.7098-1.2906-.9034-1.6133c-.2582-.4517-.1291-.9034.129-1.1615.3872-.3872 1.0325-.5808 1.6778.4517l.2581.3872.2581-.3872c.5808-.8389.968-.7744 1.2906-.7098.5163.1291.8389.7098.3872 1.6133L8.864 14.0444h1.3552c.4517-.7744.9034-1.5488 1.3552-2.3877-.0645-.3227-.1291-.7098-.0645-1.0325.0645-.5163.3227-.968.6453-1.3552l.3872.6453c1.2261-2.1295 2.1295-3.9364 2.3877-4.6463.1291-.3872.3227-1.1616.1291-1.8069H5.3794c-2.0005.0001-3.5493 1.6134-3.5493 3.5494zM4.605 17.7872c0-.0645.7744-1.4197.7744-1.4197 1.2261-.3227 1.8069.4517 1.8714.5163 0 0-.8389 1.4842-1.097 1.7423s-.5808.3227-.9034.2581c-.5164-.129-.839-.6453-.6454-1.097z" />
</svg>
);
}
/**
* Android Studio logo icon
*/
export function AndroidStudioIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M19.2693 10.3368c-.3321 0-.6026.2705-.6026.6031v9.8324h-1.7379l-3.3355-6.9396c.476-.5387.6797-1.286.5243-2.0009a2.2862 2.2862 0 0 0-1.2893-1.6248v-.8124c.0121-.2871-.1426-.5787-.4043-.7407-.1391-.0825-.2884-.1234-.4402-.1234a.8478.8478 0 0 0-.4318.1182c-.2701.1671-.4248.4587-.4123.7662l-.0003.721c-1.0149.3668-1.6619 1.4153-1.4867 2.5197a2.282 2.282 0 0 0 .5916 1.2103l-3.2096 6.9064H4.0928c-1.0949-.007-1.9797-.8948-1.9832-1.9896V5.016c-.0055 1.1024.8836 2.0006 1.9859 2.0062a2.024 2.024 0 0 0 .1326-.0037h14.7453s2.5343-.2189 2.8619 1.5392c-.2491.0287-.4449.2321-.4449.4889 0 .7115-.5791 1.2901-1.3028 1.2901h-.8183zM17.222 22.5366c.2347.4837.0329 1.066-.4507 1.3007-.1296.0629-.2666.0895-.4018.0927a.9738.9738 0 0 1-.3194-.0455c-.024-.0078-.046-.0209-.0694-.0305a.9701.9701 0 0 1-.2277-.1321c-.0247-.0192-.0495-.038-.0724-.0598-.0825-.0783-.1574-.1672-.21-.2757l-1.2554-2.6143-1.5585-3.2452a.7725.7725 0 0 0-.6995-.4443h-.0024a.792.792 0 0 0-.7083.4443l-1.5109 3.2452-1.2321 2.6464a.9722.9722 0 0 1-.7985.5795c-.0626.0053-.1238-.0024-.185-.0087-.0344-.0036-.069-.0053-.1025-.0124-.0489-.0103-.0954-.0278-.142-.0452-.0301-.0113-.0613-.0197-.0901-.0339-.0496-.0244-.0948-.0565-.1397-.0889-.0217-.0156-.0457-.0275-.0662-.045a.9862.9862 0 0 1-.1695-.1844.9788.9788 0 0 1-.0708-.9852l.8469-1.8223 3.2676-7.0314a1.7964 1.7964 0 0 1-.7072-1.1637c-.1555-.9799.5129-1.9003 1.4928-2.0559V9.3946a.3542.3542 0 0 1 .1674-.3155.3468.3468 0 0 1 .3541 0 .354.354 0 0 1 .1674.3155v1.159l.0129.0064a1.8028 1.8028 0 0 1 1.2878 1.378 1.7835 1.7835 0 0 1-.6439 1.7836l3.3889 7.0507.8481 1.7643zM12.9841 12.306c.0042-.6081-.4854-1.1044-1.0935-1.1085a1.1204 1.1204 0 0 0-.7856.3219 1.101 1.101 0 0 0-.323.7716c-.0042.6081.4854 1.1044 1.0935 1.1085h.0077c.6046 0 1.0967-.488 1.1009-1.0935zm-1.027 5.2768c-.1119.0005-.2121.0632-.2571.1553l-1.4127 3.0342h3.3733l-1.4564-3.0328a.274.274 0 0 0-.2471-.1567zm8.1432-6.7459l-.0129-.0001h-.8177a.103.103 0 0 0-.103.103v12.9103a.103.103 0 0 0 .0966.103h.8435c.9861-.0035 1.7836-.804 1.7836-1.79V9.0468c0 .9887-.8014 1.7901-1.7901 1.7901zM2.6098 5.0161v.019c.0039.816.6719 1.483 1.4874 1.4869a12.061 12.061 0 0 1 .1309-.0034h1.1286c.1972-1.315.7607-2.525 1.638-3.4859H4.0993c-.9266.0031-1.6971.6401-1.9191 1.4975.2417.0355.4296.235.4296.4859zm6.3381-2.8977L7.9112.3284a.219.219 0 0 1 0-.2189A.2384.2384 0 0 1 8.098 0a.219.219 0 0 1 .1867.1094l1.0496 1.8158a6.4907 6.4907 0 0 1 5.3186 0L15.696.1094a.2189.2189 0 0 1 .3734.2189l-1.0302 1.79c1.6671.9125 2.7974 2.5439 3.0975 4.4018l-12.286-.0014c.3004-1.8572 1.4305-3.488 3.0972-4.4003zm5.3774 2.6202a.515.515 0 0 0 .5271.5028.515.515 0 0 0 .5151-.5151.5213.5213 0 0 0-.8885-.367.5151.5151 0 0 0-.1537.3793zm-5.7178-.0067a.5151.5151 0 0 0 .5207.5095.5086.5086 0 0 0 .367-.1481.5215.5215 0 1 0-.734-.7341.515.515 0 0 0-.1537.3727z" />
</svg>
);
}
/**
* Google Antigravity IDE logo icon - stylized "A" arch shape
*/
export function AntigravityIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 1C11 1 9.5 3 8 7c-1.5 4-3 8.5-4 11.5-.5 1.5-.3 2.8.5 3.3.8.5 2 .2 3-.8.8-.8 1.3-2 1.8-3.2.3-.8.8-1.3 1.5-1.3h2.4c.7 0 1.2.5 1.5 1.3.5 1.2 1 2.4 1.8 3.2 1 1 2.2 1.3 3 .8.8-.5 1-1.8.5-3.3-1-3-2.5-7.5-4-11.5C14.5 3 13 1 12 1zm0 5c.8 2 2 5.5 3 8.5H9c1-3 2.2-6.5 3-8.5z"
/>
</svg>
);
}
/**
* Get the appropriate icon component for an editor command
*/
export function getEditorIcon(command: string): IconComponent {
// Handle direct CLI commands
const cliIcons: Record<string, IconComponent> = {
cursor: CursorIcon,
code: VSCodeIcon,
'code-insiders': VSCodeInsidersIcon,
kido: KiroIcon,
zed: ZedIcon,
subl: SublimeTextIcon,
windsurf: WindsurfIcon,
trae: TraeIcon,
rider: RiderIcon,
webstorm: WebStormIcon,
xed: XcodeIcon,
studio: AndroidStudioIcon,
[PRIMARY_ANTIGRAVITY_COMMAND]: AntigravityIcon,
[LEGACY_ANTIGRAVITY_COMMAND]: AntigravityIcon,
open: FinderIcon,
explorer: FolderOpen,
'xdg-open': FolderOpen,
};
// Check direct match first
if (cliIcons[command]) {
return cliIcons[command];
}
// Handle 'open' commands (macOS) - both 'open -a AppName' and 'open "/path/to/App.app"'
if (command.startsWith('open')) {
const cmdLower = command.toLowerCase();
if (cmdLower.includes('cursor')) return CursorIcon;
if (cmdLower.includes('visual studio code - insiders')) return VSCodeInsidersIcon;
if (cmdLower.includes('visual studio code')) return VSCodeIcon;
if (cmdLower.includes('kiro')) return KiroIcon;
if (cmdLower.includes('zed')) return ZedIcon;
if (cmdLower.includes('sublime')) return SublimeTextIcon;
if (cmdLower.includes('windsurf')) return WindsurfIcon;
if (cmdLower.includes('trae')) return TraeIcon;
if (cmdLower.includes('rider')) return RiderIcon;
if (cmdLower.includes('webstorm')) return WebStormIcon;
if (cmdLower.includes('xcode')) return XcodeIcon;
if (cmdLower.includes('android studio')) return AndroidStudioIcon;
if (cmdLower.includes('antigravity')) return AntigravityIcon;
// If just 'open' without app name, it's Finder
if (command === 'open') return FinderIcon;
}
return FolderOpen;
}

View File

@@ -257,110 +257,122 @@ export function Sidebar() {
};
return (
<aside
className={cn(
'flex-shrink-0 flex flex-col z-30 relative',
// Glass morphism background with gradient
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
// Premium border with subtle glow
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
// Smooth width transition
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
sidebarOpen ? 'w-16 lg:w-72' : 'w-16'
<>
{/* Mobile overlay backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={toggleSidebar}
aria-hidden="true"
/>
)}
data-testid="sidebar"
>
<CollapseToggleButton
sidebarOpen={sidebarOpen}
toggleSidebar={toggleSidebar}
shortcut={shortcuts.toggleSidebar}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
<ProjectSelectorWithOptions
<aside
className={cn(
'flex-shrink-0 flex flex-col z-50 relative',
// Glass morphism background with gradient
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
// Premium border with subtle glow
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
// Smooth width transition
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
// Mobile: hidden when closed, full width overlay when open
// Desktop: always visible, toggle between narrow and wide
sidebarOpen ? 'fixed lg:relative left-0 top-0 h-full w-72' : 'hidden lg:flex w-16'
)}
data-testid="sidebar"
>
<CollapseToggleButton
sidebarOpen={sidebarOpen}
isProjectPickerOpen={isProjectPickerOpen}
setIsProjectPickerOpen={setIsProjectPickerOpen}
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
toggleSidebar={toggleSidebar}
shortcut={shortcuts.toggleSidebar}
/>
<SidebarNavigation
currentProject={currentProject}
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
<ProjectSelectorWithOptions
sidebarOpen={sidebarOpen}
isProjectPickerOpen={isProjectPickerOpen}
setIsProjectPickerOpen={setIsProjectPickerOpen}
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
/>
<SidebarNavigation
currentProject={currentProject}
sidebarOpen={sidebarOpen}
navSections={navSections}
isActiveRoute={isActiveRoute}
navigate={navigate}
/>
</div>
<SidebarFooter
sidebarOpen={sidebarOpen}
navSections={navSections}
isActiveRoute={isActiveRoute}
navigate={navigate}
hideWiki={hideWiki}
hideRunningAgents={hideRunningAgents}
runningAgentsCount={runningAgentsCount}
shortcuts={{ settings: shortcuts.settings }}
/>
<TrashDialog
open={showTrashDialog}
onOpenChange={setShowTrashDialog}
trashedProjects={trashedProjects}
activeTrashId={activeTrashId}
handleRestoreProject={handleRestoreProject}
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
deleteTrashedProject={deleteTrashedProject}
handleEmptyTrash={handleEmptyTrash}
isEmptyingTrash={isEmptyingTrash}
/>
</div>
<SidebarFooter
sidebarOpen={sidebarOpen}
isActiveRoute={isActiveRoute}
navigate={navigate}
hideWiki={hideWiki}
hideRunningAgents={hideRunningAgents}
runningAgentsCount={runningAgentsCount}
shortcuts={{ settings: shortcuts.settings }}
/>
<TrashDialog
open={showTrashDialog}
onOpenChange={setShowTrashDialog}
trashedProjects={trashedProjects}
activeTrashId={activeTrashId}
handleRestoreProject={handleRestoreProject}
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
deleteTrashedProject={deleteTrashedProject}
handleEmptyTrash={handleEmptyTrash}
isEmptyingTrash={isEmptyingTrash}
/>
{/* New Project Setup Dialog */}
<CreateSpecDialog
open={showSetupDialog}
onOpenChange={setShowSetupDialog}
projectOverview={projectOverview}
onProjectOverviewChange={setProjectOverview}
generateFeatures={generateFeatures}
onGenerateFeaturesChange={setGenerateFeatures}
analyzeProject={analyzeProject}
onAnalyzeProjectChange={setAnalyzeProject}
featureCount={featureCount}
onFeatureCountChange={setFeatureCount}
onCreateSpec={handleCreateInitialSpec}
onSkip={handleSkipSetup}
isCreatingSpec={isCreatingSpec}
showSkipButton={true}
title="Set Up Your Project"
description="We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification."
/>
{/* New Project Setup Dialog */}
<CreateSpecDialog
open={showSetupDialog}
onOpenChange={setShowSetupDialog}
projectOverview={projectOverview}
onProjectOverviewChange={setProjectOverview}
generateFeatures={generateFeatures}
onGenerateFeaturesChange={setGenerateFeatures}
analyzeProject={analyzeProject}
onAnalyzeProjectChange={setAnalyzeProject}
featureCount={featureCount}
onFeatureCountChange={setFeatureCount}
onCreateSpec={handleCreateInitialSpec}
onSkip={handleSkipSetup}
isCreatingSpec={isCreatingSpec}
showSkipButton={true}
title="Set Up Your Project"
description="We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification."
/>
<OnboardingDialog
open={showOnboardingDialog}
onOpenChange={setShowOnboardingDialog}
newProjectName={newProjectName}
onSkip={handleOnboardingSkip}
onGenerateSpec={handleOnboardingGenerateSpec}
/>
<OnboardingDialog
open={showOnboardingDialog}
onOpenChange={setShowOnboardingDialog}
newProjectName={newProjectName}
onSkip={handleOnboardingSkip}
onGenerateSpec={handleOnboardingGenerateSpec}
/>
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteProjectDialog}
onOpenChange={setShowDeleteProjectDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteProjectDialog}
onOpenChange={setShowDeleteProjectDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreatingProject}
/>
</aside>
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreatingProject}
/>
</aside>
</>
);
}

View File

@@ -35,11 +35,11 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
onClick={() => navigate({ to: '/dashboard' })}
data-testid="logo-button"
>
{/* Collapsed logo - shown when sidebar is closed OR on small screens when sidebar is open */}
{/* Collapsed logo - only shown when sidebar is closed */}
<div
className={cn(
'relative flex flex-col items-center justify-center rounded-lg gap-0.5',
sidebarOpen ? 'flex lg:hidden' : 'flex'
sidebarOpen ? 'hidden' : 'flex'
)}
>
<svg
@@ -90,16 +90,16 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
</span>
</div>
{/* Expanded logo - only shown when sidebar is open on large screens */}
{/* Expanded logo - shown when sidebar is open */}
{sidebarOpen && (
<div className="hidden lg:flex flex-col">
<div className="flex flex-col">
<div className="flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="automaker"
className="h-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out"
className="h-8 w-8 lg:h-[36.8px] lg:w-[36.8px] shrink-0 group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient
@@ -137,11 +137,11 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
<span className="font-bold text-foreground text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
<span className="font-bold text-foreground text-xl lg:text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
automaker<span className="text-brand-500">.</span>
</span>
</div>
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium ml-[38.8px]">
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium ml-9 lg:ml-[38.8px]">
v{appVersion} {versionSuffix}
</span>
</div>

View File

@@ -17,7 +17,9 @@ export function CollapseToggleButton({
<button
onClick={toggleSidebar}
className={cn(
'hidden lg:flex absolute top-[68px] -right-3 z-9999',
// Show on desktop always, show on mobile only when sidebar is open
sidebarOpen ? 'flex' : 'hidden lg:flex',
'absolute top-[68px] -right-3 z-9999',
'group/toggle items-center justify-center w-7 h-7 rounded-full',
// Glass morphism button
'bg-card/95 backdrop-blur-sm border border-border/80',

View File

@@ -117,7 +117,7 @@ export function ProjectSelectorWithOptions({
</div>
<div className="flex items-center gap-1.5">
<span
className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted text-muted-foreground"
className="hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted text-muted-foreground"
data-testid="project-picker-shortcut"
>
{formatShortcut(shortcuts.projectPicker, true)}
@@ -219,7 +219,7 @@ export function ProjectSelectorWithOptions({
<DropdownMenuTrigger asChild>
<button
className={cn(
'hidden lg:flex items-center justify-center w-[42px] h-[42px] rounded-lg',
'flex items-center justify-center w-[42px] h-[42px] rounded-lg',
'text-muted-foreground hover:text-foreground',
'bg-transparent hover:bg-accent/60',
'border border-border/50 hover:border-border',

View File

@@ -72,7 +72,7 @@ export function SidebarFooter({
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'hidden lg:block' : 'hidden'
sidebarOpen ? 'block' : 'hidden'
)}
>
Wiki
@@ -148,7 +148,7 @@ export function SidebarFooter({
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'hidden lg:block' : 'hidden'
sidebarOpen ? 'block' : 'hidden'
)}
>
Running Agents
@@ -157,7 +157,7 @@ export function SidebarFooter({
{sidebarOpen && runningAgentsCount > 0 && (
<span
className={cn(
'hidden lg:flex items-center justify-center',
'flex items-center justify-center',
'min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full',
'bg-brand-500 text-white shadow-sm',
'animate-in fade-in zoom-in duration-200',
@@ -227,7 +227,7 @@ export function SidebarFooter({
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'hidden lg:block' : 'hidden'
sidebarOpen ? 'block' : 'hidden'
)}
>
Settings
@@ -235,7 +235,7 @@ export function SidebarFooter({
{sidebarOpen && (
<span
className={cn(
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
'hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
isActiveRoute('settings')
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'

View File

@@ -20,9 +20,9 @@ export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) {
// Background gradient for depth
'bg-gradient-to-b from-transparent to-background/5',
'flex items-center',
sidebarOpen ? 'px-3 lg:px-5 justify-start' : 'px-3 justify-center',
// Add left padding on macOS to avoid overlapping with traffic light buttons (only when expanded)
isMac && sidebarOpen && 'pt-4 pl-20',
sidebarOpen ? 'px-4 lg:px-5 justify-start' : 'px-3 justify-center',
// Add padding on macOS to avoid overlapping with traffic light buttons
isMac && sidebarOpen && 'pt-4',
// Smaller top padding on macOS when collapsed
isMac && !sidebarOpen && 'pt-4'
)}

View File

@@ -35,7 +35,7 @@ export function SidebarNavigation({
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? 'mt-6' : ''}>
{/* Section Label */}
{section.label && sidebarOpen && (
<div className="hidden lg:block px-3 mb-2">
<div className="px-3 mb-2">
<span className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
{section.label}
</span>
@@ -115,7 +115,7 @@ export function SidebarNavigation({
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'hidden lg:block' : 'hidden'
sidebarOpen ? 'block' : 'hidden'
)}
>
{item.label}
@@ -124,7 +124,7 @@ export function SidebarNavigation({
{item.count !== undefined && item.count > 0 && sidebarOpen && (
<span
className={cn(
'hidden lg:flex items-center justify-center',
'flex items-center justify-center',
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
'bg-primary text-primary-foreground shadow-sm',
'animate-in fade-in zoom-in duration-200'
@@ -137,7 +137,7 @@ export function SidebarNavigation({
{item.shortcut && sidebarOpen && !item.count && (
<span
className={cn(
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
'hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
isActive
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'

View File

@@ -0,0 +1,276 @@
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
interface AnsiOutputProps {
text: string;
className?: string;
}
// ANSI color codes to CSS color mappings
const ANSI_COLORS: Record<number, string> = {
// Standard colors
30: '#6b7280', // Black (use gray for visibility on dark bg)
31: '#ef4444', // Red
32: '#22c55e', // Green
33: '#eab308', // Yellow
34: '#3b82f6', // Blue
35: '#a855f7', // Magenta
36: '#06b6d4', // Cyan
37: '#d1d5db', // White
// Bright colors
90: '#9ca3af', // Bright Black (Gray)
91: '#f87171', // Bright Red
92: '#4ade80', // Bright Green
93: '#facc15', // Bright Yellow
94: '#60a5fa', // Bright Blue
95: '#c084fc', // Bright Magenta
96: '#22d3ee', // Bright Cyan
97: '#ffffff', // Bright White
};
const ANSI_BG_COLORS: Record<number, string> = {
40: 'transparent',
41: '#ef4444',
42: '#22c55e',
43: '#eab308',
44: '#3b82f6',
45: '#a855f7',
46: '#06b6d4',
47: '#f3f4f6',
// Bright backgrounds
100: '#374151',
101: '#f87171',
102: '#4ade80',
103: '#facc15',
104: '#60a5fa',
105: '#c084fc',
106: '#22d3ee',
107: '#ffffff',
};
interface TextSegment {
text: string;
style: {
color?: string;
backgroundColor?: string;
fontWeight?: string;
fontStyle?: string;
textDecoration?: string;
};
}
/**
* Strip hyperlink escape sequences (OSC 8)
* Format: ESC]8;;url ESC\ text ESC]8;; ESC\
*/
function stripHyperlinks(text: string): string {
// Remove OSC 8 hyperlink sequences
// eslint-disable-next-line no-control-regex
return text.replace(/\x1b\]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
}
/**
* Strip other OSC sequences (title, etc.)
*/
function stripOtherOSC(text: string): string {
// Remove OSC sequences (ESC ] ... BEL or ESC ] ... ST)
// eslint-disable-next-line no-control-regex
return text.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
}
function parseAnsi(text: string): TextSegment[] {
// Pre-process: strip hyperlinks and other OSC sequences
let processedText = stripHyperlinks(text);
processedText = stripOtherOSC(processedText);
const segments: TextSegment[] = [];
// Match ANSI escape sequences: ESC[...m (SGR - Select Graphic Rendition)
// Also handle ESC[K (erase line) and other CSI sequences by stripping them
// The ESC character can be \x1b, \033, \u001b
// eslint-disable-next-line no-control-regex
const ansiRegex = /\x1b\[([0-9;]*)([a-zA-Z])/g;
let currentStyle: TextSegment['style'] = {};
let lastIndex = 0;
let match;
while ((match = ansiRegex.exec(processedText)) !== null) {
// Add text before this escape sequence
if (match.index > lastIndex) {
const content = processedText.slice(lastIndex, match.index);
if (content) {
segments.push({ text: content, style: { ...currentStyle } });
}
}
const params = match[1];
const command = match[2];
// Only process 'm' command (SGR - graphics/color)
// Ignore other commands like K (erase), H (cursor), J (clear), etc.
if (command === 'm') {
// Parse the escape sequence codes
const codes = params ? params.split(';').map((c) => parseInt(c, 10) || 0) : [0];
for (let i = 0; i < codes.length; i++) {
const code = codes[i];
if (code === 0) {
// Reset all attributes
currentStyle = {};
} else if (code === 1) {
// Bold
currentStyle.fontWeight = 'bold';
} else if (code === 2) {
// Dim/faint
currentStyle.color = 'var(--muted-foreground)';
} else if (code === 3) {
// Italic
currentStyle.fontStyle = 'italic';
} else if (code === 4) {
// Underline
currentStyle.textDecoration = 'underline';
} else if (code === 22) {
// Normal intensity (not bold, not dim)
currentStyle.fontWeight = undefined;
} else if (code === 23) {
// Not italic
currentStyle.fontStyle = undefined;
} else if (code === 24) {
// Not underlined
currentStyle.textDecoration = undefined;
} else if (code === 38) {
// Extended foreground color
if (codes[i + 1] === 5 && codes[i + 2] !== undefined) {
// 256 color mode: 38;5;n
const colorIndex = codes[i + 2];
currentStyle.color = get256Color(colorIndex);
i += 2;
} else if (codes[i + 1] === 2 && codes[i + 4] !== undefined) {
// RGB mode: 38;2;r;g;b
const r = codes[i + 2];
const g = codes[i + 3];
const b = codes[i + 4];
currentStyle.color = `rgb(${r}, ${g}, ${b})`;
i += 4;
}
} else if (code === 48) {
// Extended background color
if (codes[i + 1] === 5 && codes[i + 2] !== undefined) {
// 256 color mode: 48;5;n
const colorIndex = codes[i + 2];
currentStyle.backgroundColor = get256Color(colorIndex);
i += 2;
} else if (codes[i + 1] === 2 && codes[i + 4] !== undefined) {
// RGB mode: 48;2;r;g;b
const r = codes[i + 2];
const g = codes[i + 3];
const b = codes[i + 4];
currentStyle.backgroundColor = `rgb(${r}, ${g}, ${b})`;
i += 4;
}
} else if (ANSI_COLORS[code]) {
// Standard foreground color (30-37, 90-97)
currentStyle.color = ANSI_COLORS[code];
} else if (ANSI_BG_COLORS[code]) {
// Standard background color (40-47, 100-107)
currentStyle.backgroundColor = ANSI_BG_COLORS[code];
} else if (code === 39) {
// Default foreground
currentStyle.color = undefined;
} else if (code === 49) {
// Default background
currentStyle.backgroundColor = undefined;
}
}
}
lastIndex = match.index + match[0].length;
}
// Add remaining text after last escape sequence
if (lastIndex < processedText.length) {
const content = processedText.slice(lastIndex);
if (content) {
segments.push({ text: content, style: { ...currentStyle } });
}
}
// If no segments were created (no ANSI codes), return the whole text
if (segments.length === 0 && processedText) {
segments.push({ text: processedText, style: {} });
}
return segments;
}
/**
* Convert 256-color palette index to CSS color
*/
function get256Color(index: number): string {
// 0-15: Standard colors
if (index < 16) {
const standardColors = [
'#000000',
'#cd0000',
'#00cd00',
'#cdcd00',
'#0000ee',
'#cd00cd',
'#00cdcd',
'#e5e5e5',
'#7f7f7f',
'#ff0000',
'#00ff00',
'#ffff00',
'#5c5cff',
'#ff00ff',
'#00ffff',
'#ffffff',
];
return standardColors[index];
}
// 16-231: 6x6x6 color cube
if (index < 232) {
const n = index - 16;
const b = n % 6;
const g = Math.floor(n / 6) % 6;
const r = Math.floor(n / 36);
const toHex = (v: number) => (v === 0 ? 0 : 55 + v * 40);
return `rgb(${toHex(r)}, ${toHex(g)}, ${toHex(b)})`;
}
// 232-255: Grayscale
const gray = 8 + (index - 232) * 10;
return `rgb(${gray}, ${gray}, ${gray})`;
}
export function AnsiOutput({ text, className }: AnsiOutputProps) {
const segments = useMemo(() => parseAnsi(text), [text]);
return (
<pre
className={cn(
'font-mono text-xs whitespace-pre-wrap break-words text-muted-foreground',
className
)}
>
{segments.map((segment, index) => (
<span
key={index}
style={{
color: segment.style.color,
backgroundColor: segment.style.backgroundColor,
fontWeight: segment.style.fontWeight,
fontStyle: segment.style.fontStyle,
textDecoration: segment.style.textDecoration,
}}
>
{segment.text}
</span>
))}
</pre>
);
}

View File

@@ -6,6 +6,7 @@ import { getProviderFromModel } from '@/lib/utils';
const PROVIDER_ICON_KEYS = {
anthropic: 'anthropic',
openai: 'openai',
openrouter: 'openrouter',
cursor: 'cursor',
gemini: 'gemini',
grok: 'grok',
@@ -41,6 +42,12 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
path: 'M60.8734,57.2556v-14.9432c0-1.2586.4722-2.2029,1.5728-2.8314l30.0443-17.3023c4.0899-2.3593,8.9662-3.4599,13.9988-3.4599,18.8759,0,30.8307,14.6289,30.8307,30.2006,0,1.1007,0,2.3593-.158,3.6178l-31.1446-18.2467c-1.8872-1.1006-3.7754-1.1006-5.6629,0l-39.4812,22.9651ZM131.0276,115.4561v-35.7074c0-2.2028-.9446-3.7756-2.8318-4.8763l-39.481-22.9651,12.8982-7.3934c1.1007-.6285,2.0453-.6285,3.1458,0l30.0441,17.3024c8.6523,5.0341,14.4708,15.7296,14.4708,26.1107,0,11.9539-7.0769,22.965-18.2461,27.527v.0021ZM51.593,83.9964l-12.8982-7.5497c-1.1007-.6285-1.5728-1.5728-1.5728-2.8314v-34.6048c0-16.8303,12.8982-29.5722,30.3585-29.5722,6.607,0,12.7403,2.2029,17.9324,6.1349l-30.987,17.9324c-1.8871,1.1007-2.8314,2.6735-2.8314,4.8764v45.6159l-.0014-.0015ZM79.3562,100.0403l-18.4829-10.3811v-22.0209l18.4829-10.3811,18.4812,10.3811v22.0209l-18.4812,10.3811ZM91.2319,147.8591c-6.607,0-12.7403-2.2031-17.9324-6.1344l30.9866-17.9333c1.8872-1.1005,2.8318-2.6728,2.8318-4.8759v-45.616l13.0564,7.5498c1.1005.6285,1.5723,1.5728,1.5723,2.8314v34.6051c0,16.8297-13.0564,29.5723-30.5147,29.5723v.001ZM53.9522,112.7822l-30.0443-17.3024c-8.652-5.0343-14.471-15.7296-14.471-26.1107,0-12.1119,7.2356-22.9652,18.403-27.5272v35.8634c0,2.2028.9443,3.7756,2.8314,4.8763l39.3248,22.8068-12.8982,7.3938c-1.1007.6287-2.045.6287-3.1456,0ZM52.2229,138.5791c-17.7745,0-30.8306-13.3713-30.8306-29.8871,0-1.2585.1578-2.5169.3143-3.7754l30.987,17.9323c1.8871,1.1005,3.7757,1.1005,5.6628,0l39.4811-22.807v14.9435c0,1.2585-.4721,2.2021-1.5728,2.8308l-30.0443,17.3025c-4.0898,2.359-8.9662,3.4605-13.9989,3.4605h.0014ZM91.2319,157.296c19.0327,0,34.9188-13.5272,38.5383-31.4594,17.6164-4.562,28.9425-21.0779,28.9425-37.908,0-11.0112-4.719-21.7066-13.2133-29.4143.7867-3.3035,1.2595-6.607,1.2595-9.909,0-22.4929-18.2471-39.3247-39.3251-39.3247-4.2461,0-8.3363.6285-12.4262,2.045-7.0792-6.9213-16.8318-11.3254-27.5271-11.3254-19.0331,0-34.9191,13.5268-38.5384,31.4591C11.3255,36.0212,0,52.5373,0,69.3675c0,11.0112,4.7184,21.7065,13.2125,29.4142-.7865,3.3035-1.2586,6.6067-1.2586,9.9092,0,22.4923,18.2466,39.3241,39.3248,39.3241,4.2462,0,8.3362-.6277,12.426-2.0441,7.0776,6.921,16.8302,11.3251,27.5271,11.3251Z',
fill: '#74aa9c',
},
openrouter: {
viewBox: '0 0 24 24',
// OpenRouter logo from Simple Icons
path: 'M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z',
fill: '#94A3B8',
},
cursor: {
viewBox: '0 0 512 512',
// Official Cursor logo - hexagonal shape with triangular wedge
@@ -151,6 +158,10 @@ export function OpenAIIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.openai} {...props} />;
}
export function OpenRouterIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.openrouter} {...props} />;
}
export function CursorIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.cursor} {...props} />;
}
@@ -395,6 +406,11 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
const modelStr = typeof model === 'string' ? model.toLowerCase() : model;
// Check for Amazon Bedrock models first (amazon-bedrock/...)
if (modelStr.startsWith('openrouter/')) {
return 'openrouter';
}
// Check for Amazon Bedrock models first (amazon-bedrock/...)
if (modelStr.startsWith('amazon-bedrock/')) {
// Bedrock-hosted models - detect the specific provider
@@ -442,6 +458,71 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
return 'opencode';
}
// Check for dynamic OpenCode provider models (provider/model format)
// e.g., zai-coding-plan/glm-4.5, github-copilot/gpt-4o, google/gemini-2.5-pro
// Only handle strings with exactly one slash (not URLs or paths)
if (!modelStr.includes('://')) {
const slashIndex = modelStr.indexOf('/');
if (slashIndex !== -1 && slashIndex === modelStr.lastIndexOf('/')) {
const providerName = modelStr.slice(0, slashIndex);
const modelName = modelStr.slice(slashIndex + 1);
// Skip if either part is empty
if (providerName && modelName) {
// Check model name for known patterns
if (modelName.includes('glm')) {
return 'glm';
}
if (
modelName.includes('claude') ||
modelName.includes('sonnet') ||
modelName.includes('opus')
) {
return 'anthropic';
}
if (modelName.includes('gpt') || modelName.includes('o1') || modelName.includes('o3')) {
return 'openai';
}
if (modelName.includes('gemini')) {
return 'gemini';
}
if (modelName.includes('grok')) {
return 'grok';
}
if (modelName.includes('deepseek')) {
return 'deepseek';
}
if (modelName.includes('llama')) {
return 'meta';
}
if (modelName.includes('qwen')) {
return 'qwen';
}
if (modelName.includes('mistral')) {
return 'mistral';
}
// Check provider name for hints
if (providerName.includes('google')) {
return 'gemini';
}
if (providerName.includes('anthropic')) {
return 'anthropic';
}
if (providerName.includes('openai')) {
return 'openai';
}
if (providerName.includes('openrouter')) {
return 'openrouter';
}
if (providerName.includes('xai')) {
return 'grok';
}
// Default for unknown dynamic models
return 'opencode';
}
}
}
// Check for Cursor-specific models with underlying providers
if (modelStr.includes('sonnet') || modelStr.includes('opus') || modelStr.includes('claude')) {
return 'anthropic';
@@ -475,6 +556,7 @@ export function getProviderIconForModel(
const iconMap: Record<ProviderIconKey, ComponentType<{ className?: string }>> = {
anthropic: AnthropicIcon,
openai: OpenAIIcon,
openrouter: OpenRouterIcon,
cursor: CursorIcon,
gemini: GeminiIcon,
grok: GrokIcon,

View File

@@ -0,0 +1,142 @@
import CodeMirror from '@uiw/react-codemirror';
import { StreamLanguage } from '@codemirror/language';
import { shell } from '@codemirror/legacy-modes/mode/shell';
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 ShellSyntaxEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
minHeight?: string;
maxHeight?: string;
'data-testid'?: string;
}
// Syntax highlighting using CSS variables for theme compatibility
const syntaxColors = HighlightStyle.define([
// Keywords (if, then, else, fi, for, while, do, done, case, esac, etc.)
{ tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
// Strings (single and double quoted)
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
// Comments
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
// Variables ($VAR, ${VAR})
{ tag: t.variableName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
// Operators
{ tag: t.operator, color: 'var(--muted-foreground)' },
// Numbers
{ tag: t.number, color: 'var(--chart-3, oklch(0.7 0.15 150))' },
// Function names / commands
{ tag: t.function(t.variableName), color: 'var(--primary)' },
{ tag: t.attributeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
// Default text
{ tag: t.content, color: 'var(--foreground)' },
]);
// Editor theme using CSS variables
const editorTheme = EditorView.theme({
'&': {
height: '100%',
fontSize: '0.875rem',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
backgroundColor: 'transparent',
color: 'var(--foreground)',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
},
'.cm-content': {
padding: '0.75rem',
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: 'var(--accent)',
opacity: '0.3',
},
'.cm-line': {
padding: '0 0.25rem',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-gutters': {
backgroundColor: 'transparent',
color: 'var(--muted-foreground)',
border: 'none',
paddingRight: '0.5rem',
},
'.cm-lineNumbers .cm-gutterElement': {
minWidth: '2rem',
textAlign: 'right',
paddingRight: '0.5rem',
},
'.cm-placeholder': {
color: 'var(--muted-foreground)',
fontStyle: 'italic',
},
});
// Combine all extensions
const extensions: Extension[] = [
StreamLanguage.define(shell),
syntaxHighlighting(syntaxColors),
editorTheme,
];
export function ShellSyntaxEditor({
value,
onChange,
placeholder,
className,
minHeight = '200px',
maxHeight,
'data-testid': testId,
}: ShellSyntaxEditorProps) {
return (
<div
className={cn('w-full rounded-lg border border-border bg-muted/30', className)}
style={{ minHeight }}
data-testid={testId}
>
<CodeMirror
value={value}
onChange={onChange}
extensions={extensions}
theme="none"
placeholder={placeholder}
height={maxHeight}
minHeight={minHeight}
className="[&_.cm-editor]:min-h-[inherit]"
basicSetup={{
lineNumbers: true,
foldGutter: false,
highlightActiveLine: true,
highlightSelectionMatches: true,
autocompletion: false,
bracketMatching: true,
indentOnInput: true,
}}
/>
</div>
);
}

View File

@@ -75,6 +75,8 @@ import {
} from './board-view/hooks';
import { SelectionActionBar } from './board-view/components';
import { MassEditDialog } from './board-view/dialogs';
import { InitScriptIndicator } from './board-view/init-script-indicator';
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
@@ -99,6 +101,8 @@ export function BoardView() {
useWorktrees,
enableDependencyBlocking,
skipVerificationInAutoMode,
planUseSelectedWorktreeBranch,
addFeatureUseSelectedWorktreeBranch,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
setPipelineConfig,
@@ -107,6 +111,12 @@ export function BoardView() {
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
// Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes
const showInitScriptIndicatorByProject = useAppStore(
(state) => state.showInitScriptIndicatorByProject
);
const getShowInitScriptIndicator = useAppStore((state) => state.getShowInitScriptIndicator);
const getDefaultDeleteBranch = useAppStore((state) => state.getDefaultDeleteBranch);
const shortcuts = useKeyboardShortcutsConfig();
const {
features: hookFeatures,
@@ -161,12 +171,14 @@ export function BoardView() {
followUpPrompt,
followUpImagePaths,
followUpPreviewMap,
followUpPromptHistory,
setShowFollowUpDialog,
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setFollowUpPreviewMap,
handleFollowUpDialogChange,
addToPromptHistory,
} = useFollowUpState();
// Selection mode hook for mass editing
@@ -250,6 +262,9 @@ export function BoardView() {
// Window state hook for compact dialog mode
const { isMaximized } = useWindowState();
// Init script events hook - subscribe to worktree init script events
useInitScriptEvents(currentProject?.path ?? null);
// Keyboard shortcuts hook will be initialized after actions hook
// Prevent hydration issues
@@ -1287,6 +1302,8 @@ export function BoardView() {
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
isDragging={activeFeature !== null}
onAiSuggest={() => setShowPlanDialog(true)}
/>
</div>
@@ -1357,6 +1374,14 @@ export function BoardView() {
isMaximized={isMaximized}
parentFeature={spawnParentFeature}
allFeatures={hookFeatures}
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
selectedNonMainWorktreeBranch={
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
? currentWorktreeBranch || undefined
: undefined
}
// When the worktree setting is disabled, force 'current' branch mode
forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch}
/>
{/* Edit Feature Dialog */}
@@ -1422,6 +1447,8 @@ export function BoardView() {
onPreviewMapChange={setFollowUpPreviewMap}
onSend={handleSendFollowUp}
isMaximized={isMaximized}
promptHistory={followUpPromptHistory}
onHistoryAdd={addToPromptHistory}
/>
{/* Backlog Plan Dialog */}
@@ -1434,6 +1461,7 @@ export function BoardView() {
setPendingPlanResult={setPendingBacklogPlan}
isGeneratingPlan={isGeneratingPlan}
setIsGeneratingPlan={setIsGeneratingPlan}
currentBranch={planUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined}
/>
{/* Plan Approval Dialog */}
@@ -1501,6 +1529,7 @@ export function BoardView() {
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
: 0
}
defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)}
onDeleted={(deletedWorktree, _deletedBranch) => {
// Reset features that were assigned to the deleted worktree (by branch)
hookFeatures.forEach((feature) => {
@@ -1568,6 +1597,11 @@ export function BoardView() {
setSelectedWorktreeForAction(null);
}}
/>
{/* Init Script Indicator - floating overlay for worktree init script status */}
{getShowInitScriptIndicator(currentProject.path) && (
<InitScriptIndicator projectPath={currentProject.path} />
)}
</div>
);
}

View File

@@ -9,6 +9,8 @@ import { UsagePopover } from '@/components/usage-popover';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog';
import { PlanSettingsDialog } from './dialogs/plan-settings-dialog';
import { getHttpApiClient } from '@/lib/http-api-client';
import { BoardSearchBar } from './board-search-bar';
import { BoardControls } from './board-controls';
@@ -55,10 +57,22 @@ export function BoardHeader({
completedCount,
}: BoardHeaderProps) {
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
const [showWorktreeSettings, setShowWorktreeSettings] = useState(false);
const [showPlanSettings, setShowPlanSettings] = useState(false);
const apiKeys = useAppStore((state) => state.apiKeys);
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
const planUseSelectedWorktreeBranch = useAppStore((state) => state.planUseSelectedWorktreeBranch);
const setPlanUseSelectedWorktreeBranch = useAppStore(
(state) => state.setPlanUseSelectedWorktreeBranch
);
const addFeatureUseSelectedWorktreeBranch = useAppStore(
(state) => state.addFeatureUseSelectedWorktreeBranch
);
const setAddFeatureUseSelectedWorktreeBranch = useAppStore(
(state) => state.setAddFeatureUseSelectedWorktreeBranch
);
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
// Worktree panel visibility (per-project)
@@ -132,9 +146,25 @@ export function BoardHeader({
onCheckedChange={handleWorktreePanelToggle}
data-testid="worktrees-toggle"
/>
<button
onClick={() => setShowWorktreeSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Worktree Settings"
data-testid="worktree-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
)}
{/* Worktree Settings Dialog */}
<WorktreeSettingsDialog
open={showWorktreeSettings}
onOpenChange={setShowWorktreeSettings}
addFeatureUseSelectedWorktreeBranch={addFeatureUseSelectedWorktreeBranch}
onAddFeatureUseSelectedWorktreeBranchChange={setAddFeatureUseSelectedWorktreeBranch}
/>
{/* Concurrency Control - only show after mount to prevent hydration issues */}
{isMounted && (
<Popover>
@@ -209,15 +239,33 @@ export function BoardHeader({
onSkipVerificationChange={setSkipVerificationInAutoMode}
/>
<Button
size="sm"
variant="outline"
onClick={onOpenPlanDialog}
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 mr-2" />
Plan
</Button>
{/* Plan Button with Settings */}
<div className={controlContainerClass} data-testid="plan-button-container">
<button
onClick={onOpenPlanDialog}
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Plan</span>
</button>
<button
onClick={() => setShowPlanSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Plan Settings"
data-testid="plan-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* Plan Settings Dialog */}
<PlanSettingsDialog
open={showPlanSettings}
onOpenChange={setShowPlanSettings}
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
onPlanUseSelectedWorktreeBranchChange={setPlanUseSelectedWorktreeBranch}
/>
</div>
</div>
);

View File

@@ -0,0 +1,120 @@
import { memo } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Kbd } from '@/components/ui/kbd';
import { formatShortcut } from '@/store/app-store';
import { getEmptyStateConfig, type EmptyStateConfig } from '../constants';
import { Lightbulb, Play, Clock, CheckCircle2, Sparkles, Wand2 } from 'lucide-react';
const ICON_MAP = {
lightbulb: Lightbulb,
play: Play,
clock: Clock,
check: CheckCircle2,
sparkles: Sparkles,
} as const;
interface EmptyStateCardProps {
columnId: string;
columnTitle?: string;
/** Keyboard shortcut for adding features (from settings) */
addFeatureShortcut?: string;
/** Whether the column is empty due to active filters */
isFilteredEmpty?: boolean;
/** Whether we're in read-only mode (hide actions) */
isReadOnly?: boolean;
/** Called when user clicks "Use AI Suggestions" */
onAiSuggest?: () => void;
/** Card opacity (matches board settings) */
opacity?: number;
/** Enable glassmorphism effect */
glassmorphism?: boolean;
/** Custom config override for pipeline steps */
customConfig?: Partial<EmptyStateConfig>;
}
export const EmptyStateCard = memo(function EmptyStateCard({
columnId,
addFeatureShortcut,
isFilteredEmpty = false,
isReadOnly = false,
onAiSuggest,
customConfig,
}: EmptyStateCardProps) {
// Get base config and merge with custom overrides
const baseConfig = getEmptyStateConfig(columnId);
const config: EmptyStateConfig = { ...baseConfig, ...customConfig };
const IconComponent = ICON_MAP[config.icon];
const showActions = !isReadOnly && !isFilteredEmpty;
const showShortcut = columnId === 'backlog' && addFeatureShortcut && showActions;
// Action button handler
const handlePrimaryAction = () => {
if (!config.primaryAction) return;
if (config.primaryAction.actionType === 'ai-suggest') {
onAiSuggest?.();
}
};
return (
<div
className={cn(
'w-full h-full min-h-[200px] flex-1',
'flex flex-col items-center justify-center',
'text-center px-4',
'transition-all duration-300 ease-out',
'animate-in fade-in duration-300',
'group'
)}
data-testid={`empty-state-card-${columnId}`}
>
{/* Icon */}
<div className="mb-3 text-muted-foreground/30">
<IconComponent className="w-8 h-8" />
</div>
{/* Title */}
<h4 className="font-medium text-sm text-muted-foreground/50 mb-1">
{isFilteredEmpty ? 'No Matching Items' : config.title}
</h4>
{/* Description */}
<p className="text-xs text-muted-foreground/40 leading-relaxed max-w-[180px]">
{isFilteredEmpty ? 'No features match your current filters.' : config.description}
</p>
{/* Keyboard shortcut hint for backlog */}
{showShortcut && (
<div className="flex items-center gap-1.5 mt-3 text-muted-foreground/40">
<span className="text-xs">Press</span>
<Kbd className="bg-muted/30 border-0 px-1.5 py-0.5 text-[10px] text-muted-foreground/50">
{formatShortcut(addFeatureShortcut, true)}
</Kbd>
<span className="text-xs">to add</span>
</div>
)}
{/* AI Suggest action for backlog */}
{showActions && config.primaryAction && config.primaryAction.actionType === 'ai-suggest' && (
<Button
variant="ghost"
size="sm"
className="mt-4 h-7 text-xs text-muted-foreground/50 hover:text-muted-foreground/70"
onClick={handlePrimaryAction}
data-testid={`empty-state-primary-action-${columnId}`}
>
<Wand2 className="w-3 h-3 mr-1.5" />
{config.primaryAction.label}
</Button>
)}
{/* Filtered empty state hint */}
{isFilteredEmpty && (
<p className="text-[10px] mt-2 text-muted-foreground/30 italic">
Clear filters to see all items
</p>
)}
</div>
);
});

View File

@@ -1,3 +1,4 @@
export { KanbanCard } from './kanban-card/kanban-card';
export { KanbanColumn } from './kanban-column';
export { SelectionActionBar } from './selection-action-bar';
export { EmptyStateCard } from './empty-state-card';

View File

@@ -3,6 +3,69 @@ import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types
export type ColumnId = Feature['status'];
/**
* Empty state configuration for each column type
*/
export interface EmptyStateConfig {
title: string;
description: string;
icon: 'lightbulb' | 'play' | 'clock' | 'check' | 'sparkles';
shortcutKey?: string; // Keyboard shortcut label (e.g., 'N', 'A')
shortcutHint?: string; // Human-readable shortcut hint
primaryAction?: {
label: string;
actionType: 'ai-suggest' | 'none';
};
}
/**
* Default empty state configurations per column type
*/
export const EMPTY_STATE_CONFIGS: Record<string, EmptyStateConfig> = {
backlog: {
title: 'Ready for Ideas',
description:
'Add your first feature idea to get started using the button below, or let AI help generate ideas.',
icon: 'lightbulb',
shortcutHint: 'Press',
primaryAction: {
label: 'Use AI Suggestions',
actionType: 'none',
},
},
in_progress: {
title: 'Nothing in Progress',
description: 'Drag a feature from the backlog here or click implement to start working on it.',
icon: 'play',
},
waiting_approval: {
title: 'No Items Awaiting Approval',
description: 'Features will appear here after implementation is complete and need your review.',
icon: 'clock',
},
verified: {
title: 'No Verified Features',
description: 'Approved features will appear here. They can then be completed and archived.',
icon: 'check',
},
// Pipeline step default configuration
pipeline_default: {
title: 'Pipeline Step Empty',
description: 'Features will flow through this step during the automated pipeline process.',
icon: 'sparkles',
},
};
/**
* Get empty state config for a column, with fallback for pipeline columns
*/
export function getEmptyStateConfig(columnId: string): EmptyStateConfig {
if (columnId.startsWith('pipeline_')) {
return EMPTY_STATE_CONFIGS.pipeline_default;
}
return EMPTY_STATE_CONFIGS[columnId] || EMPTY_STATE_CONFIGS.default;
}
export interface Column {
id: FeatureStatusWithPipeline;
title: string;

View File

@@ -21,11 +21,9 @@ import {
FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Sparkles, ChevronDown, ChevronRight, Play, Cpu, FolderKanban } from 'lucide-react';
import { Play, Cpu, FolderKanban } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils';
import {
useAppStore,
@@ -43,16 +41,12 @@ import {
WorkModeSelector,
PlanningModeSelect,
AncestorContextSection,
EnhanceWithAI,
EnhancementHistoryButton,
type BaseHistoryEntry,
} from '../shared';
import type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
getAncestors,
@@ -62,6 +56,32 @@ import {
const logger = createLogger('AddFeatureDialog');
/**
* Determines the default work mode based on global settings and current worktree selection.
*
* Priority:
* 1. If forceCurrentBranchMode is true, always defaults to 'current' (work on current branch)
* 2. If a non-main worktree is selected in the board header, defaults to 'custom' (use that branch)
* 3. If useWorktrees global setting is enabled, defaults to 'auto' (automatic worktree creation)
* 4. Otherwise, defaults to 'current' (work on current branch without isolation)
*/
const getDefaultWorkMode = (
useWorktrees: boolean,
selectedNonMainWorktreeBranch?: string,
forceCurrentBranchMode?: boolean
): WorkMode => {
// If force current branch mode is enabled (worktree setting is off), always use 'current'
if (forceCurrentBranchMode) {
return 'current';
}
// If a non-main worktree is selected, default to 'custom' mode with that branch
if (selectedNonMainWorktreeBranch) {
return 'custom';
}
// Otherwise, respect the global worktree setting
return useWorktrees ? 'auto' : 'current';
};
type FeatureData = {
title: string;
category: string;
@@ -95,6 +115,23 @@ interface AddFeatureDialogProps {
isMaximized: boolean;
parentFeature?: Feature | null;
allFeatures?: Feature[];
/**
* When a non-main worktree is selected in the board header, this will be set to that worktree's branch.
* When set, the dialog will default to 'custom' work mode with this branch pre-filled.
*/
selectedNonMainWorktreeBranch?: string;
/**
* When true, forces the dialog to default to 'current' work mode (work on current branch).
* This is used when the "Use selected worktree branch" setting is disabled.
*/
forceCurrentBranchMode?: boolean;
}
/**
* A single entry in the description history
*/
interface DescriptionHistoryEntry extends BaseHistoryEntry {
description: string;
}
export function AddFeatureDialog({
@@ -111,6 +148,8 @@ export function AddFeatureDialog({
isMaximized,
parentFeature = null,
allFeatures = [],
selectedNonMainWorktreeBranch,
forceCurrentBranchMode,
}: AddFeatureDialogProps) {
const isSpawnMode = !!parentFeature;
const [workMode, setWorkMode] = useState<WorkMode>('current');
@@ -139,21 +178,16 @@ export function AddFeatureDialog({
// UI state
const [previewMap, setPreviewMap] = useState<ImagePreviewMap>(() => new Map());
const [descriptionError, setDescriptionError] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<
'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
>('improve');
const [enhanceOpen, setEnhanceOpen] = useState(false);
// Description history state
const [descriptionHistory, setDescriptionHistory] = useState<DescriptionHistoryEntry[]>([]);
// Spawn mode state
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
// Get defaults from store
const { defaultPlanningMode, defaultRequirePlanApproval } = useAppStore();
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
// Track previous open state to detect when dialog opens
const wasOpenRef = useRef(false);
@@ -165,12 +199,19 @@ export function AddFeatureDialog({
if (justOpened) {
setSkipTests(defaultSkipTests);
setBranchName(defaultBranch || '');
setWorkMode('current');
// When a non-main worktree is selected, use its branch name for custom mode
// Otherwise, use the default branch
setBranchName(selectedNonMainWorktreeBranch || defaultBranch || '');
setWorkMode(
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setModelEntry({ model: 'opus' });
// Initialize description history (empty for new feature)
setDescriptionHistory([]);
// Initialize ancestors for spawn mode
if (parentFeature) {
const ancestorList = getAncestors(parentFeature, allFeatures);
@@ -187,6 +228,9 @@ export function AddFeatureDialog({
defaultBranch,
defaultPlanningMode,
defaultRequirePlanApproval,
useWorktrees,
selectedNonMainWorktreeBranch,
forceCurrentBranchMode,
parentFeature,
allFeatures,
]);
@@ -271,15 +315,18 @@ export function AddFeatureDialog({
setImagePaths([]);
setTextFilePaths([]);
setSkipTests(defaultSkipTests);
setBranchName('');
// When a non-main worktree is selected, use its branch name for custom mode
setBranchName(selectedNonMainWorktreeBranch || '');
setPriority(2);
setModelEntry({ model: 'opus' });
setWorkMode('current');
setWorkMode(
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setPreviewMap(new Map());
setDescriptionError(false);
setEnhanceOpen(false);
setDescriptionHistory([]);
onOpenChange(false);
};
@@ -302,33 +349,6 @@ export function AddFeatureDialog({
}
};
const handleEnhanceDescription = async () => {
if (!description.trim() || isEnhancing) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
description,
enhancementMode,
enhancementOverride.effectiveModel,
enhancementOverride.effectiveModelEntry.thinkingLevel
);
if (result?.success && result.enhancedText) {
setDescription(result.enhancedText);
toast.success('Description enhanced!');
} else {
toast.error(result?.error || 'Failed to enhance description');
}
} catch (error) {
logger.error('Enhancement failed:', error);
toast.error('Failed to enhance description');
} finally {
setIsEnhancing(false);
}
};
// Shared card styling
const cardClass = 'rounded-lg border border-border/50 bg-muted/30 p-4 space-y-3';
const sectionHeaderClass = 'flex items-center gap-2 text-sm font-medium text-foreground';
@@ -380,7 +400,18 @@ export function AddFeatureDialog({
{/* Task Details Section */}
<div className={cardClass}>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<div className="flex items-center justify-between">
<Label htmlFor="description">Description</Label>
{/* Version History Button */}
<EnhancementHistoryButton
history={descriptionHistory}
currentValue={description}
onRestore={setDescription}
valueAccessor={(entry) => entry.description}
title="Version History"
restoreMessage="Description restored from history"
/>
</div>
<DescriptionImageDropZone
value={description}
onChange={(value) => {
@@ -409,75 +440,35 @@ export function AddFeatureDialog({
/>
</div>
{/* Collapsible Enhancement Section */}
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}>
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1">
{enhanceOpen ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
<Sparkles className="w-4 h-4" />
<span>Enhance with AI</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="flex flex-wrap items-center gap-2 pl-6">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-xs">
{enhancementMode === 'improve' && 'Improve Clarity'}
{enhancementMode === 'technical' && 'Add Technical Details'}
{enhancementMode === 'simplify' && 'Simplify'}
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
{enhancementMode === 'ux-reviewer' && 'User Experience'}
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
Simplify
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
Add Acceptance Criteria
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('ux-reviewer')}>
User Experience
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="default"
size="sm"
className="h-8 text-xs"
onClick={handleEnhanceDescription}
disabled={!description.trim() || isEnhancing}
loading={isEnhancing}
>
<Sparkles className="w-3 h-3 mr-1" />
Enhance
</Button>
<ModelOverrideTrigger
currentModelEntry={enhancementOverride.effectiveModelEntry}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
</CollapsibleContent>
</Collapsible>
{/* Enhancement Section */}
<EnhanceWithAI
value={description}
onChange={setDescription}
onHistoryAdd={({ mode, originalText, enhancedText }) => {
const timestamp = new Date().toISOString();
setDescriptionHistory((prev) => {
const newHistory = [...prev];
// Add original text first (so user can restore to pre-enhancement state)
// Only add if it's different from the last entry to avoid duplicates
const lastEntry = prev[prev.length - 1];
if (!lastEntry || lastEntry.description !== originalText) {
newHistory.push({
description: originalText,
timestamp,
source: prev.length === 0 ? 'initial' : 'edit',
});
}
// Add enhanced text
newHistory.push({
description: enhancedText,
timestamp,
source: 'enhance',
enhancementMode: mode,
});
return newHistory;
});
}}
/>
</div>
{/* AI & Execution Section */}

View File

@@ -63,6 +63,8 @@ interface BacklogPlanDialogProps {
setPendingPlanResult: (result: BacklogPlanResult | null) => void;
isGeneratingPlan: boolean;
setIsGeneratingPlan: (generating: boolean) => void;
// Branch to use for created features (defaults to 'main' when applying)
currentBranch?: string;
}
type DialogMode = 'input' | 'review' | 'applying';
@@ -76,6 +78,7 @@ export function BacklogPlanDialog({
setPendingPlanResult,
isGeneratingPlan,
setIsGeneratingPlan,
currentBranch,
}: BacklogPlanDialogProps) {
const [mode, setMode] = useState<DialogMode>('input');
const [prompt, setPrompt] = useState('');
@@ -167,7 +170,11 @@ export function BacklogPlanDialog({
}) || [],
};
const result = await api.backlogPlan.apply(projectPath, filteredPlanResult);
const result = await api.backlogPlan.apply(
projectPath,
filteredPlanResult,
currentBranch ?? 'main'
);
if (result.success) {
toast.success(`Applied ${result.appliedChanges?.length || 0} changes`);
setPendingPlanResult(null);
@@ -184,6 +191,7 @@ export function BacklogPlanDialog({
setPendingPlanResult,
onPlanApplied,
onClose,
currentBranch,
]);
const handleDiscard = useCallback(() => {

View File

@@ -10,10 +10,73 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { GitBranch, Loader2 } from 'lucide-react';
import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
/**
* Parse git/worktree error messages and return user-friendly versions
*/
function parseWorktreeError(error: string): { title: string; description?: string } {
const errorLower = error.toLowerCase();
// Worktree already exists
if (errorLower.includes('already exists') && errorLower.includes('worktree')) {
return {
title: 'A worktree with this name already exists',
description: 'Try a different branch name or delete the existing worktree first.',
};
}
// Branch already checked out in another worktree
if (
errorLower.includes('already checked out') ||
errorLower.includes('is already used by worktree')
) {
return {
title: 'This branch is already in use',
description: 'The branch is checked out in another worktree. Use a different branch name.',
};
}
// Branch name conflicts with existing branch
if (errorLower.includes('already exists') && errorLower.includes('branch')) {
return {
title: 'A branch with this name already exists',
description: 'The worktree will use the existing branch, or try a different name.',
};
}
// Not a git repository
if (errorLower.includes('not a git repository')) {
return {
title: 'Not a git repository',
description: 'Initialize git in this project first with "git init".',
};
}
// Lock file exists (another git operation in progress)
if (errorLower.includes('.lock') || errorLower.includes('lock file')) {
return {
title: 'Another git operation is in progress',
description: 'Wait for it to complete or remove stale lock files.',
};
}
// Permission denied
if (errorLower.includes('permission denied') || errorLower.includes('access denied')) {
return {
title: 'Permission denied',
description: 'Check file permissions for the project directory.',
};
}
// Default: return original error but cleaned up
return {
title: error.replace(/^(fatal|error):\s*/i, '').split('\n')[0],
};
}
interface CreatedWorktreeInfo {
path: string;
branch: string;
@@ -34,20 +97,21 @@ export function CreateWorktreeDialog({
}: CreateWorktreeDialogProps) {
const [branchName, setBranchName] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<{ title: string; description?: string } | null>(null);
const handleCreate = async () => {
if (!branchName.trim()) {
setError('Branch name is required');
setError({ title: 'Branch name is required' });
return;
}
// Validate branch name (git-compatible)
const validBranchRegex = /^[a-zA-Z0-9._/-]+$/;
if (!validBranchRegex.test(branchName)) {
setError(
'Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes.'
);
setError({
title: 'Invalid branch name',
description: 'Use only letters, numbers, dots, underscores, hyphens, and slashes.',
});
return;
}
@@ -57,7 +121,7 @@ export function CreateWorktreeDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.create) {
setError('Worktree API not available');
setError({ title: 'Worktree API not available' });
return;
}
const result = await api.worktree.create(projectPath, branchName);
@@ -70,10 +134,12 @@ export function CreateWorktreeDialog({
onOpenChange(false);
setBranchName('');
} else {
setError(result.error || 'Failed to create worktree');
setError(parseWorktreeError(result.error || 'Failed to create worktree'));
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create worktree');
setError(
parseWorktreeError(err instanceof Error ? err.message : 'Failed to create worktree')
);
} finally {
setIsLoading(false);
}
@@ -114,7 +180,17 @@ export function CreateWorktreeDialog({
className="font-mono text-sm"
autoFocus
/>
{error && <p className="text-sm text-destructive">{error}</p>}
{error && (
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">{error.title}</p>
{error.description && (
<p className="text-xs text-destructive/80">{error.description}</p>
)}
</div>
</div>
)}
</div>
<div className="text-xs text-muted-foreground space-y-1">

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -30,6 +30,8 @@ interface DeleteWorktreeDialogProps {
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
/** Number of features assigned to this worktree's branch */
affectedFeatureCount?: number;
/** Default value for the "delete branch" checkbox */
defaultDeleteBranch?: boolean;
}
export function DeleteWorktreeDialog({
@@ -39,10 +41,18 @@ export function DeleteWorktreeDialog({
worktree,
onDeleted,
affectedFeatureCount = 0,
defaultDeleteBranch = false,
}: DeleteWorktreeDialogProps) {
const [deleteBranch, setDeleteBranch] = useState(false);
const [deleteBranch, setDeleteBranch] = useState(defaultDeleteBranch);
const [isLoading, setIsLoading] = useState(false);
// Reset deleteBranch to default when dialog opens
useEffect(() => {
if (open) {
setDeleteBranch(defaultDeleteBranch);
}
}, [open, defaultDeleteBranch]);
const handleDelete = async () => {
if (!worktree) return;

View File

@@ -21,18 +21,8 @@ import {
FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
Sparkles,
ChevronDown,
ChevronRight,
GitBranch,
History,
Cpu,
FolderKanban,
} from 'lucide-react';
import { GitBranch, Cpu, FolderKanban } from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { cn, modelSupportsThinking } from '@/lib/utils';
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
@@ -41,17 +31,12 @@ import {
PrioritySelector,
WorkModeSelector,
PlanningModeSelect,
EnhanceWithAI,
EnhancementHistoryButton,
type EnhancementMode,
} from '../shared';
import type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { DependencyTreeDialog } from './dependency-tree-dialog';
import { isClaudeModel, supportsReasoningEffort } from '@automaker/types';
@@ -79,7 +64,8 @@ interface EditFeatureDialogProps {
requirePlanApproval: boolean;
},
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
enhancementMode?: EnhancementMode,
preEnhancementDescription?: string
) => void;
categorySuggestions: string[];
branchSuggestions: string[];
@@ -110,11 +96,6 @@ export function EditFeatureDialog({
const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState<ImagePreviewMap>(
() => new Map()
);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<
'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
>('improve');
const [enhanceOpen, setEnhanceOpen] = useState(false);
const [showDependencyTree, setShowDependencyTree] = useState(false);
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(
@@ -133,15 +114,16 @@ export function EditFeatureDialog({
// Track the source of description changes for history
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
{ source: 'enhance'; mode: 'improve' | 'technical' | 'simplify' | 'acceptance' } | 'edit' | null
{ source: 'enhance'; mode: EnhancementMode } | 'edit' | null
>(null);
// Track the original description when the dialog opened for comparison
const [originalDescription, setOriginalDescription] = useState(feature?.description ?? '');
// Track if history dropdown is open
const [showHistory, setShowHistory] = useState(false);
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
// Track the description before enhancement (so it can be restored)
const [preEnhancementDescription, setPreEnhancementDescription] = useState<string | null>(null);
// Local history state for real-time display (combines persisted + session history)
const [localHistory, setLocalHistory] = useState<DescriptionHistoryEntry[]>(
feature?.descriptionHistory ?? []
);
useEffect(() => {
setEditingFeature(feature);
@@ -153,8 +135,8 @@ export function EditFeatureDialog({
// Reset history tracking state
setOriginalDescription(feature.description ?? '');
setDescriptionChangeSource(null);
setShowHistory(false);
setEnhanceOpen(false);
setPreEnhancementDescription(null);
setLocalHistory(feature.descriptionHistory ?? []);
// Reset model entry
setModelEntry({
model: (feature.model as ModelAlias) || 'opus',
@@ -164,7 +146,8 @@ export function EditFeatureDialog({
} else {
setEditFeaturePreviewMap(new Map());
setDescriptionChangeSource(null);
setShowHistory(false);
setPreEnhancementDescription(null);
setLocalHistory([]);
}
}, [feature]);
@@ -226,7 +209,13 @@ export function EditFeatureDialog({
}
}
onUpdate(editingFeature.id, updates, historySource, historyEnhancementMode);
onUpdate(
editingFeature.id,
updates,
historySource,
historyEnhancementMode,
preEnhancementDescription ?? undefined
);
setEditFeaturePreviewMap(new Map());
onClose();
};
@@ -237,36 +226,6 @@ export function EditFeatureDialog({
}
};
const handleEnhanceDescription = async () => {
if (!editingFeature?.description.trim() || isEnhancing) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
editingFeature.description,
enhancementMode,
enhancementOverride.effectiveModel, // API accepts string, extract from PhaseModelEntry
enhancementOverride.effectiveModelEntry.thinkingLevel // Pass thinking level
);
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setEditingFeature((prev) => (prev ? { ...prev, description: enhancedText } : prev));
// Track that this change was from enhancement
setDescriptionChangeSource({ source: 'enhance', mode: enhancementMode });
toast.success('Description enhanced!');
} else {
toast.error(result?.error || 'Failed to enhance description');
}
} catch (error) {
logger.error('Enhancement failed:', error);
toast.error('Failed to enhance description');
} finally {
setIsEnhancing(false);
}
};
if (!editingFeature) {
return null;
}
@@ -304,95 +263,18 @@ export function EditFeatureDialog({
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="edit-description">Description</Label>
{/* Version History Button */}
{feature?.descriptionHistory && feature.descriptionHistory.length > 0 && (
<Popover open={showHistory} onOpenChange={setShowHistory}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-muted-foreground"
>
<History className="w-3.5 h-3.5" />
History ({feature.descriptionHistory.length})
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="p-3 border-b">
<h4 className="font-medium text-sm">Version History</h4>
<p className="text-xs text-muted-foreground mt-1">
Click a version to restore it
</p>
</div>
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{[...(feature.descriptionHistory || [])]
.reverse()
.map((entry: DescriptionHistoryEntry, index: number) => {
const isCurrentVersion =
entry.description === editingFeature.description;
const date = new Date(entry.timestamp);
const formattedDate = date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const getEnhancementModeLabel = (mode?: string) => {
const labels: Record<string, string> = {
improve: 'Improve Clarity',
technical: 'Add Technical Details',
simplify: 'Simplify',
acceptance: 'Add Acceptance Criteria',
'ux-reviewer': 'User Experience',
};
return labels[mode || 'improve'] || mode || 'improve';
};
const sourceLabel =
entry.source === 'initial'
? 'Original'
: entry.source === 'enhance'
? `Enhanced (${getEnhancementModeLabel(entry.enhancementMode)})`
: 'Edited';
return (
<button
key={`${entry.timestamp}-${index}`}
onClick={() => {
setEditingFeature((prev) =>
prev ? { ...prev, description: entry.description } : prev
);
// Mark as edit since user is restoring from history
setDescriptionChangeSource('edit');
setShowHistory(false);
toast.success('Description restored from history');
}}
className={`w-full text-left p-2 rounded-md hover:bg-muted transition-colors ${
isCurrentVersion ? 'bg-muted/50 border border-primary/20' : ''
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{sourceLabel}</span>
<span className="text-xs text-muted-foreground">
{formattedDate}
</span>
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{entry.description.slice(0, 100)}
{entry.description.length > 100 ? '...' : ''}
</p>
{isCurrentVersion && (
<span className="text-xs text-primary font-medium mt-1 block">
Current version
</span>
)}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
)}
{/* Version History Button - uses local history for real-time updates */}
<EnhancementHistoryButton
history={localHistory}
currentValue={editingFeature.description}
onRestore={(description) => {
setEditingFeature((prev) => (prev ? { ...prev, description } : prev));
setDescriptionChangeSource('edit');
}}
valueAccessor={(entry) => entry.description}
title="Version History"
restoreMessage="Description restored from history"
/>
</div>
<DescriptionImageDropZone
value={editingFeature.description}
@@ -443,75 +325,40 @@ export function EditFeatureDialog({
/>
</div>
{/* Collapsible Enhancement Section */}
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}>
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1">
{enhanceOpen ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
<Sparkles className="w-4 h-4" />
<span>Enhance with AI</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="flex flex-wrap items-center gap-2 pl-6">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-xs">
{enhancementMode === 'improve' && 'Improve Clarity'}
{enhancementMode === 'technical' && 'Add Technical Details'}
{enhancementMode === 'simplify' && 'Simplify'}
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
{enhancementMode === 'ux-reviewer' && 'User Experience'}
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
Simplify
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
Add Acceptance Criteria
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('ux-reviewer')}>
User Experience
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Enhancement Section */}
<EnhanceWithAI
value={editingFeature.description}
onChange={(enhanced) =>
setEditingFeature((prev) => (prev ? { ...prev, description: enhanced } : prev))
}
onHistoryAdd={({ mode, originalText, enhancedText }) => {
setDescriptionChangeSource({ source: 'enhance', mode });
setPreEnhancementDescription(originalText);
<Button
type="button"
variant="default"
size="sm"
className="h-8 text-xs"
onClick={handleEnhanceDescription}
disabled={!editingFeature.description.trim() || isEnhancing}
loading={isEnhancing}
>
<Sparkles className="w-3 h-3 mr-1" />
Enhance
</Button>
<ModelOverrideTrigger
currentModelEntry={enhancementOverride.effectiveModelEntry}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
</CollapsibleContent>
</Collapsible>
// Update local history for real-time display
const timestamp = new Date().toISOString();
setLocalHistory((prev) => {
const newHistory = [...prev];
// Add original text first (so user can restore to pre-enhancement state)
const lastEntry = prev[prev.length - 1];
if (!lastEntry || lastEntry.description !== originalText) {
newHistory.push({
description: originalText,
timestamp,
source: prev.length === 0 ? 'initial' : 'edit',
});
}
// Add enhanced text
newHistory.push({
description: enhancedText,
timestamp,
source: 'enhance',
enhancementMode: mode,
});
return newHistory;
});
}}
/>
</div>
{/* AI & Execution Section */}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
@@ -17,6 +18,21 @@ import {
} from '@/components/ui/description-image-dropzone';
import { MessageSquare } from 'lucide-react';
import { Feature } from '@/store/app-store';
import {
EnhanceWithAI,
EnhancementHistoryButton,
type EnhancementMode,
type BaseHistoryEntry,
} from '../shared';
const logger = createLogger('FollowUpDialog');
/**
* A single entry in the follow-up prompt history
*/
export interface FollowUpHistoryEntry extends BaseHistoryEntry {
prompt: string;
}
interface FollowUpDialogProps {
open: boolean;
@@ -30,6 +46,10 @@ interface FollowUpDialogProps {
onPreviewMapChange: (map: ImagePreviewMap) => void;
onSend: () => void;
isMaximized: boolean;
/** History of prompt versions for restoration */
promptHistory?: FollowUpHistoryEntry[];
/** Callback to add a new entry to prompt history */
onHistoryAdd?: (entry: FollowUpHistoryEntry) => void;
}
export function FollowUpDialog({
@@ -44,9 +64,11 @@ export function FollowUpDialog({
onPreviewMapChange,
onSend,
isMaximized,
promptHistory = [],
onHistoryAdd,
}: FollowUpDialogProps) {
const handleClose = (open: boolean) => {
if (!open) {
const handleClose = (openState: boolean) => {
if (!openState) {
onOpenChange(false);
}
};
@@ -77,7 +99,18 @@ export function FollowUpDialog({
</DialogHeader>
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
<div className="space-y-2">
<Label htmlFor="follow-up-prompt">Instructions</Label>
<div className="flex items-center justify-between">
<Label htmlFor="follow-up-prompt">Instructions</Label>
{/* Version History Button */}
<EnhancementHistoryButton
history={promptHistory}
currentValue={prompt}
onRestore={onPromptChange}
valueAccessor={(entry) => entry.prompt}
title="Prompt History"
restoreMessage="Prompt restored from history"
/>
</div>
<DescriptionImageDropZone
value={prompt}
onChange={onPromptChange}
@@ -88,6 +121,33 @@ export function FollowUpDialog({
onPreviewMapChange={onPreviewMapChange}
/>
</div>
{/* Enhancement Section */}
<EnhanceWithAI
value={prompt}
onChange={onPromptChange}
onHistoryAdd={({ mode, originalText, enhancedText }) => {
const timestamp = new Date().toISOString();
// Add original text first (so user can restore to pre-enhancement state)
// Only add if it's different from the last history entry
const lastEntry = promptHistory[promptHistory.length - 1];
if (!lastEntry || lastEntry.prompt !== originalText) {
onHistoryAdd?.({
prompt: originalText,
timestamp,
source: promptHistory.length === 0 ? 'initial' : 'edit',
});
}
// Add enhanced text
onHistoryAdd?.({
prompt: enhancedText,
timestamp,
source: 'enhance',
enhancementMode: mode,
});
}}
/>
<p className="text-xs text-muted-foreground">
The agent will continue from where it left off, using the existing context. You can
attach screenshots to help explain the issue.

View File

@@ -5,6 +5,6 @@ export { CompletedFeaturesModal } from './completed-features-modal';
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
export { EditFeatureDialog } from './edit-feature-dialog';
export { FollowUpDialog } from './follow-up-dialog';
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
export { PlanApprovalDialog } from './plan-approval-dialog';
export { MassEditDialog } from './mass-edit-dialog';

View File

@@ -0,0 +1,67 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { GitBranch, Settings2 } from 'lucide-react';
interface PlanSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
planUseSelectedWorktreeBranch: boolean;
onPlanUseSelectedWorktreeBranchChange: (value: boolean) => void;
}
export function PlanSettingsDialog({
open,
onOpenChange,
planUseSelectedWorktreeBranch,
onPlanUseSelectedWorktreeBranchChange,
}: PlanSettingsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="plan-settings-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="w-5 h-5" />
Plan Settings
</DialogTitle>
<DialogDescription>
Configure how the Plan feature creates and organizes new features.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Use Selected Worktree Branch Setting */}
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<Label
htmlFor="plan-worktree-branch-toggle"
className="text-sm font-medium cursor-pointer flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Use selected worktree branch
</Label>
<Switch
id="plan-worktree-branch-toggle"
checked={planUseSelectedWorktreeBranch}
onCheckedChange={onPlanUseSelectedWorktreeBranchChange}
data-testid="plan-worktree-branch-toggle"
/>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, features created via the Plan dialog will be assigned to the currently
selected worktree branch. When disabled, features will be added to the main branch.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,67 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { GitBranch, Settings2 } from 'lucide-react';
interface WorktreeSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
addFeatureUseSelectedWorktreeBranch: boolean;
onAddFeatureUseSelectedWorktreeBranchChange: (value: boolean) => void;
}
export function WorktreeSettingsDialog({
open,
onOpenChange,
addFeatureUseSelectedWorktreeBranch,
onAddFeatureUseSelectedWorktreeBranchChange,
}: WorktreeSettingsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="worktree-settings-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="w-5 h-5" />
Worktree Settings
</DialogTitle>
<DialogDescription>
Configure how worktrees affect feature creation and organization.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Use Selected Worktree Branch Setting */}
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<Label
htmlFor="worktree-branch-toggle"
className="text-sm font-medium cursor-pointer flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Use selected worktree branch
</Label>
<Switch
id="worktree-branch-toggle"
checked={addFeatureUseSelectedWorktreeBranch}
onCheckedChange={onAddFeatureUseSelectedWorktreeBranchChange}
data-testid="worktree-branch-toggle"
/>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, the Add Feature dialog will default to custom branch mode with the
currently selected worktree branch pre-filled.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -30,7 +30,8 @@ interface UseBoardActionsProps {
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) => Promise<void>;
persistFeatureDelete: (featureId: string) => Promise<void>;
saveCategory: (category: string) => Promise<void>;
@@ -251,7 +252,8 @@ export function useBoardActions({
workMode?: 'current' | 'auto' | 'custom';
},
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) => {
const workMode = updates.workMode || 'current';
@@ -308,7 +310,13 @@ export function useBoardActions({
};
updateFeature(featureId, finalUpdates);
persistFeatureUpdate(featureId, finalUpdates, descriptionHistorySource, enhancementMode);
persistFeatureUpdate(
featureId,
finalUpdates,
descriptionHistorySource,
enhancementMode,
preEnhancementDescription
);
if (updates.category) {
saveCategory(updates.category);
}

View File

@@ -19,7 +19,8 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) => {
if (!currentProject) return;
@@ -35,7 +36,8 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
featureId,
updates,
descriptionHistorySource,
enhancementMode
enhancementMode,
preEnhancementDescription
);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);

View File

@@ -4,13 +4,18 @@ import {
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import type { FollowUpHistoryEntry } from '../dialogs/follow-up-dialog';
/**
* Custom hook for managing follow-up dialog state including prompt history
*/
export function useFollowUpState() {
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
const [followUpPrompt, setFollowUpPrompt] = useState('');
const [followUpImagePaths, setFollowUpImagePaths] = useState<DescriptionImagePath[]>([]);
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(() => new Map());
const [followUpPromptHistory, setFollowUpPromptHistory] = useState<FollowUpHistoryEntry[]>([]);
const resetFollowUpState = useCallback(() => {
setShowFollowUpDialog(false);
@@ -18,6 +23,7 @@ export function useFollowUpState() {
setFollowUpPrompt('');
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
setFollowUpPromptHistory([]);
}, []);
const handleFollowUpDialogChange = useCallback(
@@ -31,6 +37,13 @@ export function useFollowUpState() {
[resetFollowUpState]
);
/**
* Adds a new entry to the prompt history
*/
const addToPromptHistory = useCallback((entry: FollowUpHistoryEntry) => {
setFollowUpPromptHistory((prev) => [...prev, entry]);
}, []);
return {
// State
showFollowUpDialog,
@@ -38,14 +51,17 @@ export function useFollowUpState() {
followUpPrompt,
followUpImagePaths,
followUpPreviewMap,
followUpPromptHistory,
// Setters
setShowFollowUpDialog,
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setFollowUpPreviewMap,
setFollowUpPromptHistory,
// Helpers
resetFollowUpState,
handleFollowUpDialogChange,
addToPromptHistory,
};
}

View File

@@ -0,0 +1,209 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore, type InitScriptState } from '@/store/app-store';
import { AnsiOutput } from '@/components/ui/ansi-output';
interface InitScriptIndicatorProps {
projectPath: string;
}
interface SingleIndicatorProps {
stateKey: string;
state: InitScriptState;
onDismiss: (key: string) => void;
isOnlyOne: boolean; // Whether this is the only indicator shown
autoDismiss: boolean; // Whether to auto-dismiss after completion
}
function SingleIndicator({
stateKey,
state,
onDismiss,
isOnlyOne,
autoDismiss,
}: SingleIndicatorProps) {
const [showLogs, setShowLogs] = useState(false);
const logsEndRef = useRef<HTMLDivElement>(null);
const { status, output, branch, error } = state;
// Auto-scroll to bottom when new output arrives
useEffect(() => {
if (showLogs && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [output, showLogs]);
// Auto-expand logs when script starts (only if it's the only one or running)
useEffect(() => {
if (status === 'running' && isOnlyOne) {
setShowLogs(true);
}
}, [status, isOnlyOne]);
// Auto-dismiss after completion (5 seconds)
useEffect(() => {
if (autoDismiss && (status === 'success' || status === 'failed')) {
const timer = setTimeout(() => {
onDismiss(stateKey);
}, 5000);
return () => clearTimeout(timer);
}
}, [status, autoDismiss, stateKey, onDismiss]);
if (status === 'idle') return null;
return (
<div
className={cn(
'bg-card border border-border rounded-lg shadow-lg',
'min-w-[350px] max-w-[500px]',
'animate-in slide-in-from-right-5 duration-200'
)}
>
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-border/50">
<div className="flex items-center gap-2">
{status === 'running' && <Loader2 className="w-4 h-4 animate-spin text-blue-500" />}
{status === 'success' && <Check className="w-4 h-4 text-green-500" />}
{status === 'failed' && <X className="w-4 h-4 text-red-500" />}
<span className="font-medium text-sm">
Init Script{' '}
{status === 'running' ? 'Running' : status === 'success' ? 'Completed' : 'Failed'}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setShowLogs(!showLogs)}
className="p-1 hover:bg-accent rounded transition-colors"
title={showLogs ? 'Hide logs' : 'Show logs'}
>
{showLogs ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
)}
</button>
{status !== 'running' && (
<button
onClick={() => onDismiss(stateKey)}
className="p-1 hover:bg-accent rounded transition-colors"
title="Dismiss"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
)}
</div>
</div>
{/* Branch info */}
<div className="px-3 py-2 text-xs text-muted-foreground flex items-center gap-2">
<Terminal className="w-3.5 h-3.5" />
<span>Branch: {branch}</span>
</div>
{/* Logs (collapsible) */}
{showLogs && (
<div className="border-t border-border/50">
<div className="p-3 max-h-[300px] overflow-y-auto">
{output.length > 0 ? (
<AnsiOutput text={output.join('')} />
) : (
<div className="text-xs text-muted-foreground/60 text-center py-2">
{status === 'running' ? 'Waiting for output...' : 'No output'}
</div>
)}
{error && <div className="mt-2 text-red-500 text-xs font-medium">Error: {error}</div>}
<div ref={logsEndRef} />
</div>
</div>
)}
{/* Status bar for completed states */}
{status !== 'running' && (
<div
className={cn(
'px-3 py-2 text-xs',
status === 'success' ? 'bg-green-500/10 text-green-600' : 'bg-red-500/10 text-red-600'
)}
>
{status === 'success'
? 'Initialization completed successfully'
: 'Initialization failed - worktree is still usable'}
</div>
)}
</div>
);
}
export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
const getInitScriptStatesForProject = useAppStore((s) => s.getInitScriptStatesForProject);
const clearInitScriptState = useAppStore((s) => s.clearInitScriptState);
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
// Get auto-dismiss setting
const autoDismiss = getAutoDismissInitScriptIndicator(projectPath);
// Get all init script states for this project
const allStates = getInitScriptStatesForProject(projectPath);
// Filter out dismissed and idle states
const activeStates = allStates.filter(
({ key, state }) => !dismissedKeys.has(key) && state.status !== 'idle'
);
// Reset dismissed keys when a new script starts for a branch
useEffect(() => {
const runningKeys = allStates
.filter(({ state }) => state.status === 'running')
.map(({ key }) => key);
if (runningKeys.length > 0) {
setDismissedKeys((prev) => {
const newSet = new Set(prev);
runningKeys.forEach((key) => newSet.delete(key));
return newSet;
});
}
}, [allStates]);
const handleDismiss = useCallback(
(key: string) => {
setDismissedKeys((prev) => new Set(prev).add(key));
// Extract branch from key (format: "projectPath::branch")
const branch = key.split('::')[1];
if (branch) {
// Clear state after a delay to allow for future scripts
setTimeout(() => {
clearInitScriptState(projectPath, branch);
}, 100);
}
},
[projectPath, clearInitScriptState]
);
if (activeStates.length === 0) return null;
return (
<div
className={cn(
'fixed bottom-4 right-4 z-50 flex flex-col gap-2',
'max-h-[calc(100vh-120px)] overflow-y-auto',
'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent'
)}
>
{activeStates.map(({ key, state }) => (
<SingleIndicator
key={key}
stateKey={key}
state={state}
onDismiss={handleDismiss}
isOnlyOne={activeStates.length === 1}
autoDismiss={autoDismiss}
/>
))}
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Button } from '@/components/ui/button';
import { KanbanColumn, KanbanCard } from './components';
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
import { Feature, useAppStore, formatShortcut } from '@/store/app-store';
import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-react';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
@@ -51,6 +51,12 @@ interface KanbanBoardProps {
selectedFeatureIds?: Set<string>;
onToggleFeatureSelection?: (featureId: string) => void;
onToggleSelectionMode?: () => void;
// Empty state action props
onAiSuggest?: () => void;
/** Whether currently dragging (hides empty states during drag) */
isDragging?: boolean;
/** Whether the board is in read-only mode */
isReadOnly?: boolean;
}
export function KanbanBoard({
@@ -86,6 +92,9 @@ export function KanbanBoard({
selectedFeatureIds = new Set(),
onToggleFeatureSelection,
onToggleSelectionMode,
onAiSuggest,
isDragging = false,
isReadOnly = false,
}: KanbanBoardProps) {
// Generate columns including pipeline steps
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
@@ -211,6 +220,26 @@ export function KanbanBoard({
items={columnFeatures.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
{/* Empty state card when column has no features */}
{columnFeatures.length === 0 && !isDragging && (
<EmptyStateCard
columnId={column.id}
columnTitle={column.title}
addFeatureShortcut={addFeatureShortcut}
isReadOnly={isReadOnly}
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
opacity={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
customConfig={
column.isPipelineStep
? {
title: `${column.title} Empty`,
description: `Features will appear here during the ${column.title.toLowerCase()} phase of the pipeline.`,
}
: undefined
}
/>
)}
{columnFeatures.map((feature, index) => {
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
let shortcutKey: string | undefined;

View File

@@ -0,0 +1,152 @@
import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Sparkles, ChevronDown, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
const logger = createLogger('EnhanceWithAI');
interface EnhanceWithAIProps {
/** Current text value to enhance */
value: string;
/** Callback when text is enhanced */
onChange: (enhancedText: string) => void;
/** Optional callback to track enhancement in history */
onHistoryAdd?: (entry: {
mode: EnhancementMode;
originalText: string;
enhancedText: string;
}) => void;
/** Disable the enhancement feature */
disabled?: boolean;
/** Additional CSS classes */
className?: string;
}
/**
* Reusable "Enhance with AI" component
*
* Provides AI-powered text enhancement with multiple modes:
* - Improve Clarity
* - Add Technical Details
* - Simplify
* - Add Acceptance Criteria
* - User Experience
*
* Used in Add Feature, Edit Feature, and Follow-Up dialogs.
*/
export function EnhanceWithAI({
value,
onChange,
onHistoryAdd,
disabled = false,
className,
}: EnhanceWithAIProps) {
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<EnhancementMode>('improve');
const [enhanceOpen, setEnhanceOpen] = useState(false);
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
const handleEnhance = async () => {
if (!value.trim() || isEnhancing || disabled) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
value,
enhancementMode,
enhancementOverride.effectiveModel,
enhancementOverride.effectiveModelEntry.thinkingLevel
);
if (result?.success && result.enhancedText) {
const originalText = value;
const enhancedText = result.enhancedText;
onChange(enhancedText);
// Track in history if callback provided (includes original for restoration)
onHistoryAdd?.({ mode: enhancementMode, originalText, enhancedText });
toast.success('Enhanced successfully!');
} else {
toast.error(result?.error || 'Failed to enhance');
}
} catch (error) {
logger.error('Enhancement failed:', error);
toast.error('Failed to enhance');
} finally {
setIsEnhancing(false);
}
};
return (
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen} className={className}>
<CollapsibleTrigger asChild>
<button
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1"
disabled={disabled}
>
{enhanceOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
<Sparkles className="w-4 h-4" />
<span>Enhance with AI</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="flex flex-wrap items-center gap-2 pl-6">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-xs" disabled={disabled}>
{ENHANCEMENT_MODE_LABELS[enhancementMode]}
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{(Object.entries(ENHANCEMENT_MODE_LABELS) as [EnhancementMode, string][]).map(
([mode, label]) => (
<DropdownMenuItem key={mode} onClick={() => setEnhancementMode(mode)}>
{label}
</DropdownMenuItem>
)
)}
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="default"
size="sm"
className="h-8 text-xs"
onClick={handleEnhance}
disabled={!value.trim() || isEnhancing || disabled}
loading={isEnhancing}
>
<Sparkles className="w-3 h-3 mr-1" />
Enhance
</Button>
<ModelOverrideTrigger
currentModelEntry={enhancementOverride.effectiveModelEntry}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
</CollapsibleContent>
</Collapsible>
);
}

View File

@@ -0,0 +1,20 @@
/** Enhancement mode options for AI-powered prompt improvement */
export type EnhancementMode = 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
/** Labels for enhancement modes displayed in the UI */
export const ENHANCEMENT_MODE_LABELS: Record<EnhancementMode, string> = {
improve: 'Improve Clarity',
technical: 'Add Technical Details',
simplify: 'Simplify',
acceptance: 'Add Acceptance Criteria',
'ux-reviewer': 'User Experience',
};
/** Descriptions for enhancement modes (for tooltips/accessibility) */
export const ENHANCEMENT_MODE_DESCRIPTIONS: Record<EnhancementMode, string> = {
improve: 'Make the prompt clearer and more concise',
technical: 'Add implementation details and specifications',
simplify: 'Reduce complexity while keeping the core intent',
acceptance: 'Add specific acceptance criteria and test cases',
'ux-reviewer': 'Add user experience considerations and flows',
};

View File

@@ -0,0 +1,136 @@
import { useState, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { History } from 'lucide-react';
import { toast } from 'sonner';
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
/**
* Base interface for history entries
*/
export interface BaseHistoryEntry {
timestamp: string;
source: 'initial' | 'enhance' | 'edit';
enhancementMode?: EnhancementMode;
}
interface EnhancementHistoryButtonProps<T extends BaseHistoryEntry> {
/** Array of history entries */
history: T[];
/** Current value to compare against for highlighting */
currentValue: string;
/** Callback when a history entry is restored */
onRestore: (value: string) => void;
/** Function to extract the text value from a history entry */
valueAccessor: (entry: T) => string;
/** Title for the history popover (e.g., "Version History", "Prompt History") */
title?: string;
/** Message shown when restoring an entry */
restoreMessage?: string;
}
/**
* Reusable history button component for enhancement-related history
*
* Displays a popover with a list of historical versions that can be restored.
* Used in edit-feature-dialog and follow-up-dialog for description/prompt history.
*/
export function EnhancementHistoryButton<T extends BaseHistoryEntry>({
history,
currentValue,
onRestore,
valueAccessor,
title = 'Version History',
restoreMessage = 'Restored from history',
}: EnhancementHistoryButtonProps<T>) {
const [showHistory, setShowHistory] = useState(false);
// Memoize reversed history to avoid creating new array on every render
// NOTE: This hook MUST be called before any early returns to follow Rules of Hooks
const reversedHistory = useMemo(() => [...history].reverse(), [history]);
// Early return AFTER all hooks are called
if (history.length === 0) {
return null;
}
const getSourceLabel = (entry: T): string => {
if (entry.source === 'initial') {
return 'Original';
}
if (entry.source === 'enhance') {
const mode = entry.enhancementMode ?? 'improve';
const label = ENHANCEMENT_MODE_LABELS[mode as EnhancementMode] ?? mode;
return `Enhanced (${label})`;
}
return 'Edited';
};
const formatDate = (timestamp: string): string => {
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<Popover open={showHistory} onOpenChange={setShowHistory}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-muted-foreground"
>
<History className="w-3.5 h-3.5" />
History ({history.length})
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="p-3 border-b">
<h4 className="font-medium text-sm">{title}</h4>
<p className="text-xs text-muted-foreground mt-1">Click a version to restore it</p>
</div>
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{reversedHistory.map((entry, index) => {
const value = valueAccessor(entry);
const isCurrentVersion = value === currentValue;
const sourceLabel = getSourceLabel(entry);
const formattedDate = formatDate(entry.timestamp);
return (
<button
key={`${entry.timestamp}-${index}`}
onClick={() => {
onRestore(value);
setShowHistory(false);
toast.success(restoreMessage);
}}
className={`w-full text-left p-2 rounded-md hover:bg-muted transition-colors ${
isCurrentVersion ? 'bg-muted/50 border border-primary/20' : ''
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{sourceLabel}</span>
<span className="text-xs text-muted-foreground">{formattedDate}</span>
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{value.slice(0, 100)}
{value.length > 100 ? '...' : ''}
</p>
{isCurrentVersion && (
<span className="text-xs text-primary font-medium mt-1 block">
Current version
</span>
)}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,3 @@
export * from './enhancement-constants';
export * from './enhance-with-ai';
export * from './enhancement-history-button';

View File

@@ -10,3 +10,4 @@ export * from './planning-mode-selector';
export * from './planning-mode-select';
export * from './ancestor-context-section';
export * from './work-mode-selector';
export * from './enhancement';

View File

@@ -6,13 +6,15 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from '@/components/ui/dropdown-menu';
import {
Trash2,
MoreHorizontal,
GitCommit,
GitPullRequest,
ExternalLink,
Download,
Upload,
Play,
@@ -21,15 +23,19 @@ import {
MessageSquare,
GitMerge,
AlertCircle,
RefreshCw,
Copy,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
import { TooltipWrapper } from './tooltip-wrapper';
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
import { getEditorIcon } from '@/components/icons/editor-icons';
interface WorktreeActionsDropdownProps {
worktree: WorktreeInfo;
isSelected: boolean;
defaultEditorName: string;
aheadCount: number;
behindCount: number;
isPulling: boolean;
@@ -41,7 +47,7 @@ interface WorktreeActionsDropdownProps {
onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -50,12 +56,13 @@ interface WorktreeActionsDropdownProps {
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
export function WorktreeActionsDropdown({
worktree,
isSelected,
defaultEditorName,
aheadCount,
behindCount,
isPulling,
@@ -76,7 +83,23 @@ export function WorktreeActionsDropdown({
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
onRunInitScript,
hasInitScript,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
const { editors } = useAvailableEditors();
// Use shared hook for effective default editor
const effectiveDefaultEditor = useEffectiveDefaultEditor(editors);
// Get other editors (excluding the default) for the submenu
const otherEditors = editors.filter((e) => e.command !== effectiveDefaultEditor?.command);
// Get icon component for the effective editor (avoid IIFE in JSX)
const DefaultEditorIcon = effectiveDefaultEditor
? getEditorIcon(effectiveDefaultEditor.command)
: null;
// Check if there's a PR associated with this worktree from stored metadata
const hasPR = !!worktree.pr;
@@ -200,10 +223,60 @@ export function WorktreeActionsDropdown({
</TooltipWrapper>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onOpenInEditor(worktree)} className="text-xs">
<ExternalLink className="w-3.5 h-3.5 mr-2" />
Open in {defaultEditorName}
</DropdownMenuItem>
{/* Open in editor - split button: click main area for default, chevron for other options */}
{effectiveDefaultEditor && (
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - opens in default editor */}
<DropdownMenuItem
onClick={() => onOpenInEditor(worktree, effectiveDefaultEditor.command)}
className="text-xs flex-1 pr-0 rounded-r-none"
>
{DefaultEditorIcon && <DefaultEditorIcon className="w-3.5 h-3.5 mr-2" />}
Open in {effectiveDefaultEditor.name}
</DropdownMenuItem>
{/* Chevron trigger for submenu with other editors and Copy Path */}
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
</div>
<DropdownMenuSubContent>
{/* Other editors */}
{otherEditors.map((editor) => {
const EditorIcon = getEditorIcon(editor.command);
return (
<DropdownMenuItem
key={editor.command}
onClick={() => onOpenInEditor(worktree, editor.command)}
className="text-xs"
>
<EditorIcon className="w-3.5 h-3.5 mr-2" />
{editor.name}
</DropdownMenuItem>
);
})}
{otherEditors.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={async () => {
try {
await navigator.clipboard.writeText(worktree.path);
toast.success('Path copied to clipboard');
} catch {
toast.error('Failed to copy path to clipboard');
}
}}
className="text-xs"
>
<Copy className="w-3.5 h-3.5 mr-2" />
Copy Path
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{!worktree.isMain && hasInitScript && (
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2" />
Re-run Init Script
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{worktree.hasChanges && (
<TooltipWrapper

View File

@@ -17,7 +17,6 @@ interface WorktreeTabProps {
isActivating: boolean;
isDevServerRunning: boolean;
devServerInfo?: DevServerInfo;
defaultEditorName: string;
branches: BranchInfo[];
filteredBranches: BranchInfo[];
branchFilter: string;
@@ -37,7 +36,7 @@ interface WorktreeTabProps {
onCreateBranch: (worktree: WorktreeInfo) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -46,6 +45,8 @@ interface WorktreeTabProps {
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
export function WorktreeTab({
@@ -58,7 +59,6 @@ export function WorktreeTab({
isActivating,
isDevServerRunning,
devServerInfo,
defaultEditorName,
branches,
filteredBranches,
branchFilter,
@@ -87,6 +87,8 @@ export function WorktreeTab({
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
onRunInitScript,
hasInitScript,
}: WorktreeTabProps) {
let prBadge: JSX.Element | null = null;
if (worktree.pr) {
@@ -315,7 +317,6 @@ export function WorktreeTab({
<WorktreeActionsDropdown
worktree={worktree}
isSelected={isSelected}
defaultEditorName={defaultEditorName}
aheadCount={aheadCount}
behindCount={behindCount}
isPulling={isPulling}
@@ -336,6 +337,8 @@ export function WorktreeTab({
onStartDevServer={onStartDevServer}
onStopDevServer={onStopDevServer}
onOpenDevServerUrl={onOpenDevServerUrl}
onRunInitScript={onRunInitScript}
hasInitScript={hasInitScript}
/>
</div>
);

View File

@@ -2,5 +2,5 @@ export { useWorktrees } from './use-worktrees';
export { useDevServers } from './use-dev-servers';
export { useBranches } from './use-branches';
export { useWorktreeActions } from './use-worktree-actions';
export { useDefaultEditor } from './use-default-editor';
export { useRunningFeatures } from './use-running-features';
export { useAvailableEditors, useEffectiveDefaultEditor } from './use-available-editors';

View File

@@ -0,0 +1,101 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import type { EditorInfo } from '@automaker/types';
const logger = createLogger('AvailableEditors');
// Re-export EditorInfo for convenience
export type { EditorInfo };
export function useAvailableEditors() {
const [editors, setEditors] = useState<EditorInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const fetchAvailableEditors = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.getAvailableEditors) {
setIsLoading(false);
return;
}
const result = await api.worktree.getAvailableEditors();
if (result.success && result.result?.editors) {
setEditors(result.result.editors);
}
} catch (error) {
logger.error('Failed to fetch available editors:', error);
} finally {
setIsLoading(false);
}
}, []);
/**
* Refresh editors by clearing the server cache and re-detecting
* Use this when the user has installed/uninstalled editors
*/
const refresh = useCallback(async () => {
setIsRefreshing(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.refreshEditors) {
// Fallback to regular fetch if refresh not available
await fetchAvailableEditors();
return;
}
const result = await api.worktree.refreshEditors();
if (result.success && result.result?.editors) {
setEditors(result.result.editors);
logger.info(`Editor cache refreshed, found ${result.result.editors.length} editors`);
}
} catch (error) {
logger.error('Failed to refresh editors:', error);
} finally {
setIsRefreshing(false);
}
}, [fetchAvailableEditors]);
useEffect(() => {
fetchAvailableEditors();
}, [fetchAvailableEditors]);
return {
editors,
isLoading,
isRefreshing,
refresh,
// Convenience property: has multiple editors (for deciding whether to show submenu)
hasMultipleEditors: editors.length > 1,
// The first editor is the "default" one
defaultEditor: editors[0] ?? null,
};
}
/**
* Hook to get the effective default editor based on user settings
* Falls back to: Cursor > VS Code > first available editor
*/
export function useEffectiveDefaultEditor(editors: EditorInfo[]): EditorInfo | null {
const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand);
return useMemo(() => {
if (editors.length === 0) return null;
// If user has a saved preference and it exists in available editors, use it
if (defaultEditorCommand) {
const found = editors.find((e) => e.command === defaultEditorCommand);
if (found) return found;
}
// Auto-detect: prefer Cursor, then VS Code, then first available
const cursor = editors.find((e) => e.command === 'cursor');
if (cursor) return cursor;
const vscode = editors.find((e) => e.command === 'code');
if (vscode) return vscode;
return editors[0];
}, [editors, defaultEditorCommand]);
}

View File

@@ -125,14 +125,14 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
[isPushing, fetchBranches, fetchWorktrees]
);
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo) => {
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.openInEditor) {
logger.warn('Open in editor API not available');
return;
}
const result = await api.worktree.openInEditor(worktree.path);
const result = await api.worktree.openInEditor(worktree.path, editorCommand);
if (result.success && result.result) {
toast.success(result.result.message);
} else if (result.error) {

View File

@@ -1,14 +1,15 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { cn, pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { WorktreePanelProps, WorktreeInfo } from './types';
import {
useWorktrees,
useDevServers,
useBranches,
useWorktreeActions,
useDefaultEditor,
useRunningFeatures,
} from './hooks';
import { WorktreeTab } from './components';
@@ -75,13 +76,33 @@ export function WorktreePanel({
fetchBranches,
});
const { defaultEditorName } = useDefaultEditor();
const { hasRunningFeatures } = useRunningFeatures({
runningFeatureIds,
features,
});
// Track whether init script exists for the project
const [hasInitScript, setHasInitScript] = useState(false);
useEffect(() => {
if (!projectPath) {
setHasInitScript(false);
return;
}
const checkInitScript = async () => {
try {
const api = getHttpApiClient();
const result = await api.worktree.getInitScript(projectPath);
setHasInitScript(result.success && result.exists);
} catch {
setHasInitScript(false);
}
};
checkInitScript();
}, [projectPath]);
// Periodic interval check (5 seconds) to detect branch changes on disk
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
const intervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -116,6 +137,33 @@ export function WorktreePanel({
}
};
const handleRunInitScript = useCallback(
async (worktree: WorktreeInfo) => {
if (!projectPath) return;
try {
const api = getHttpApiClient();
const result = await api.worktree.runInitScript(
projectPath,
worktree.path,
worktree.branch
);
if (!result.success) {
toast.error('Failed to run init script', {
description: result.error,
});
}
// Success feedback will come via WebSocket events (init-started, init-output, init-completed)
} catch (error) {
toast.error('Failed to run init script', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
},
[projectPath]
);
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
@@ -137,7 +185,6 @@ export function WorktreePanel({
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
defaultEditorName={defaultEditorName}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
@@ -166,6 +213,8 @@ export function WorktreePanel({
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onRunInitScript={handleRunInitScript}
hasInitScript={hasInitScript}
/>
)}
</div>
@@ -192,7 +241,6 @@ export function WorktreePanel({
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
defaultEditorName={defaultEditorName}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
@@ -221,6 +269,8 @@ export function WorktreePanel({
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onRunInitScript={handleRunInitScript}
hasInitScript={hasInitScript}
/>
);
})}

View File

@@ -472,9 +472,9 @@ export function DashboardView() {
aria-hidden="true"
/>
)}
<div className="px-8 py-4 flex items-center justify-between">
<div className="px-4 sm:px-8 py-4 flex items-center justify-between">
<div
className="flex items-center gap-3 cursor-pointer group titlebar-no-drag"
className="flex items-center gap-2 sm:gap-3 cursor-pointer group titlebar-no-drag"
onClick={() => navigate({ to: '/dashboard' })}
>
<svg
@@ -482,7 +482,7 @@ export function DashboardView() {
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="size-10 group-hover:rotate-12 transition-transform duration-300 ease-out"
className="size-8 sm:size-10 group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient
@@ -521,7 +521,7 @@ export function DashboardView() {
</g>
</svg>
<div className="flex flex-col">
<span className="font-bold text-foreground text-2xl tracking-tight leading-none">
<span className="font-bold text-foreground text-xl sm:text-2xl tracking-tight leading-none">
automaker<span className="text-brand-500">.</span>
</span>
<span className="text-xs text-muted-foreground leading-none font-medium mt-1">
@@ -541,30 +541,32 @@ export function DashboardView() {
</header>
{/* Main content */}
<div className="flex-1 overflow-y-auto p-8">
<div className="flex-1 overflow-y-auto p-4 sm:p-8">
<div className="max-w-6xl mx-auto">
{/* No projects - show getting started */}
{!hasProjects && (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-foreground mb-3">Welcome to Automaker</h2>
<p className="text-lg text-muted-foreground max-w-xl mx-auto">
<div className="text-center mb-8 sm:mb-12">
<h2 className="text-2xl sm:text-3xl font-bold text-foreground mb-3">
Welcome to Automaker
</h2>
<p className="text-base sm:text-lg text-muted-foreground max-w-xl mx-auto px-2">
Your autonomous AI development studio. Get started by creating a new project or
opening an existing one.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-3xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 max-w-3xl mx-auto">
{/* New Project Card */}
<div
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 rounded-xl bg-linear-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-xl bg-linear-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 className="relative p-4 sm:p-6 h-full flex flex-col">
<div className="flex items-start gap-3 sm:gap-4 flex-1">
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-linear-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-5 h-5 sm:w-6 sm:h-6 text-white" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground mb-1.5">
@@ -578,7 +580,7 @@ export function DashboardView() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="w-full mt-5 bg-linear-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"
className="w-full mt-4 sm:mt-5 bg-linear-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" />
@@ -613,10 +615,10 @@ export function DashboardView() {
data-testid="open-project-card"
>
<div className="absolute inset-0 rounded-xl bg-linear-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-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 className="relative p-4 sm:p-6 h-full flex flex-col">
<div className="flex items-start gap-3 sm:gap-4 flex-1">
<div className="w-10 h-10 sm:w-12 sm: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-5 h-5 sm:w-6 sm:h-6 text-muted-foreground group-hover:text-blue-500 transition-colors duration-300" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground mb-1.5">
@@ -629,7 +631,7 @@ export function DashboardView() {
</div>
<Button
variant="secondary"
className="w-full mt-5 bg-secondary/80 hover:bg-secondary text-foreground border border-border hover:border-blue-500/30 transition-all"
className="w-full mt-4 sm: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" />
@@ -643,21 +645,26 @@ export function DashboardView() {
{/* Has projects - show project list */}
{hasProjects && (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="space-y-6 sm:space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Quick actions header */}
<div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 className="text-2xl font-bold text-foreground">Your Projects</h2>
<div className="flex gap-2">
<Button variant="outline" onClick={handleOpenProject}>
<FolderOpen className="w-4 h-4 mr-2" />
Open Folder
<Button
variant="outline"
onClick={handleOpenProject}
className="flex-1 sm:flex-none"
>
<FolderOpen className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">Open Folder</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white">
<Plus className="w-4 h-4 mr-2" />
New Project
<ChevronDown className="w-4 h-4 ml-2" />
<Button className="flex-1 sm:flex-none bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white">
<Plus className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">New Project</span>
<span className="sm:hidden">New</span>
<ChevronDown className="w-4 h-4 ml-1 sm:ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
@@ -677,13 +684,15 @@ export function DashboardView() {
{/* Favorites section */}
{favoriteProjects.length > 0 && (
<div>
<div className="flex items-center gap-2.5 mb-4">
<div className="w-8 h-8 rounded-lg bg-yellow-500/10 flex items-center justify-center">
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
<div className="flex items-center gap-2 sm:gap-2.5 mb-3 sm:mb-4">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-yellow-500/10 flex items-center justify-center">
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-yellow-500 fill-yellow-500" />
</div>
<h3 className="text-lg font-semibold text-foreground">Favorites</h3>
<h3 className="text-base sm:text-lg font-semibold text-foreground">
Favorites
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
{favoriteProjects.map((project) => (
<div
key={project.id}
@@ -692,40 +701,40 @@ export function DashboardView() {
data-testid={`project-card-${project.id}`}
>
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-yellow-500/5 to-amber-600/5 opacity-0 group-hover:opacity-100 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-yellow-500/10 border border-yellow-500/30 flex items-center justify-center group-hover:bg-yellow-500/20 transition-all duration-300 shrink-0">
<Folder className="w-5 h-5 text-yellow-500" />
<div className="relative p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-yellow-500/10 border border-yellow-500/30 flex items-center justify-center group-hover:bg-yellow-500/20 transition-all duration-300 shrink-0">
<Folder className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-500" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate group-hover:text-yellow-500 transition-colors duration-300">
<p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-yellow-500 transition-colors duration-300">
{project.name}
</p>
<p className="text-xs text-muted-foreground/70 truncate mt-1">
<p className="text-xs text-muted-foreground/70 truncate mt-0.5 sm:mt-1">
{project.path}
</p>
{project.lastOpened && (
<p className="text-xs text-muted-foreground mt-1.5">
<p className="text-xs text-muted-foreground mt-1 sm:mt-1.5">
{new Date(project.lastOpened).toLocaleDateString()}
</p>
)}
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-0.5 sm:gap-1">
<button
onClick={(e) => handleToggleFavorite(e, project.id)}
className="p-1.5 rounded-lg hover:bg-yellow-500/20 transition-colors"
className="p-1 sm:p-1.5 rounded-lg hover:bg-yellow-500/20 transition-colors"
title="Remove from favorites"
>
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-yellow-500 fill-yellow-500" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
title="More options"
>
<MoreVertical className="w-4 h-4 text-muted-foreground" />
<MoreVertical className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -750,13 +759,15 @@ export function DashboardView() {
{/* Recent projects section */}
{recentProjects.length > 0 && (
<div>
<div className="flex items-center gap-2.5 mb-4">
<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 className="flex items-center gap-2 sm:gap-2.5 mb-3 sm:mb-4">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-muted/50 flex items-center justify-center">
<Clock className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold text-foreground">Recent Projects</h3>
<h3 className="text-base sm:text-lg font-semibold text-foreground">
Recent Projects
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
{recentProjects.map((project) => (
<div
key={project.id}
@@ -765,40 +776,40 @@ export function DashboardView() {
data-testid={`project-card-${project.id}`}
>
<div className="absolute inset-0 rounded-xl bg-linear-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/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 className="relative p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
<div className="w-9 h-9 sm:w-10 sm: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-4 h-4 sm:w-5 sm: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 duration-300">
<p className="text-sm sm:text-base 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-1">
<p className="text-xs text-muted-foreground/70 truncate mt-0.5 sm:mt-1">
{project.path}
</p>
{project.lastOpened && (
<p className="text-xs text-muted-foreground mt-1.5">
<p className="text-xs text-muted-foreground mt-1 sm:mt-1.5">
{new Date(project.lastOpened).toLocaleDateString()}
</p>
)}
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-0.5 sm:gap-1">
<button
onClick={(e) => handleToggleFavorite(e, project.id)}
className="p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
title="Add to favorites"
>
<Star className="w-4 h-4 text-muted-foreground hover:text-yellow-500" />
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground hover:text-yellow-500" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
title="More options"
>
<MoreVertical className="w-4 h-4 text-muted-foreground" />
<MoreVertical className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">

View File

@@ -13,7 +13,15 @@
import { useReducer, useEffect, useRef } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { login, getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client';
import {
login,
getHttpApiClient,
getServerUrlSync,
getApiKey,
getSessionToken,
initApiKey,
waitForApiKeyInit,
} from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react';
@@ -92,6 +100,7 @@ function reducer(state: State, action: Action): State {
const MAX_RETRIES = 5;
const BACKOFF_BASE_MS = 400;
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
// =============================================================================
// Imperative Flow Logic (runs once on mount)
@@ -102,7 +111,9 @@ const BACKOFF_BASE_MS = 400;
* Unlike the httpClient methods, this does NOT call handleUnauthorized()
* which would navigate us away to /logged-out.
*
* Relies on HTTP-only session cookie being sent via credentials: 'include'.
* Supports both:
* - Electron mode: Uses X-API-Key header (API key from IPC)
* - Web mode: Uses HTTP-only session cookie
*
* Returns: { authenticated: true } or { authenticated: false }
* Throws: on network errors (for retry logic)
@@ -110,9 +121,31 @@ const BACKOFF_BASE_MS = 400;
async function checkAuthStatusSafe(): Promise<{ authenticated: boolean }> {
const serverUrl = getServerUrlSync();
// Wait for API key to be initialized before checking auth
// This ensures we have a valid API key to send in the header
await waitForApiKeyInit();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Electron mode: use API key header
const apiKey = getApiKey();
if (apiKey) {
headers['X-API-Key'] = apiKey;
}
// Add session token header if available (web mode)
const sessionToken = getSessionToken();
if (sessionToken) {
headers['X-Session-Token'] = sessionToken;
}
const response = await fetch(`${serverUrl}/api/auth/status`, {
credentials: 'include', // Send HTTP-only session cookie
headers,
credentials: 'include',
signal: AbortSignal.timeout(5000),
cache: NO_STORE_CACHE_MODE,
});
// Any response means server is reachable
@@ -246,6 +279,14 @@ export function LoginView() {
const [state, dispatch] = useReducer(reducer, initialState);
const retryControllerRef = useRef<AbortController | null>(null);
// Initialize API key before checking session
// This ensures getApiKey() returns a valid value in checkAuthStatusSafe()
useEffect(() => {
initApiKey().catch((error) => {
console.warn('Failed to initialize API key:', error);
});
}, []);
// Run initial server/session check on mount.
// IMPORTANT: Do not "run once" via a ref guard here.
// In React StrictMode (dev), effects mount -> cleanup -> mount.

View File

@@ -15,6 +15,7 @@ import { TerminalSection } from './settings-view/terminal/terminal-section';
import { AudioSection } from './settings-view/audio/audio-section';
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
import { WorktreesSection } from './settings-view/worktrees';
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
import { AccountSection } from './settings-view/account';
import { SecuritySection } from './settings-view/security';
@@ -149,17 +150,19 @@ export function SettingsView() {
defaultSkipTests={defaultSkipTests}
enableDependencyBlocking={enableDependencyBlocking}
skipVerificationInAutoMode={skipVerificationInAutoMode}
useWorktrees={useWorktrees}
defaultPlanningMode={defaultPlanningMode}
defaultRequirePlanApproval={defaultRequirePlanApproval}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
onUseWorktreesChange={setUseWorktrees}
onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
/>
);
case 'worktrees':
return (
<WorktreesSection useWorktrees={useWorktrees} onUseWorktreesChange={setUseWorktrees} />
);
case 'account':
return <AccountSection />;
case 'security':

View File

@@ -1,15 +1,51 @@
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
import { LogOut, User } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
import { LogOut, User, Code2, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { logout } from '@/lib/http-api-client';
import { useAuthStore } from '@/store/auth-store';
import { useAppStore } from '@/store/app-store';
import {
useAvailableEditors,
useEffectiveDefaultEditor,
} from '@/components/views/board-view/worktree-panel/hooks/use-available-editors';
import { getEditorIcon } from '@/components/icons/editor-icons';
export function AccountSection() {
const navigate = useNavigate();
const [isLoggingOut, setIsLoggingOut] = useState(false);
// Editor settings
const { editors, isLoading: isLoadingEditors, isRefreshing, refresh } = useAvailableEditors();
const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand);
const setDefaultEditorCommand = useAppStore((s) => s.setDefaultEditorCommand);
// Use shared hook for effective default editor
const effectiveEditor = useEffectiveDefaultEditor(editors);
// Normalize Select value: if saved editor isn't found, show 'auto'
const hasSavedEditor =
!!defaultEditorCommand && editors.some((e) => e.command === defaultEditorCommand);
const selectValue = hasSavedEditor ? defaultEditorCommand : 'auto';
// Get icon component for the effective editor
const EffectiveEditorIcon = effectiveEditor ? getEditorIcon(effectiveEditor.command) : null;
const handleRefreshEditors = async () => {
await refresh();
toast.success('Editor list refreshed');
};
const handleLogout = async () => {
setIsLoggingOut(true);
try {
@@ -43,6 +79,81 @@ export function AccountSection() {
<p className="text-sm text-muted-foreground/80 ml-12">Manage your session and account.</p>
</div>
<div className="p-6 space-y-4">
{/* Default IDE */}
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border/30">
<div className="flex items-center gap-3.5 min-w-0">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-muted/50 to-muted/30 border border-border/30 flex items-center justify-center shrink-0">
<Code2 className="w-5 h-5 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground">Default IDE</p>
<p className="text-xs text-muted-foreground/70 mt-0.5">
Default IDE to use when opening branches or worktrees
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Select
value={selectValue}
onValueChange={(value) => setDefaultEditorCommand(value === 'auto' ? null : value)}
disabled={isLoadingEditors || isRefreshing || editors.length === 0}
>
<SelectTrigger className="w-[180px] shrink-0">
<SelectValue placeholder="Select editor">
{effectiveEditor ? (
<span className="flex items-center gap-2">
{EffectiveEditorIcon && <EffectiveEditorIcon className="w-4 h-4" />}
{effectiveEditor.name}
{selectValue === 'auto' && (
<span className="text-muted-foreground text-xs">(Auto)</span>
)}
</span>
) : (
'Select editor'
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">
<span className="flex items-center gap-2">
<Code2 className="w-4 h-4" />
Auto-detect
</span>
</SelectItem>
{editors.map((editor) => {
const Icon = getEditorIcon(editor.command);
return (
<SelectItem key={editor.command} value={editor.command}>
<span className="flex items-center gap-2">
<Icon className="w-4 h-4" />
{editor.name}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleRefreshEditors}
disabled={isRefreshing || isLoadingEditors}
className="shrink-0 h-9 w-9"
>
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Refresh available editors</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Logout */}
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border/30">
<div className="flex items-center gap-3.5 min-w-0">

View File

@@ -1,9 +1,40 @@
import { Button } from '@/components/ui/button';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { CheckCircle2, AlertCircle, RefreshCw, Bot, Cloud } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
import { OpenCodeIcon } from '@/components/ui/provider-icon';
export interface OpenCodeProviderInfo {
id: string;
name: string;
authenticated: boolean;
authMethod?: 'oauth' | 'api_key';
}
/**
* Provider display configuration
*/
const PROVIDER_DISPLAY: Record<string, string> = {
copilot: 'GitHub Copilot',
'github-copilot': 'GitHub Copilot',
anthropic: 'Anthropic',
openai: 'OpenAI',
openrouter: 'OpenRouter',
google: 'Google AI',
'amazon-bedrock': 'AWS Bedrock',
azure: 'Azure OpenAI',
ollama: 'Ollama',
lmstudio: 'LM Studio',
opencode: 'OpenCode',
xai: 'xAI',
deepseek: 'DeepSeek',
};
function getProviderDisplayName(provider: OpenCodeProviderInfo): string {
return PROVIDER_DISPLAY[provider.id] || provider.name || provider.id;
}
export type OpencodeAuthMethod =
| 'api_key_env' // ANTHROPIC_API_KEY or other provider env vars
| 'api_key' // Manually stored API key
@@ -38,6 +69,7 @@ function getAuthMethodLabel(method: OpencodeAuthMethod): string {
interface OpencodeCliStatusProps {
status: CliStatus | null;
authStatus?: OpencodeAuthStatus | null;
providers?: OpenCodeProviderInfo[];
isChecking: boolean;
onRefresh: () => void;
}
@@ -152,9 +184,11 @@ export function OpencodeModelConfigSkeleton() {
export function OpencodeCliStatus({
status,
authStatus,
providers = [],
isChecking,
onRefresh,
}: OpencodeCliStatusProps) {
const authenticatedProviders = providers.filter((p) => p.authenticated);
if (!status) return <OpencodeCliStatusSkeleton />;
return (
@@ -223,8 +257,8 @@ export function OpencodeCliStatus({
</div>
</div>
{/* Authentication Status */}
{authStatus?.authenticated ? (
{/* Authentication Status - consider both direct auth and provider auth */}
{authStatus?.authenticated || authenticatedProviders.length > 0 ? (
<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" />
@@ -232,24 +266,71 @@ export function OpencodeCliStatus({
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
<div className="text-xs text-emerald-400/70 mt-1.5">
<p>
Method:{' '}
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
</p>
{authStatus?.authenticated && authStatus.method !== 'none' ? (
<p>
Method:{' '}
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
</p>
) : authenticatedProviders.length > 0 ? (
<p>
Via {authenticatedProviders.length} connected provider
{authenticatedProviders.length !== 1 ? 's' : ''}
</p>
) : null}
</div>
</div>
</div>
) : (
<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">
<XCircle className="w-5 h-5 text-amber-500" />
<div className="flex items-start gap-3 p-4 rounded-xl bg-blue-500/10 border border-blue-500/20">
<div className="w-10 h-10 rounded-xl bg-blue-500/15 flex items-center justify-center border border-blue-500/20 shrink-0 mt-0.5">
<Bot className="w-5 h-5 text-blue-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">Not Authenticated</p>
<p className="text-xs text-amber-400/70 mt-1">
Run{' '}
<code className="font-mono bg-amber-500/10 px-1 rounded">opencode auth</code> or
set an API key to authenticate.
<p className="text-sm font-medium text-blue-400">OpenCode Free Tier Ready</p>
<p className="text-xs text-blue-400/70 mt-1">
Free OpenCode models work without login. Run{' '}
<code className="font-mono bg-blue-500/10 px-1 rounded">
opencode auth login
</code>{' '}
to connect providers like GitHub Copilot, Google AI, etc.
</p>
</div>
</div>
)}
{/* Dynamic Providers Connected */}
{authenticatedProviders.length > 0 && (
<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">
<Cloud 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">
{authenticatedProviders.length} Dynamic Provider
{authenticatedProviders.length !== 1 ? 's' : ''} Connected
</p>
<div className="flex flex-wrap gap-1.5 mt-2">
{authenticatedProviders.map((provider) => (
<Badge
key={provider.id}
variant="outline"
className="text-xs bg-emerald-500/10 text-emerald-400 border-emerald-500/30"
>
{getProviderDisplayName(provider)}
{provider.authMethod && (
<span className="ml-1 text-emerald-400/60">
({provider.authMethod === 'oauth' ? 'OAuth' : 'Key'})
</span>
)}
</Badge>
))}
</div>
<p className="text-xs text-emerald-400/60 mt-2">
Use{' '}
<code className="font-mono bg-emerald-500/10 px-1 rounded">
opencode auth login
</code>{' '}
to add more providers.
</p>
</div>
</div>

View File

@@ -14,6 +14,8 @@ import {
MessageSquareText,
User,
Shield,
Cpu,
GitBranch,
} from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import type { SettingsViewId } from '../hooks/use-settings-view';
@@ -37,6 +39,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
items: [
{ id: 'model-defaults', label: 'Model Defaults', icon: Workflow },
{ id: 'defaults', label: 'Feature Defaults', icon: FlaskConical },
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
{ id: 'api-keys', label: 'API Keys', icon: Key },
{

View File

@@ -3,7 +3,6 @@ import { Checkbox } from '@/components/ui/checkbox';
import {
FlaskConical,
TestTube,
GitBranch,
AlertCircle,
Zap,
ClipboardList,
@@ -27,13 +26,11 @@ interface FeatureDefaultsSectionProps {
defaultSkipTests: boolean;
enableDependencyBlocking: boolean;
skipVerificationInAutoMode: boolean;
useWorktrees: boolean;
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
onSkipVerificationInAutoModeChange: (value: boolean) => void;
onUseWorktreesChange: (value: boolean) => void;
onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
}
@@ -42,13 +39,11 @@ export function FeatureDefaultsSection({
defaultSkipTests,
enableDependencyBlocking,
skipVerificationInAutoMode,
useWorktrees,
defaultPlanningMode,
defaultRequirePlanApproval,
onDefaultSkipTestsChange,
onEnableDependencyBlockingChange,
onSkipVerificationInAutoModeChange,
onUseWorktreesChange,
onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange,
}: FeatureDefaultsSectionProps) {
@@ -256,33 +251,6 @@ export function FeatureDefaultsSection({
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Worktree Isolation Setting */}
<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="use-worktrees"
checked={useWorktrees}
onCheckedChange={(checked) => onUseWorktreesChange(checked === true)}
className="mt-1"
data-testid="use-worktrees-checkbox"
/>
<div className="space-y-1.5">
<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
</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>
</div>
</div>
</div>
</div>
);

View File

@@ -16,6 +16,7 @@ export type SettingsViewId =
| 'keyboard'
| 'audio'
| 'defaults'
| 'worktrees'
| 'account'
| 'security'
| 'danger';

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type {
@@ -25,18 +25,13 @@ import {
THINKING_LEVEL_LABELS,
REASONING_EFFORT_LEVELS,
REASONING_EFFORT_LABELS,
type ModelOption,
} from '@/components/views/board-view/shared/model-constants';
import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react';
import {
AnthropicIcon,
CursorIcon,
OpenAIIcon,
OpenCodeIcon,
DeepSeekIcon,
NovaIcon,
QwenIcon,
MistralIcon,
MetaIcon,
getProviderIconForModel,
} from '@/components/ui/provider-icon';
import { Button } from '@/components/ui/button';
@@ -51,6 +46,80 @@ import {
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
const OPENCODE_CLI_GROUP_LABEL = 'OpenCode CLI';
const OPENCODE_PROVIDER_FALLBACK = 'opencode';
const OPENCODE_PROVIDER_WORD_SEPARATOR = '-';
const OPENCODE_MODEL_ID_SEPARATOR = '/';
const OPENCODE_SECTION_GROUP_PADDING = 'pt-2';
const OPENCODE_STATIC_PROVIDER_LABELS: Record<string, string> = {
[OPENCODE_PROVIDER_FALLBACK]: 'OpenCode (Free)',
};
const OPENCODE_DYNAMIC_PROVIDER_LABELS: Record<string, string> = {
'github-copilot': 'GitHub Copilot',
'zai-coding-plan': 'Z.AI Coding Plan',
google: 'Google AI',
openai: 'OpenAI',
openrouter: 'OpenRouter',
anthropic: 'Anthropic',
xai: 'xAI',
deepseek: 'DeepSeek',
ollama: 'Ollama (Local)',
lmstudio: 'LM Studio (Local)',
azure: 'Azure OpenAI',
[OPENCODE_PROVIDER_FALLBACK]: 'OpenCode (Free)',
};
const OPENCODE_DYNAMIC_PROVIDER_ORDER = [
'github-copilot',
'google',
'openai',
'openrouter',
'anthropic',
'xai',
'deepseek',
'ollama',
'lmstudio',
'azure',
'zai-coding-plan',
];
const OPENCODE_SECTION_ORDER = ['free', 'dynamic'] as const;
const OPENCODE_SECTION_LABELS: Record<(typeof OPENCODE_SECTION_ORDER)[number], string> = {
free: 'Free Tier',
dynamic: 'Connected Providers',
};
const OPENCODE_STATIC_PROVIDER_BY_ID = new Map(
OPENCODE_MODELS.map((model) => [model.id, model.provider])
);
function formatProviderLabel(providerKey: string): string {
return providerKey
.split(OPENCODE_PROVIDER_WORD_SEPARATOR)
.map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word))
.join(' ');
}
function getOpencodeSectionKey(providerKey: string): (typeof OPENCODE_SECTION_ORDER)[number] {
if (providerKey === OPENCODE_PROVIDER_FALLBACK) {
return 'free';
}
return 'dynamic';
}
function getOpencodeGroupLabel(
providerKey: string,
sectionKey: (typeof OPENCODE_SECTION_ORDER)[number]
): string {
if (sectionKey === 'free') {
return OPENCODE_STATIC_PROVIDER_LABELS[providerKey] || 'OpenCode Free Tier';
}
return OPENCODE_DYNAMIC_PROVIDER_LABELS[providerKey] || formatProviderLabel(providerKey);
}
interface PhaseModelSelectorProps {
/** Label shown in full mode */
label?: string;
@@ -95,6 +164,7 @@ export function PhaseModelSelector({
codexModels,
codexModelsLoading,
fetchCodexModels,
dynamicOpencodeModels,
} = useAppStore();
// Extract model and thinking/reasoning levels from value
@@ -235,12 +305,30 @@ export function PhaseModelSelector({
const codexModel = transformedCodexModels.find((m) => m.id === selectedModel);
if (codexModel) return { ...codexModel, icon: OpenAIIcon };
// Check OpenCode models
// Check OpenCode models (static) - use dynamic icon resolution for provider-specific icons
const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel);
if (opencodeModel) return { ...opencodeModel, icon: OpenCodeIcon };
if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) };
// Check dynamic OpenCode models - use dynamic icon resolution for provider-specific icons
const dynamicModel = dynamicOpencodeModels.find((m) => m.id === selectedModel);
if (dynamicModel) {
return {
id: dynamicModel.id,
label: dynamicModel.name,
description: dynamicModel.description,
provider: 'opencode' as const,
icon: getProviderIconForModel(dynamicModel.id),
};
}
return null;
}, [selectedModel, selectedThinkingLevel, availableCursorModels, transformedCodexModels]);
}, [
selectedModel,
selectedThinkingLevel,
availableCursorModels,
transformedCodexModels,
dynamicOpencodeModels,
]);
// Compute grouped vs standalone Cursor models
const { groupedModels, standaloneCursorModels } = useMemo(() => {
@@ -275,13 +363,35 @@ export function PhaseModelSelector({
return { groupedModels: grouped, standaloneCursorModels: standalone };
}, [availableCursorModels, enabledCursorModels]);
// Combine static and dynamic OpenCode models
const allOpencodeModels: ModelOption[] = useMemo(() => {
// Start with static models
const staticModels = [...OPENCODE_MODELS];
// Add dynamic models (convert ModelDefinition to ModelOption)
const dynamicModelOptions: ModelOption[] = dynamicOpencodeModels.map((model) => ({
id: model.id,
label: model.name,
description: model.description,
badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Free' : undefined,
provider: 'opencode' as const,
}));
// Merge, avoiding duplicates (static models take precedence for same ID)
// In practice, static and dynamic IDs don't overlap
const staticIds = new Set(staticModels.map((m) => m.id));
const uniqueDynamic = dynamicModelOptions.filter((m) => !staticIds.has(m.id));
return [...staticModels, ...uniqueDynamic];
}, [dynamicOpencodeModels]);
// Group models
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
const favs: typeof CLAUDE_MODELS = [];
const cModels: typeof CLAUDE_MODELS = [];
const curModels: typeof CURSOR_MODELS = [];
const codModels: typeof transformedCodexModels = [];
const ocModels: typeof OPENCODE_MODELS = [];
const ocModels: ModelOption[] = [];
// Process Claude Models
CLAUDE_MODELS.forEach((model) => {
@@ -310,8 +420,8 @@ export function PhaseModelSelector({
}
});
// Process OpenCode Models
OPENCODE_MODELS.forEach((model) => {
// Process OpenCode Models (including dynamic)
allOpencodeModels.forEach((model) => {
if (favoriteModels.includes(model.id)) {
favs.push(model);
} else {
@@ -326,7 +436,95 @@ export function PhaseModelSelector({
codex: codModels,
opencode: ocModels,
};
}, [favoriteModels, availableCursorModels, transformedCodexModels]);
}, [favoriteModels, availableCursorModels, transformedCodexModels, allOpencodeModels]);
// Group OpenCode models by model type for better organization
const opencodeSections = useMemo(() => {
type OpencodeSectionKey = (typeof OPENCODE_SECTION_ORDER)[number];
type OpencodeGroup = { key: string; label: string; models: ModelOption[] };
type OpencodeSection = {
key: OpencodeSectionKey;
label: string;
showGroupLabels: boolean;
groups: OpencodeGroup[];
};
const sections: Record<OpencodeSectionKey, Record<string, OpencodeGroup>> = {
free: {},
dynamic: {},
};
const dynamicProviderById = new Map(
dynamicOpencodeModels.map((model) => [model.id, model.provider])
);
const resolveProviderKey = (modelId: string): string => {
const staticProvider = OPENCODE_STATIC_PROVIDER_BY_ID.get(modelId);
if (staticProvider) return staticProvider;
const dynamicProvider = dynamicProviderById.get(modelId);
if (dynamicProvider) return dynamicProvider;
return modelId.includes(OPENCODE_MODEL_ID_SEPARATOR)
? modelId.split(OPENCODE_MODEL_ID_SEPARATOR)[0]
: OPENCODE_PROVIDER_FALLBACK;
};
const addModelToGroup = (
sectionKey: OpencodeSectionKey,
providerKey: string,
model: ModelOption
) => {
if (!sections[sectionKey][providerKey]) {
sections[sectionKey][providerKey] = {
key: providerKey,
label: getOpencodeGroupLabel(providerKey, sectionKey),
models: [],
};
}
sections[sectionKey][providerKey].models.push(model);
};
opencode.forEach((model) => {
const providerKey = resolveProviderKey(model.id);
const sectionKey = getOpencodeSectionKey(providerKey);
addModelToGroup(sectionKey, providerKey, model);
});
const buildGroupList = (sectionKey: OpencodeSectionKey): OpencodeGroup[] => {
const groupMap = sections[sectionKey];
const priorityOrder = sectionKey === 'dynamic' ? OPENCODE_DYNAMIC_PROVIDER_ORDER : [];
const priorityMap = new Map(priorityOrder.map((provider, index) => [provider, index]));
return Object.keys(groupMap)
.sort((a, b) => {
const aPriority = priorityMap.get(a);
const bPriority = priorityMap.get(b);
if (aPriority !== undefined && bPriority !== undefined) {
return aPriority - bPriority;
}
if (aPriority !== undefined) return -1;
if (bPriority !== undefined) return 1;
return groupMap[a].label.localeCompare(groupMap[b].label);
})
.map((key) => groupMap[key]);
};
const builtSections = OPENCODE_SECTION_ORDER.map((sectionKey) => {
const groups = buildGroupList(sectionKey);
if (groups.length === 0) return null;
return {
key: sectionKey,
label: OPENCODE_SECTION_LABELS[sectionKey],
showGroupLabels: sectionKey !== 'free',
groups,
};
}).filter(Boolean) as OpencodeSection[];
return builtSections;
}, [opencode, dynamicOpencodeModels]);
// Render Codex model item with secondary popover for reasoning effort (only for models that support it)
const renderCodexModelItem = (model: (typeof transformedCodexModels)[0]) => {
@@ -992,9 +1190,32 @@ export function PhaseModelSelector({
</CommandGroup>
)}
{opencode.length > 0 && (
<CommandGroup heading="OpenCode Models">
{opencode.map((model) => renderOpencodeModelItem(model))}
{opencodeSections.length > 0 && (
<CommandGroup heading={OPENCODE_CLI_GROUP_LABEL}>
{opencodeSections.map((section, sectionIndex) => (
<Fragment key={section.key}>
<div className="px-2 pt-2 text-xs font-medium text-muted-foreground">
{section.label}
</div>
<div
className={cn(
'space-y-2',
section.key === 'dynamic' && OPENCODE_SECTION_GROUP_PADDING
)}
>
{section.groups.map((group) => (
<div key={group.key} className="space-y-1">
{section.showGroupLabels && (
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
{group.label}
</div>
)}
{group.models.map((model) => renderOpencodeModelItem(model))}
</div>
))}
</div>
</Fragment>
))}
</CommandGroup>
)}
</CommandList>

View File

@@ -1,6 +1,7 @@
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
@@ -8,20 +9,32 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Terminal, Cloud, Cpu, Brain, Github, Loader2, KeyRound, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { OpencodeModelId, OpencodeProvider, OpencodeModelConfig } from '@automaker/types';
import { Input } from '@/components/ui/input';
import type {
OpencodeModelId,
OpencodeProvider,
OpencodeModelConfig,
ModelDefinition,
} from '@automaker/types';
import { OPENCODE_MODELS, OPENCODE_MODEL_CONFIG_MAP } from '@automaker/types';
import type { OpenCodeProviderInfo } from '../cli-status/opencode-cli-status';
import {
OpenCodeIcon,
DeepSeekIcon,
QwenIcon,
NovaIcon,
AnthropicIcon,
OpenRouterIcon,
MistralIcon,
MetaIcon,
GeminiIcon,
OpenAIIcon,
GrokIcon,
getProviderIconForModel,
} from '@/components/ui/provider-icon';
import type { ComponentType } from 'react';
import { useEffect, useMemo, useRef, useState, type ComponentType } from 'react';
interface OpencodeModelConfigurationProps {
enabledOpencodeModels: OpencodeModelId[];
@@ -29,6 +42,12 @@ interface OpencodeModelConfigurationProps {
isSaving: boolean;
onDefaultModelChange: (model: OpencodeModelId) => void;
onModelToggle: (model: OpencodeModelId, enabled: boolean) => void;
providers?: OpenCodeProviderInfo[];
// Dynamic models
dynamicModels: ModelDefinition[];
enabledDynamicModelIds: string[];
onDynamicModelToggle: (modelId: string, enabled: boolean) => void;
isLoadingDynamicModels?: boolean;
}
/**
@@ -45,31 +64,144 @@ function getProviderLabel(provider: OpencodeProvider): string {
switch (provider) {
case 'opencode':
return 'OpenCode (Free)';
case 'amazon-bedrock-anthropic':
return 'Claude (Bedrock)';
case 'amazon-bedrock-deepseek':
return 'DeepSeek (Bedrock)';
case 'amazon-bedrock-amazon':
return 'Amazon Nova (Bedrock)';
case 'amazon-bedrock-meta':
return 'Meta Llama (Bedrock)';
case 'amazon-bedrock-mistral':
return 'Mistral (Bedrock)';
case 'amazon-bedrock-qwen':
return 'Qwen (Bedrock)';
default:
return provider;
}
}
/**
* Configuration for dynamic provider display
*/
const DYNAMIC_PROVIDER_CONFIG: Record<
string,
{ label: string; icon: ComponentType<{ className?: string }> }
> = {
'github-copilot': { label: 'GitHub Copilot', icon: Github },
google: { label: 'Google AI', icon: GeminiIcon },
openai: { label: 'OpenAI', icon: OpenAIIcon },
openrouter: { label: 'OpenRouter', icon: OpenRouterIcon },
anthropic: { label: 'Anthropic', icon: AnthropicIcon },
opencode: { label: 'OpenCode (Free)', icon: Terminal },
ollama: { label: 'Ollama (Local)', icon: Cpu },
lmstudio: { label: 'LM Studio (Local)', icon: Cpu },
azure: { label: 'Azure OpenAI', icon: Cloud },
'amazon-bedrock': { label: 'AWS Bedrock', icon: Cloud },
xai: { label: 'xAI', icon: GrokIcon },
deepseek: { label: 'DeepSeek', icon: Brain },
};
function getDynamicProviderConfig(providerId: string) {
return (
DYNAMIC_PROVIDER_CONFIG[providerId] || {
label: providerId.charAt(0).toUpperCase() + providerId.slice(1).replace(/-/g, ' '),
icon: Cloud,
}
);
}
const OPENCODE_AUTH_METHOD_LABELS: Record<string, string> = {
oauth: 'OAuth',
api_key: 'Key',
api: 'Key',
key: 'Key',
};
const OPENCODE_AUTH_METHOD_ICONS: Record<string, ComponentType<{ className?: string }>> = {
oauth: ShieldCheck,
api_key: KeyRound,
api: KeyRound,
key: KeyRound,
};
const OPENCODE_PROVIDER_FILTER_CLEAR_LABEL = 'Clear';
const OPENCODE_PROVIDER_FILTER_SEARCH_PLACEHOLDER = 'Search models...';
const OPENCODE_PROVIDER_FILTER_EMPTY_LABEL = 'No models match your filters.';
const OPENCODE_PROVIDER_FILTER_EMPTY_HINT = 'Try a different search or provider.';
const OPENCODE_PROVIDER_MODELS_EMPTY_LABEL = 'No models available yet.';
const OPENCODE_PROVIDER_MODELS_EMPTY_HINT = 'Connect or refresh OpenCode CLI to load models.';
const OPENCODE_DYNAMIC_MODELS_SECTION_LABEL = 'Dynamic Models (from OpenCode providers)';
const OPENCODE_SELECT_DYNAMIC_LABEL = 'Select all';
const OPENCODE_SELECT_STATIC_LABEL = 'Select all';
const OPENCODE_SELECT_ALL_CONTAINER_CLASS =
'flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-2.5 py-1 text-xs text-muted-foreground';
function formatProviderAuthLabel(provider?: OpenCodeProviderInfo): string | null {
if (!provider?.authMethod) return null;
return OPENCODE_AUTH_METHOD_LABELS[provider.authMethod] || provider.authMethod;
}
function getProviderAuthIcon(
provider?: OpenCodeProviderInfo
): ComponentType<{ className?: string }> | null {
if (!provider?.authMethod) return null;
return OPENCODE_AUTH_METHOD_ICONS[provider.authMethod] || null;
}
function getDynamicProviderBaseLabel(
providerId: string,
providerInfo: OpenCodeProviderInfo | undefined
): string {
const providerConfig = getDynamicProviderConfig(providerId);
return providerInfo?.name || providerConfig.label;
}
function getDynamicProviderLabel(
providerId: string,
providerInfo: OpenCodeProviderInfo | undefined
): string {
const providerConfig = getDynamicProviderConfig(providerId);
const baseLabel = providerInfo?.name || providerConfig.label;
const authLabel = formatProviderAuthLabel(providerInfo);
return authLabel ? `${baseLabel} (${authLabel})` : baseLabel;
}
function getSelectionState(
candidateIds: string[],
selectedIds: string[]
): boolean | 'indeterminate' {
if (candidateIds.length === 0) return false;
const allSelected = candidateIds.every((modelId) => selectedIds.includes(modelId));
if (allSelected) return true;
const anySelected = candidateIds.some((modelId) => selectedIds.includes(modelId));
return anySelected ? 'indeterminate' : false;
}
/**
* Group dynamic models by their provider
*/
function groupDynamicModelsByProvider(
models: ModelDefinition[]
): Record<string, ModelDefinition[]> {
return models.reduce(
(acc, model) => {
const provider = model.provider || 'unknown';
if (!acc[provider]) {
acc[provider] = [];
}
acc[provider].push(model);
return acc;
},
{} as Record<string, ModelDefinition[]>
);
}
function matchesDynamicModelQuery(model: ModelDefinition, query: string): boolean {
if (!query) return true;
const haystack = `${model.name} ${model.description} ${model.id}`.toLowerCase();
return haystack.includes(query);
}
export function OpencodeModelConfiguration({
enabledOpencodeModels,
opencodeDefaultModel,
isSaving,
onDefaultModelChange,
onModelToggle,
providers,
dynamicModels,
enabledDynamicModelIds,
onDynamicModelToggle,
isLoadingDynamicModels = false,
}: OpencodeModelConfigurationProps) {
// Group models by provider for organized display
// Group static models by provider for organized display
const modelsByProvider = OPENCODE_MODELS.reduce(
(acc, model) => {
if (!acc[model.provider]) {
@@ -81,17 +213,141 @@ export function OpencodeModelConfiguration({
{} as Record<OpencodeProvider, OpencodeModelConfig[]>
);
// Group dynamic models by provider
const dynamicModelsByProvider = groupDynamicModelsByProvider(dynamicModels);
const authenticatedProviders = (providers || []).filter((provider) => provider.authenticated);
const [dynamicProviderFilter, setDynamicProviderFilter] = useState<string | null>(null);
const hasInitializedDynamicProviderFilter = useRef(false);
const [dynamicProviderSearch, setDynamicProviderSearch] = useState('');
const normalizedDynamicSearch = dynamicProviderSearch.trim().toLowerCase();
const hasDynamicSearch = normalizedDynamicSearch.length > 0;
const allStaticModelIds = OPENCODE_MODELS.map((model) => model.id);
const selectableStaticModelIds = allStaticModelIds.filter(
(modelId) => modelId !== opencodeDefaultModel
);
const allDynamicModelIds = dynamicModels.map((model) => model.id);
const hasDynamicModels = allDynamicModelIds.length > 0;
const staticSelectState = getSelectionState(selectableStaticModelIds, enabledOpencodeModels);
// Order: Free tier first, then Claude, then others
const providerOrder: OpencodeProvider[] = [
'opencode',
'amazon-bedrock-anthropic',
'amazon-bedrock-deepseek',
'amazon-bedrock-amazon',
'amazon-bedrock-meta',
'amazon-bedrock-mistral',
'amazon-bedrock-qwen',
const providerOrder: OpencodeProvider[] = ['opencode'];
// Dynamic provider order (prioritize commonly used ones)
const dynamicProviderOrder = [
'github-copilot',
'google',
'openai',
'openrouter',
'anthropic',
'xai',
'deepseek',
'ollama',
'lmstudio',
'azure',
'amazon-bedrock',
'opencode', // Skip opencode in dynamic since it's in static
];
const sortedDynamicProviders = useMemo(() => {
const providerIndex = (providerId: string) => dynamicProviderOrder.indexOf(providerId);
const providerIds = new Set([
...Object.keys(dynamicModelsByProvider),
...(providers || []).map((provider) => provider.id),
]);
providerIds.delete('opencode'); // Don't show opencode twice
return Array.from(providerIds).sort((a, b) => {
const aIndex = providerIndex(a);
const bIndex = providerIndex(b);
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
return a.localeCompare(b);
});
}, [dynamicModelsByProvider, providers]);
useEffect(() => {
if (
dynamicProviderFilter &&
sortedDynamicProviders.length > 0 &&
!sortedDynamicProviders.includes(dynamicProviderFilter)
) {
setDynamicProviderFilter(sortedDynamicProviders[0]);
return;
}
if (
!hasInitializedDynamicProviderFilter.current &&
!dynamicProviderFilter &&
sortedDynamicProviders.length > 0
) {
hasInitializedDynamicProviderFilter.current = true;
setDynamicProviderFilter(sortedDynamicProviders[0]);
}
}, [dynamicProviderFilter, sortedDynamicProviders]);
const filteredDynamicProviders = useMemo(() => {
const baseProviders = dynamicProviderFilter ? [dynamicProviderFilter] : sortedDynamicProviders;
if (!hasDynamicSearch) {
return baseProviders;
}
return baseProviders.filter((providerId) => {
const models = dynamicModelsByProvider[providerId] || [];
return models.some((model) => matchesDynamicModelQuery(model, normalizedDynamicSearch));
});
}, [
dynamicModelsByProvider,
dynamicProviderFilter,
hasDynamicSearch,
normalizedDynamicSearch,
sortedDynamicProviders,
]);
const hasDynamicProviders = sortedDynamicProviders.length > 0;
const showDynamicProviderFilters = sortedDynamicProviders.length > 1;
const hasFilteredDynamicProviders = filteredDynamicProviders.length > 0;
const toggleDynamicProviderFilter = (providerId: string) => {
setDynamicProviderFilter((current) => (current === providerId ? current : providerId));
};
const toggleAllStaticModels = (checked: boolean) => {
if (checked) {
selectableStaticModelIds.forEach((modelId) => {
if (!enabledOpencodeModels.includes(modelId)) {
onModelToggle(modelId, true);
}
});
return;
}
selectableStaticModelIds.forEach((modelId) => {
if (enabledOpencodeModels.includes(modelId)) {
onModelToggle(modelId, false);
}
});
};
const toggleProviderDynamicModels = (modelIds: string[], checked: boolean) => {
if (checked) {
modelIds.forEach((modelId) => {
if (!enabledDynamicModelIds.includes(modelId)) {
onDynamicModelToggle(modelId, true);
}
});
return;
}
modelIds.forEach((modelId) => {
if (enabledDynamicModelIds.includes(modelId)) {
onDynamicModelToggle(modelId, false);
}
});
};
return (
<div
className={cn(
@@ -146,7 +402,21 @@ export function OpencodeModelConfiguration({
{/* Available Models grouped by provider */}
<div className="space-y-4">
<Label>Available Models</Label>
<div className="flex flex-wrap items-center justify-between gap-2">
<Label>Available Models</Label>
{selectableStaticModelIds.length > 0 && (
<div className={OPENCODE_SELECT_ALL_CONTAINER_CLASS}>
<Checkbox
checked={staticSelectState}
onCheckedChange={toggleAllStaticModels}
disabled={isSaving}
/>
<span>{OPENCODE_SELECT_STATIC_LABEL}</span>
</div>
)}
</div>
{/* Static models grouped by provider (Built-in) */}
{providerOrder.map((provider) => {
const models = modelsByProvider[provider];
if (!models || models.length === 0) return null;
@@ -217,6 +487,196 @@ export function OpencodeModelConfiguration({
</div>
);
})}
{/* Dynamic models from OpenCode providers */}
{(hasDynamicProviders || isLoadingDynamicModels) && (
<>
{/* Separator between static and dynamic models */}
<div className="border-t border-border/50 my-4" />
<div className="flex flex-wrap items-center justify-between gap-2 -mt-2 mb-2">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs text-muted-foreground">
{OPENCODE_DYNAMIC_MODELS_SECTION_LABEL}
</p>
{isLoadingDynamicModels && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="w-3 h-3 animate-spin" />
<span>Discovering...</span>
</div>
)}
</div>
</div>
{showDynamicProviderFilters && (
<div className="space-y-2">
<div className="flex flex-wrap gap-2 rounded-xl border border-border/60 bg-card/40 p-2">
{sortedDynamicProviders.map((providerId) => {
const providerInfo = authenticatedProviders.find(
(provider) => provider.id === providerId
);
const providerLabel = getDynamicProviderBaseLabel(providerId, providerInfo);
const providerConfig = getDynamicProviderConfig(providerId);
const ProviderIcon = providerConfig.icon;
const AuthIcon = getProviderAuthIcon(providerInfo);
const authLabel = formatProviderAuthLabel(providerInfo);
const isActive = dynamicProviderFilter === providerId;
const authBadgeClass = cn(
'inline-flex h-5 w-5 items-center justify-center rounded-full border border-transparent bg-transparent text-muted-foreground/80 transition-colors',
isActive && 'text-accent-foreground'
);
return (
<Button
key={providerId}
type="button"
size="sm"
variant="outline"
onClick={() => toggleDynamicProviderFilter(providerId)}
className={cn('text-xs', isActive && 'bg-accent text-accent-foreground')}
>
<span className="flex items-center gap-1.5">
<ProviderIcon className="w-3.5 h-3.5" />
<span>{providerLabel}</span>
{AuthIcon && authLabel && (
<span className={authBadgeClass}>
<AuthIcon className="w-2.5 h-2.5" />
<span className="sr-only">{authLabel}</span>
</span>
)}
</span>
</Button>
);
})}
</div>
</div>
)}
{hasDynamicProviders && (
<div className="flex flex-wrap items-center gap-2">
<Input
value={dynamicProviderSearch}
onChange={(event) => setDynamicProviderSearch(event.target.value)}
placeholder={OPENCODE_PROVIDER_FILTER_SEARCH_PLACEHOLDER}
className="h-8 text-xs"
/>
{dynamicProviderSearch && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setDynamicProviderSearch('')}
className="text-xs"
>
{OPENCODE_PROVIDER_FILTER_CLEAR_LABEL}
</Button>
)}
</div>
)}
{hasDynamicSearch && !hasFilteredDynamicProviders && (
<div className="rounded-xl border border-dashed border-border/60 bg-card/40 px-3 py-2 text-xs text-muted-foreground">
<p className="font-medium">{OPENCODE_PROVIDER_FILTER_EMPTY_LABEL}</p>
<p className="mt-1">{OPENCODE_PROVIDER_FILTER_EMPTY_HINT}</p>
</div>
)}
{filteredDynamicProviders.map((providerId) => {
const models = dynamicModelsByProvider[providerId] || [];
const providerConfig = getDynamicProviderConfig(providerId);
const providerInfo = authenticatedProviders.find(
(provider) => provider.id === providerId
);
const providerLabel = getDynamicProviderLabel(providerId, providerInfo);
const DynamicProviderIcon = providerConfig.icon;
const filteredModels = hasDynamicSearch
? models.filter((model) =>
matchesDynamicModelQuery(model, normalizedDynamicSearch)
)
: models;
if (hasDynamicSearch && filteredModels.length === 0) {
return null;
}
return (
<div key={`dynamic-${providerId}`} className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<DynamicProviderIcon className="w-4 h-4" />
<span className="font-medium">{providerLabel}</span>
<Badge
variant="outline"
className="text-xs bg-blue-500/10 text-blue-500 border-blue-500/30"
>
Dynamic
</Badge>
</div>
{models.length > 0 && (
<div className={OPENCODE_SELECT_ALL_CONTAINER_CLASS}>
<Checkbox
checked={getSelectionState(
models.map((model) => model.id),
enabledDynamicModelIds
)}
onCheckedChange={(checked) =>
toggleProviderDynamicModels(
models.map((model) => model.id),
checked
)
}
disabled={isSaving}
/>
<span>{OPENCODE_SELECT_DYNAMIC_LABEL}</span>
</div>
)}
</div>
<div className="grid gap-2">
{filteredModels.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/60 bg-card/40 px-3 py-2 text-xs text-muted-foreground">
<p className="font-medium">{OPENCODE_PROVIDER_MODELS_EMPTY_LABEL}</p>
<p className="mt-1">{OPENCODE_PROVIDER_MODELS_EMPTY_HINT}</p>
</div>
) : (
filteredModels.map((model) => {
const isEnabled = enabledDynamicModelIds.includes(model.id);
return (
<div
key={model.id}
className="flex items-center justify-between p-3 rounded-xl border border-border/50 bg-card/50 hover:bg-accent/30 transition-colors"
>
<div className="flex items-center gap-3">
<Checkbox
checked={isEnabled}
onCheckedChange={(checked) =>
onDynamicModelToggle(model.id, !!checked)
}
disabled={isSaving}
/>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{model.name}</span>
{model.supportsVision && (
<Badge variant="outline" className="text-xs">
Vision
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{model.description}
</p>
</div>
</div>
</div>
);
})
)}
</div>
</div>
);
})}
</>
)}
</div>
</div>
</div>

View File

@@ -1,19 +1,18 @@
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import {
OpencodeCliStatus,
OpencodeCliStatusSkeleton,
OpencodeModelConfigSkeleton,
} from '../cli-status/opencode-cli-status';
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status';
import { OpencodeModelConfiguration } from './opencode-model-configuration';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
import type { CliStatus as SharedCliStatus } from '../shared/types';
import type { OpencodeModelId } from '@automaker/types';
import type { OpencodeAuthStatus } from '../cli-status/opencode-cli-status';
import type { OpencodeAuthStatus, OpenCodeProviderInfo } from '../cli-status/opencode-cli-status';
const logger = createLogger('OpencodeSettings');
const OPENCODE_PROVIDER_ID = 'opencode';
const OPENCODE_PROVIDER_SIGNATURE_SEPARATOR = '|';
const OPENCODE_STATIC_MODEL_PROVIDERS = new Set([OPENCODE_PROVIDER_ID]);
export function OpencodeSettingsTab() {
const {
@@ -21,15 +20,22 @@ export function OpencodeSettingsTab() {
opencodeDefaultModel,
setOpencodeDefaultModel,
toggleOpencodeModel,
setDynamicOpencodeModels,
dynamicOpencodeModels,
enabledDynamicModelIds,
toggleDynamicModel,
cachedOpencodeProviders,
setCachedOpencodeProviders,
} = useAppStore();
const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [isLoadingDynamicModels, setIsLoadingDynamicModels] = useState(false);
const [cliStatus, setCliStatus] = useState<SharedCliStatus | null>(null);
const [authStatus, setAuthStatus] = useState<OpencodeAuthStatus | null>(null);
const [isSaving, setIsSaving] = useState(false);
const providerRefreshSignatureRef = useRef<string>('');
// Load OpenCode CLI status on mount
// Phase 1: Load CLI status quickly on mount
useEffect(() => {
const checkOpencodeStatus = async () => {
setIsCheckingOpencodeCli(true);
@@ -46,7 +52,6 @@ export function OpencodeSettingsTab() {
recommendation: result.recommendation,
installCommands: result.installCommands,
});
// Set auth status if available
if (result.auth) {
setAuthStatus({
authenticated: result.auth.authenticated,
@@ -57,7 +62,6 @@ export function OpencodeSettingsTab() {
});
}
} else {
// Fallback for web mode or when API is not available
setCliStatus({
success: false,
status: 'not_installed',
@@ -73,14 +77,111 @@ export function OpencodeSettingsTab() {
});
} finally {
setIsCheckingOpencodeCli(false);
setIsInitialLoading(false);
}
};
checkOpencodeStatus();
}, []);
// Phase 2: Load dynamic models and providers in background (only if not cached)
useEffect(() => {
const loadDynamicContent = async () => {
const api = getElectronAPI();
const isInstalled = cliStatus?.success && cliStatus?.status === 'installed';
if (!isInstalled || !api?.setup) return;
// Skip if already have cached data
const needsProviders = cachedOpencodeProviders.length === 0;
const needsModels = dynamicOpencodeModels.length === 0;
if (!needsProviders && !needsModels) return;
setIsLoadingDynamicModels(true);
try {
// Load providers if needed
if (needsProviders && api.setup.getOpencodeProviders) {
const providersResult = await api.setup.getOpencodeProviders();
if (providersResult.success && providersResult.providers) {
setCachedOpencodeProviders(providersResult.providers);
}
}
// Load models if needed
if (needsModels && api.setup.getOpencodeModels) {
const modelsResult = await api.setup.getOpencodeModels();
if (modelsResult.success && modelsResult.models) {
setDynamicOpencodeModels(modelsResult.models);
}
}
} catch (error) {
logger.error('Failed to load dynamic content:', error);
} finally {
setIsLoadingDynamicModels(false);
}
};
loadDynamicContent();
}, [cliStatus?.success, cliStatus?.status]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const refreshModelsForNewProviders = async () => {
const api = getElectronAPI();
const isInstalled = cliStatus?.success && cliStatus?.status === 'installed';
if (!isInstalled || !api?.setup?.refreshOpencodeModels) return;
if (isLoadingDynamicModels) return;
const authenticatedProviders = cachedOpencodeProviders
.filter((provider) => provider.authenticated)
.map((provider) => provider.id)
.filter((providerId) => !OPENCODE_STATIC_MODEL_PROVIDERS.has(providerId));
if (authenticatedProviders.length === 0) {
providerRefreshSignatureRef.current = '';
return;
}
const dynamicProviderIds = new Set(
dynamicOpencodeModels.map((model) => model.provider).filter(Boolean)
);
const missingProviders = authenticatedProviders.filter(
(providerId) => !dynamicProviderIds.has(providerId)
);
if (missingProviders.length === 0) {
providerRefreshSignatureRef.current = '';
return;
}
const signature = [...missingProviders].sort().join(OPENCODE_PROVIDER_SIGNATURE_SEPARATOR);
if (providerRefreshSignatureRef.current === signature) return;
providerRefreshSignatureRef.current = signature;
setIsLoadingDynamicModels(true);
try {
const modelsResult = await api.setup.refreshOpencodeModels();
if (modelsResult.success && modelsResult.models) {
setDynamicOpencodeModels(modelsResult.models);
}
} catch (error) {
logger.error('Failed to refresh OpenCode models for new providers:', error);
} finally {
setIsLoadingDynamicModels(false);
}
};
refreshModelsForNewProviders();
}, [
cachedOpencodeProviders,
dynamicOpencodeModels,
cliStatus?.success,
cliStatus?.status,
isLoadingDynamicModels,
setDynamicOpencodeModels,
]);
const handleRefreshOpencodeCli = useCallback(async () => {
setIsCheckingOpencodeCli(true);
setIsLoadingDynamicModels(true);
try {
const api = getElectronAPI();
if (api?.setup?.getOpencodeStatus) {
@@ -94,7 +195,6 @@ export function OpencodeSettingsTab() {
recommendation: result.recommendation,
installCommands: result.installCommands,
});
// Update auth status if available
if (result.auth) {
setAuthStatus({
authenticated: result.auth.authenticated,
@@ -104,14 +204,35 @@ export function OpencodeSettingsTab() {
hasOAuthToken: result.auth.hasOAuthToken,
});
}
if (result.installed) {
// Refresh providers
if (api?.setup?.getOpencodeProviders) {
const providersResult = await api.setup.getOpencodeProviders();
if (providersResult.success && providersResult.providers) {
setCachedOpencodeProviders(providersResult.providers);
}
}
// Refresh dynamic models
if (api?.setup?.refreshOpencodeModels) {
const modelsResult = await api.setup.refreshOpencodeModels();
if (modelsResult.success && modelsResult.models) {
setDynamicOpencodeModels(modelsResult.models);
}
}
toast.success('OpenCode CLI refreshed');
}
}
} catch (error) {
logger.error('Failed to refresh OpenCode CLI status:', error);
toast.error('Failed to refresh OpenCode CLI status');
} finally {
setIsCheckingOpencodeCli(false);
setIsLoadingDynamicModels(false);
}
}, []);
}, [setDynamicOpencodeModels, setCachedOpencodeProviders]);
const handleDefaultModelChange = useCallback(
(model: OpencodeModelId) => {
@@ -142,12 +263,25 @@ export function OpencodeSettingsTab() {
[toggleOpencodeModel]
);
// Show loading skeleton during initial load
if (isInitialLoading) {
const handleDynamicModelToggle = useCallback(
(modelId: string, enabled: boolean) => {
setIsSaving(true);
try {
toggleDynamicModel(modelId, enabled);
} catch (error) {
toast.error('Failed to update dynamic model');
} finally {
setIsSaving(false);
}
},
[toggleDynamicModel]
);
// Show skeleton only while checking CLI status initially
if (!cliStatus && isCheckingOpencodeCli) {
return (
<div className="space-y-6">
<OpencodeCliStatusSkeleton />
<OpencodeModelConfigSkeleton />
</div>
);
}
@@ -159,6 +293,7 @@ export function OpencodeSettingsTab() {
<OpencodeCliStatus
status={cliStatus}
authStatus={authStatus}
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
isChecking={isCheckingOpencodeCli}
onRefresh={handleRefreshOpencodeCli}
/>
@@ -171,6 +306,11 @@ export function OpencodeSettingsTab() {
isSaving={isSaving}
onDefaultModelChange={handleDefaultModelChange}
onModelToggle={handleModelToggle}
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
dynamicModels={dynamicOpencodeModels}
enabledDynamicModelIds={enabledDynamicModelIds}
onDynamicModelToggle={handleDynamicModelToggle}
isLoadingDynamicModels={isLoadingDynamicModels}
/>
)}
</div>

View File

@@ -0,0 +1 @@
export { WorktreesSection } from './worktrees-section';

View File

@@ -0,0 +1,430 @@
import { useState, useEffect, useCallback } from 'react';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
import {
GitBranch,
Terminal,
FileCode,
Save,
RotateCcw,
Trash2,
Loader2,
PanelBottomClose,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
interface WorktreesSectionProps {
useWorktrees: boolean;
onUseWorktreesChange: (value: boolean) => void;
}
interface InitScriptResponse {
success: boolean;
exists: boolean;
content: string;
path: string;
error?: string;
}
export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) {
const currentProject = useAppStore((s) => s.currentProject);
const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator);
const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator);
const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch);
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
const [scriptContent, setScriptContent] = useState('');
const [originalContent, setOriginalContent] = useState('');
const [scriptExists, setScriptExists] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Get the current show indicator setting
const showIndicator = currentProject?.path
? getShowInitScriptIndicator(currentProject.path)
: true;
// Get the default delete branch setting
const defaultDeleteBranch = currentProject?.path
? getDefaultDeleteBranch(currentProject.path)
: false;
// Get the auto-dismiss setting
const autoDismiss = currentProject?.path
? getAutoDismissInitScriptIndicator(currentProject.path)
: true;
// Check if there are unsaved changes
const hasChanges = scriptContent !== originalContent;
// Load init script content when project changes
useEffect(() => {
if (!currentProject?.path) {
setScriptContent('');
setOriginalContent('');
setScriptExists(false);
setIsLoading(false);
return;
}
const loadInitScript = async () => {
setIsLoading(true);
try {
const response = await apiGet<InitScriptResponse>(
`/api/worktree/init-script?projectPath=${encodeURIComponent(currentProject.path)}`
);
if (response.success) {
const content = response.content || '';
setScriptContent(content);
setOriginalContent(content);
setScriptExists(response.exists);
}
} catch (error) {
console.error('Failed to load init script:', error);
} finally {
setIsLoading(false);
}
};
loadInitScript();
}, [currentProject?.path]);
// Save script
const handleSave = useCallback(async () => {
if (!currentProject?.path) return;
setIsSaving(true);
try {
const response = await apiPut<{ success: boolean; error?: string }>(
'/api/worktree/init-script',
{
projectPath: currentProject.path,
content: scriptContent,
}
);
if (response.success) {
setOriginalContent(scriptContent);
setScriptExists(true);
toast.success('Init script saved');
} else {
toast.error('Failed to save init script', {
description: response.error,
});
}
} catch (error) {
console.error('Failed to save init script:', error);
toast.error('Failed to save init script');
} finally {
setIsSaving(false);
}
}, [currentProject?.path, scriptContent]);
// Reset to original content
const handleReset = useCallback(() => {
setScriptContent(originalContent);
}, [originalContent]);
// Delete script
const handleDelete = useCallback(async () => {
if (!currentProject?.path) return;
setIsDeleting(true);
try {
const response = await apiDelete<{ success: boolean; error?: string }>(
'/api/worktree/init-script',
{
body: { projectPath: currentProject.path },
}
);
if (response.success) {
setScriptContent('');
setOriginalContent('');
setScriptExists(false);
toast.success('Init script deleted');
} else {
toast.error('Failed to delete init script', {
description: response.error,
});
}
} catch (error) {
console.error('Failed to delete init script:', error);
toast.error('Failed to delete init script');
} finally {
setIsDeleting(false);
}
}, [currentProject?.path]);
// Handle content change (no auto-save)
const handleContentChange = useCallback((value: string) => {
setScriptContent(value);
}, []);
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'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/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">
<GitBranch className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Worktrees</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure git worktree isolation and initialization scripts.
</p>
</div>
<div className="p-6 space-y-5">
{/* Enable Worktrees Toggle */}
<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="use-worktrees"
checked={useWorktrees}
onCheckedChange={(checked) => onUseWorktreesChange(checked === true)}
className="mt-1"
data-testid="use-worktrees-checkbox"
/>
<div className="space-y-1.5">
<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
</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>
</div>
</div>
{/* Show Init Script Indicator Toggle */}
{currentProject && (
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 mt-4">
<Checkbox
id="show-init-script-indicator"
checked={showIndicator}
onCheckedChange={async (checked) => {
if (currentProject?.path) {
const value = checked === true;
setShowInitScriptIndicator(currentProject.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(currentProject.path, {
showInitScriptIndicator: value,
});
} catch (error) {
console.error('Failed to persist showInitScriptIndicator:', error);
}
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="show-init-script-indicator"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<PanelBottomClose className="w-4 h-4 text-brand-500" />
Show Init Script Indicator
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Display a floating panel in the bottom-right corner showing init script execution
status and output when a worktree is created.
</p>
</div>
</div>
)}
{/* Auto-dismiss Init Script Indicator Toggle */}
{currentProject && showIndicator && (
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 ml-6">
<Checkbox
id="auto-dismiss-indicator"
checked={autoDismiss}
onCheckedChange={async (checked) => {
if (currentProject?.path) {
const value = checked === true;
setAutoDismissInitScriptIndicator(currentProject.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(currentProject.path, {
autoDismissInitScriptIndicator: value,
});
} catch (error) {
console.error('Failed to persist autoDismissInitScriptIndicator:', error);
}
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="auto-dismiss-indicator"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
Auto-dismiss After Completion
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Automatically hide the indicator 5 seconds after the script completes.
</p>
</div>
</div>
)}
{/* Default Delete Branch Toggle */}
{currentProject && (
<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-delete-branch"
checked={defaultDeleteBranch}
onCheckedChange={async (checked) => {
if (currentProject?.path) {
const value = checked === true;
setDefaultDeleteBranch(currentProject.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(currentProject.path, {
defaultDeleteBranch: value,
});
} catch (error) {
console.error('Failed to persist defaultDeleteBranch:', error);
}
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="default-delete-branch"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Trash2 className="w-4 h-4 text-brand-500" />
Delete Branch by Default
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When deleting a worktree, automatically check the "Also delete the branch" option.
</p>
</div>
</div>
)}
{/* Separator */}
<div className="border-t border-border/30" />
{/* Init Script Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-brand-500" />
<Label className="text-foreground font-medium">Initialization Script</Label>
</div>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash
on Windows for cross-platform compatibility.
</p>
{currentProject ? (
<>
{/* File path indicator */}
<div className="flex items-center gap-2 text-xs text-muted-foreground/60">
<FileCode className="w-3.5 h-3.5" />
<code className="font-mono">.automaker/worktree-init.sh</code>
{hasChanges && (
<span className="text-amber-500 font-medium">(unsaved changes)</span>
)}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : (
<>
<ShellSyntaxEditor
value={scriptContent}
onChange={handleContentChange}
placeholder={`# Example initialization commands
npm install
# Or use pnpm
# pnpm install
# Copy environment file
# cp .env.example .env`}
minHeight="200px"
maxHeight="500px"
data-testid="init-script-editor"
/>
{/* Action buttons */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDelete}
disabled={!scriptExists || isSaving || isDeleting}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
{isDeleting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
Delete
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
{isSaving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Save className="w-3.5 h-3.5" />
)}
Save
</Button>
</div>
</>
)}
</>
) : (
<div className="text-sm text-muted-foreground/60 py-4 text-center">
Select a project to configure the init script.
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -182,7 +182,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP
<div>
<p className="font-medium text-foreground">This step is optional</p>
<p className="text-sm text-muted-foreground mt-1">
Configure OpenCode CLI for access to free tier models and AWS Bedrock models. You
Configure OpenCode CLI for access to free tier models and connected providers. You
can skip this and use other providers, or configure it later in Settings.
</p>
</div>
@@ -241,7 +241,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP
<div className="flex-1">
<p className="font-medium text-foreground">OpenCode CLI not found</p>
<p className="text-sm text-muted-foreground mt-1">
Install the OpenCode CLI to use free tier and AWS Bedrock models.
Install the OpenCode CLI to use free tier models and connected providers.
</p>
</div>
</div>

View File

@@ -1119,7 +1119,7 @@ function OpencodeContent() {
<div className="flex-1">
<p className="font-medium text-foreground">OpenCode CLI not found</p>
<p className="text-sm text-muted-foreground mt-1">
Install the OpenCode CLI for free tier and AWS Bedrock models.
Install the OpenCode CLI for free tier models and connected providers.
</p>
</div>
</div>

View File

@@ -44,6 +44,7 @@ import { getElectronAPI } from '@/lib/electron';
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
const logger = createLogger('Terminal');
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
// Font size constraints
const MIN_FONT_SIZE = 8;
@@ -504,6 +505,7 @@ export function TerminalPanel({
const response = await fetch(`${serverUrl}/api/auth/token`, {
headers,
credentials: 'include',
cache: NO_STORE_CACHE_MODE,
});
if (!response.ok) {

View File

@@ -0,0 +1,79 @@
import { useEffect } from 'react';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
import { pathsEqual } from '@/lib/utils';
interface InitScriptStartedPayload {
projectPath: string;
worktreePath: string;
branch: string;
}
interface InitScriptOutputPayload {
projectPath: string;
branch: string;
type: 'stdout' | 'stderr';
content: string;
}
interface InitScriptCompletedPayload {
projectPath: string;
worktreePath: string;
branch: string;
success: boolean;
exitCode?: number;
error?: string;
}
/**
* Hook to subscribe to init script WebSocket events and update the store.
* Should be used in a component that's always mounted (e.g., board-view).
*/
export function useInitScriptEvents(projectPath: string | null) {
const setInitScriptState = useAppStore((s) => s.setInitScriptState);
const appendInitScriptOutput = useAppStore((s) => s.appendInitScriptOutput);
useEffect(() => {
if (!projectPath) return;
const api = getHttpApiClient();
const unsubscribe = api.worktree.onInitScriptEvent((event) => {
const payload = event.payload as
| InitScriptStartedPayload
| InitScriptOutputPayload
| InitScriptCompletedPayload;
// Only handle events for the current project (use pathsEqual for cross-platform path comparison)
if (!pathsEqual(payload.projectPath, projectPath)) return;
switch (event.type) {
case 'worktree:init-started': {
const startPayload = payload as InitScriptStartedPayload;
setInitScriptState(projectPath, startPayload.branch, {
status: 'running',
branch: startPayload.branch,
output: [],
error: undefined,
});
break;
}
case 'worktree:init-output': {
const outputPayload = payload as InitScriptOutputPayload;
appendInitScriptOutput(projectPath, outputPayload.branch, outputPayload.content);
break;
}
case 'worktree:init-completed': {
const completePayload = payload as InitScriptCompletedPayload;
setInitScriptState(projectPath, completePayload.branch, {
status: completePayload.success ? 'success' : 'failed',
error: completePayload.error,
});
break;
}
}
});
return unsubscribe;
}, [projectPath, setInitScriptState, appendInitScriptOutput]);
}

View File

@@ -18,6 +18,11 @@ export function useProjectSettingsLoader() {
const setCardBorderOpacity = useAppStore((state) => state.setCardBorderOpacity);
const setHideScrollbar = useAppStore((state) => state.setHideScrollbar);
const setWorktreePanelVisible = useAppStore((state) => state.setWorktreePanelVisible);
const setShowInitScriptIndicator = useAppStore((state) => state.setShowInitScriptIndicator);
const setDefaultDeleteBranch = useAppStore((state) => state.setDefaultDeleteBranch);
const setAutoDismissInitScriptIndicator = useAppStore(
(state) => state.setAutoDismissInitScriptIndicator
);
const loadingRef = useRef<string | null>(null);
const currentProjectRef = useRef<string | null>(null);
@@ -78,6 +83,27 @@ export function useProjectSettingsLoader() {
if (result.settings.worktreePanelVisible !== undefined) {
setWorktreePanelVisible(requestedProjectPath, result.settings.worktreePanelVisible);
}
// Apply showInitScriptIndicator if present
if (result.settings.showInitScriptIndicator !== undefined) {
setShowInitScriptIndicator(
requestedProjectPath,
result.settings.showInitScriptIndicator
);
}
// Apply defaultDeleteBranch if present
if (result.settings.defaultDeleteBranch !== undefined) {
setDefaultDeleteBranch(requestedProjectPath, result.settings.defaultDeleteBranch);
}
// Apply autoDismissInitScriptIndicator if present
if (result.settings.autoDismissInitScriptIndicator !== undefined) {
setAutoDismissInitScriptIndicator(
requestedProjectPath,
result.settings.autoDismissInitScriptIndicator
);
}
}
} catch (error) {
console.error('Failed to load project settings:', error);

View File

@@ -28,7 +28,11 @@ import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client';
import { getItem, setItem } from '@/lib/storage';
import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import type { GlobalSettings } from '@automaker/types';
import {
DEFAULT_OPENCODE_MODEL,
getAllOpencodeModelIds,
type GlobalSettings,
} from '@automaker/types';
const logger = createLogger('SettingsMigration');
@@ -152,6 +156,10 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
phaseModels: state.phaseModels as GlobalSettings['phaseModels'],
enabledCursorModels: state.enabledCursorModels as GlobalSettings['enabledCursorModels'],
cursorDefaultModel: state.cursorDefaultModel as GlobalSettings['cursorDefaultModel'],
enabledOpencodeModels: state.enabledOpencodeModels as GlobalSettings['enabledOpencodeModels'],
opencodeDefaultModel: state.opencodeDefaultModel as GlobalSettings['opencodeDefaultModel'],
enabledDynamicModelIds:
state.enabledDynamicModelIds as GlobalSettings['enabledDynamicModelIds'],
autoLoadClaudeMd: state.autoLoadClaudeMd as boolean,
keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'],
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
@@ -495,6 +503,27 @@ export function useSettingsMigration(): MigrationState {
*/
export function hydrateStoreFromSettings(settings: GlobalSettings): void {
const current = useAppStore.getState();
const validOpencodeModelIds = new Set(getAllOpencodeModelIds());
const incomingEnabledOpencodeModels =
settings.enabledOpencodeModels ?? current.enabledOpencodeModels;
const sanitizedOpencodeDefaultModel = validOpencodeModelIds.has(
settings.opencodeDefaultModel ?? current.opencodeDefaultModel
)
? (settings.opencodeDefaultModel ?? current.opencodeDefaultModel)
: DEFAULT_OPENCODE_MODEL;
const sanitizedEnabledOpencodeModels = Array.from(
new Set(incomingEnabledOpencodeModels.filter((modelId) => validOpencodeModelIds.has(modelId)))
);
if (!sanitizedEnabledOpencodeModels.includes(sanitizedOpencodeDefaultModel)) {
sanitizedEnabledOpencodeModels.push(sanitizedOpencodeDefaultModel);
}
const persistedDynamicModelIds =
settings.enabledDynamicModelIds ?? current.enabledDynamicModelIds;
const sanitizedDynamicModelIds = persistedDynamicModelIds.filter(
(modelId) => !modelId.startsWith('amazon-bedrock/')
);
// Convert ProjectRef[] to Project[] (minimal data, features will be loaded separately)
const projects = (settings.projects ?? []).map((ref) => ({
@@ -517,8 +546,9 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
}
// Save theme to localStorage for fallback when server settings aren't available
if (settings.theme) {
setItem(THEME_STORAGE_KEY, settings.theme);
const storedTheme = (currentProject?.theme as string | undefined) || settings.theme;
if (storedTheme) {
setItem(THEME_STORAGE_KEY, storedTheme);
}
useAppStore.setState({
@@ -538,6 +568,9 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
phaseModels: settings.phaseModels ?? current.phaseModels,
enabledCursorModels: settings.enabledCursorModels ?? current.enabledCursorModels,
cursorDefaultModel: settings.cursorDefaultModel ?? 'auto',
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
enabledDynamicModelIds: sanitizedDynamicModelIds,
autoLoadClaudeMd: settings.autoLoadClaudeMd ?? false,
skipSandboxWarning: settings.skipSandboxWarning ?? false,
keyboardShortcuts: {
@@ -591,6 +624,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
enhancementModel: state.enhancementModel,
validationModel: state.validationModel,
phaseModels: state.phaseModels,
enabledDynamicModelIds: state.enabledDynamicModelIds,
autoLoadClaudeMd: state.autoLoadClaudeMd,
skipSandboxWarning: state.skipSandboxWarning,
keyboardShortcuts: state.keyboardShortcuts,

View File

@@ -19,7 +19,11 @@ import { useAppStore, type ThemeMode, THEME_STORAGE_KEY } from '@/store/app-stor
import { useSetupStore } from '@/store/setup-store';
import { useAuthStore } from '@/store/auth-store';
import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration';
import type { GlobalSettings } from '@automaker/types';
import {
DEFAULT_OPENCODE_MODEL,
getAllOpencodeModelIds,
type GlobalSettings,
} from '@automaker/types';
const logger = createLogger('SettingsSync');
@@ -44,9 +48,13 @@ const SETTINGS_FIELDS_TO_SYNC = [
'phaseModels',
'enabledCursorModels',
'cursorDefaultModel',
'enabledOpencodeModels',
'opencodeDefaultModel',
'enabledDynamicModelIds',
'autoLoadClaudeMd',
'keyboardShortcuts',
'mcpServers',
'defaultEditorCommand',
'promptCustomization',
'projects',
'trashedProjects',
@@ -89,6 +97,7 @@ export function useSettingsSync(): SettingsSyncState {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const authChecked = useAuthStore((s) => s.authChecked);
const settingsLoaded = useAuthStore((s) => s.settingsLoaded);
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSyncedRef = useRef<string>('');
@@ -117,9 +126,17 @@ export function useSettingsSync(): SettingsSyncState {
// Debounced sync function
const syncToServer = useCallback(async () => {
try {
// Never sync when not authenticated (prevents overwriting server settings during logout/login transitions)
// Never sync when not authenticated or settings not loaded
// The settingsLoaded flag ensures we don't sync default empty state before hydration
const auth = useAuthStore.getState();
if (!auth.authChecked || !auth.isAuthenticated) {
logger.debug('syncToServer check:', {
authChecked: auth.authChecked,
isAuthenticated: auth.isAuthenticated,
settingsLoaded: auth.settingsLoaded,
projectsCount: useAppStore.getState().projects?.length ?? 0,
});
if (!auth.authChecked || !auth.isAuthenticated || !auth.settingsLoaded) {
logger.debug('Sync skipped: not authenticated or settings not loaded');
return;
}
@@ -127,6 +144,8 @@ export function useSettingsSync(): SettingsSyncState {
const api = getHttpApiClient();
const appState = useAppStore.getState();
logger.debug('Syncing to server:', { projectsCount: appState.projects?.length ?? 0 });
// Build updates object from current state
const updates: Record<string, unknown> = {};
for (const field of SETTINGS_FIELDS_TO_SYNC) {
@@ -147,10 +166,13 @@ export function useSettingsSync(): SettingsSyncState {
// Create a hash of the updates to avoid redundant syncs
const updateHash = JSON.stringify(updates);
if (updateHash === lastSyncedRef.current) {
logger.debug('Sync skipped: no changes');
setState((s) => ({ ...s, syncing: false }));
return;
}
logger.info('Sending settings update:', { projects: updates.projects });
const result = await api.settings.updateGlobal(updates);
if (result.success) {
lastSyncedRef.current = updateHash;
@@ -184,11 +206,20 @@ export function useSettingsSync(): SettingsSyncState {
void syncToServer();
}, [syncToServer]);
// Initialize sync - WAIT for migration to complete first
// Initialize sync - WAIT for settings to be loaded and migration to complete
useEffect(() => {
// Don't initialize syncing until we know auth status and are authenticated.
// Prevents accidental overwrites when the app boots before settings are hydrated.
if (!authChecked || !isAuthenticated) return;
// Don't initialize syncing until:
// 1. Auth has been checked
// 2. User is authenticated
// 3. Settings have been loaded from server (settingsLoaded flag)
// This prevents syncing empty/default state before hydration completes.
logger.debug('useSettingsSync initialization check:', {
authChecked,
isAuthenticated,
settingsLoaded,
stateLoaded: state.loaded,
});
if (!authChecked || !isAuthenticated || !settingsLoaded) return;
if (isInitializedRef.current) return;
isInitializedRef.current = true;
@@ -198,14 +229,26 @@ export function useSettingsSync(): SettingsSyncState {
await waitForApiKeyInit();
// CRITICAL: Wait for migration/hydration to complete before we start syncing
// This prevents overwriting server data with empty/default state
// This is a backup to the settingsLoaded flag for extra safety
logger.info('Waiting for migration to complete before starting sync...');
await waitForMigrationComplete();
// Wait for React to finish rendering after store hydration.
// Zustand's subscribe() fires during setState(), which happens BEFORE React's
// render completes. Use a small delay to ensure all pending state updates
// have propagated through the React tree before we read state.
await new Promise((resolve) => setTimeout(resolve, 50));
logger.info('Migration complete, initializing sync');
// Read state - at this point React has processed the store update
const appState = useAppStore.getState();
const setupState = useSetupStore.getState();
logger.info('Initial state read:', { projectsCount: appState.projects?.length ?? 0 });
// Store the initial state hash to avoid immediate re-sync
// (migration has already hydrated the store from server/localStorage)
const appState = useAppStore.getState();
const updates: Record<string, unknown> = {};
for (const field of SETTINGS_FIELDS_TO_SYNC) {
if (field === 'currentProjectId') {
@@ -214,7 +257,6 @@ export function useSettingsSync(): SettingsSyncState {
updates[field] = appState[field as keyof typeof appState];
}
}
const setupState = useSetupStore.getState();
for (const field of SETUP_FIELDS_TO_SYNC) {
updates[field] = setupState[field as keyof typeof setupState];
}
@@ -233,16 +275,33 @@ export function useSettingsSync(): SettingsSyncState {
}
initializeSync();
}, [authChecked, isAuthenticated]);
}, [authChecked, isAuthenticated, settingsLoaded]);
// Subscribe to store changes and sync to server
useEffect(() => {
if (!state.loaded || !authChecked || !isAuthenticated) return;
if (!state.loaded || !authChecked || !isAuthenticated || !settingsLoaded) return;
// Subscribe to app store changes
const unsubscribeApp = useAppStore.subscribe((newState, prevState) => {
const auth = useAuthStore.getState();
logger.debug('Store subscription fired:', {
prevProjects: prevState.projects?.length ?? 0,
newProjects: newState.projects?.length ?? 0,
authChecked: auth.authChecked,
isAuthenticated: auth.isAuthenticated,
settingsLoaded: auth.settingsLoaded,
loaded: state.loaded,
});
// Don't sync if settings not loaded yet
if (!auth.settingsLoaded) {
logger.debug('Store changed but settings not loaded, skipping sync');
return;
}
// If the current project changed, sync immediately so we can restore on next launch
if (newState.currentProject?.id !== prevState.currentProject?.id) {
logger.debug('Current project changed, syncing immediately');
syncNow();
return;
}
@@ -266,6 +325,7 @@ export function useSettingsSync(): SettingsSyncState {
}
if (changed) {
logger.debug('Store changed, scheduling sync');
scheduleSyncToServer();
}
});
@@ -294,11 +354,11 @@ export function useSettingsSync(): SettingsSyncState {
clearTimeout(syncTimeoutRef.current);
}
};
}, [state.loaded, authChecked, isAuthenticated, scheduleSyncToServer, syncNow]);
}, [state.loaded, authChecked, isAuthenticated, settingsLoaded, scheduleSyncToServer, syncNow]);
// Best-effort flush on tab close / backgrounding
useEffect(() => {
if (!state.loaded || !authChecked || !isAuthenticated) return;
if (!state.loaded || !authChecked || !isAuthenticated || !settingsLoaded) return;
const handleBeforeUnload = () => {
// Fire-and-forget; may not complete in all browsers, but helps in Electron/webview
@@ -318,7 +378,7 @@ export function useSettingsSync(): SettingsSyncState {
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [state.loaded, authChecked, isAuthenticated, syncNow]);
}, [state.loaded, authChecked, isAuthenticated, settingsLoaded, syncNow]);
return state;
}
@@ -368,6 +428,27 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
const serverSettings = result.settings as unknown as GlobalSettings;
const currentAppState = useAppStore.getState();
const validOpencodeModelIds = new Set(getAllOpencodeModelIds());
const incomingEnabledOpencodeModels =
serverSettings.enabledOpencodeModels ?? currentAppState.enabledOpencodeModels;
const sanitizedOpencodeDefaultModel = validOpencodeModelIds.has(
serverSettings.opencodeDefaultModel ?? currentAppState.opencodeDefaultModel
)
? (serverSettings.opencodeDefaultModel ?? currentAppState.opencodeDefaultModel)
: DEFAULT_OPENCODE_MODEL;
const sanitizedEnabledOpencodeModels = Array.from(
new Set(incomingEnabledOpencodeModels.filter((modelId) => validOpencodeModelIds.has(modelId)))
);
if (!sanitizedEnabledOpencodeModels.includes(sanitizedOpencodeDefaultModel)) {
sanitizedEnabledOpencodeModels.push(sanitizedOpencodeDefaultModel);
}
const persistedDynamicModelIds =
serverSettings.enabledDynamicModelIds ?? currentAppState.enabledDynamicModelIds;
const sanitizedDynamicModelIds = persistedDynamicModelIds.filter(
(modelId) => !modelId.startsWith('amazon-bedrock/')
);
// Save theme to localStorage for fallback when server settings aren't available
if (serverSettings.theme) {
@@ -391,6 +472,9 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
phaseModels: serverSettings.phaseModels,
enabledCursorModels: serverSettings.enabledCursorModels,
cursorDefaultModel: serverSettings.cursorDefaultModel,
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
enabledDynamicModelIds: sanitizedDynamicModelIds,
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,
keyboardShortcuts: {
...currentAppState.keyboardShortcuts,
@@ -399,6 +483,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
>),
},
mcpServers: serverSettings.mcpServers,
defaultEditorCommand: serverSettings.defaultEditorCommand ?? null,
promptCustomization: serverSettings.promptCustomization ?? {},
projects: serverSettings.projects,
trashedProjects: serverSettings.trashedProjects,

View File

@@ -13,6 +13,7 @@ import { getApiKey, getSessionToken, getServerUrlSync } from './http-api-client'
// Server URL - uses shared cached URL from http-api-client
const getServerUrl = (): string => getServerUrlSync();
const DEFAULT_CACHE_MODE: RequestCache = 'no-store';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
@@ -80,7 +81,7 @@ export async function apiFetch(
method: HttpMethod = 'GET',
options: ApiFetchOptions = {}
): Promise<Response> {
const { headers: additionalHeaders, body, skipAuth, ...restOptions } = options;
const { headers: additionalHeaders, body, skipAuth, cache, ...restOptions } = options;
const headers = skipAuth
? { 'Content-Type': 'application/json', ...additionalHeaders }
@@ -90,6 +91,7 @@ export async function apiFetch(
method,
headers,
credentials: 'include',
cache: cache ?? DEFAULT_CACHE_MODE,
...restOptions,
};

View File

@@ -462,7 +462,8 @@ export interface FeaturesAPI {
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) => Promise<{ success: boolean; feature?: Feature; error?: string }>;
delete: (projectPath: string, featureId: string) => Promise<{ success: boolean; error?: string }>;
getAgentOutput: (
@@ -612,7 +613,8 @@ export interface ElectronAPI {
enhance: (
originalText: string,
enhancementMode: string,
model?: string
model?: string,
thinkingLevel?: string
) => Promise<{
success: boolean;
enhancedText?: string;
@@ -649,7 +651,8 @@ export interface ElectronAPI {
removedDependencies: string[];
addedDependencies: string[];
}>;
}
},
branchName?: string
) => Promise<{ success: boolean; appliedChanges?: string[]; error?: string }>;
onEvent: (callback: (data: unknown) => void) => () => void;
};
@@ -1643,13 +1646,34 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
openInEditor: async (worktreePath: string) => {
console.log('[Mock] Opening in editor:', worktreePath);
openInEditor: async (worktreePath: string, editorCommand?: string) => {
const ANTIGRAVITY_EDITOR_COMMAND = 'antigravity';
const ANTIGRAVITY_LEGACY_COMMAND = 'agy';
// Map editor commands to display names
const editorNameMap: Record<string, string> = {
cursor: 'Cursor',
code: 'VS Code',
zed: 'Zed',
subl: 'Sublime Text',
windsurf: 'Windsurf',
trae: 'Trae',
rider: 'Rider',
webstorm: 'WebStorm',
xed: 'Xcode',
studio: 'Android Studio',
[ANTIGRAVITY_EDITOR_COMMAND]: 'Antigravity',
[ANTIGRAVITY_LEGACY_COMMAND]: 'Antigravity',
open: 'Finder',
explorer: 'Explorer',
'xdg-open': 'File Manager',
};
const editorName = editorCommand ? (editorNameMap[editorCommand] ?? 'Editor') : 'VS Code';
console.log('[Mock] Opening in editor:', worktreePath, 'using:', editorName);
return {
success: true,
result: {
message: `Opened ${worktreePath} in VS Code`,
editorName: 'VS Code',
message: `Opened ${worktreePath} in ${editorName}`,
editorName,
},
};
},
@@ -1665,6 +1689,32 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
getAvailableEditors: async () => {
console.log('[Mock] Getting available editors');
return {
success: true,
result: {
editors: [
{ name: 'VS Code', command: 'code' },
{ name: 'Finder', command: 'open' },
],
},
};
},
refreshEditors: async () => {
console.log('[Mock] Refreshing available editors');
return {
success: true,
result: {
editors: [
{ name: 'VS Code', command: 'code' },
{ name: 'Finder', command: 'open' },
],
message: 'Found 2 available editors',
},
};
},
initGit: async (projectPath: string) => {
console.log('[Mock] Initializing git:', projectPath);
return {
@@ -1720,6 +1770,47 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
};
},
getInitScript: async (projectPath: string) => {
console.log('[Mock] Getting init script:', { projectPath });
return {
success: true,
exists: false,
content: '',
path: `${projectPath}/.automaker/worktree-init.sh`,
};
},
setInitScript: async (projectPath: string, content: string) => {
console.log('[Mock] Setting init script:', { projectPath, content });
return {
success: true,
path: `${projectPath}/.automaker/worktree-init.sh`,
};
},
deleteInitScript: async (projectPath: string) => {
console.log('[Mock] Deleting init script:', { projectPath });
return {
success: true,
};
},
runInitScript: async (projectPath: string, worktreePath: string, branch: string) => {
console.log('[Mock] Running init script:', { projectPath, worktreePath, branch });
return {
success: true,
message: 'Init script started (mock)',
};
},
onInitScriptEvent: (callback) => {
console.log('[Mock] Subscribing to init script events');
// Return unsubscribe function
return () => {
console.log('[Mock] Unsubscribing from init script events');
};
},
};
}

View File

@@ -39,6 +39,7 @@ import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/typ
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
const logger = createLogger('HttpClient');
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
// Cached server URL (set during initialization in Electron mode)
let cachedServerUrl: string | null = null;
@@ -69,6 +70,7 @@ const handleUnauthorized = (): void => {
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: '{}',
cache: NO_STORE_CACHE_MODE,
}).catch(() => {});
notifyLoggedOut();
};
@@ -296,6 +298,7 @@ export const checkAuthStatus = async (): Promise<{
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
credentials: 'include',
headers: getApiKey() ? { 'X-API-Key': getApiKey()! } : undefined,
cache: NO_STORE_CACHE_MODE,
});
const data = await response.json();
return {
@@ -322,6 +325,7 @@ export const login = async (
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ apiKey }),
cache: NO_STORE_CACHE_MODE,
});
const data = await response.json();
@@ -361,6 +365,7 @@ export const fetchSessionToken = async (): Promise<boolean> => {
try {
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
credentials: 'include', // Send the session cookie
cache: NO_STORE_CACHE_MODE,
});
if (!response.ok) {
@@ -391,6 +396,7 @@ export const logout = async (): Promise<{ success: boolean }> => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
cache: NO_STORE_CACHE_MODE,
});
// Clear the cached session token
@@ -439,6 +445,7 @@ export const verifySession = async (): Promise<boolean> => {
const response = await fetch(`${getServerUrl()}/api/settings/status`, {
headers,
credentials: 'include',
cache: NO_STORE_CACHE_MODE,
// Avoid hanging indefinitely during backend reloads or network issues
signal: AbortSignal.timeout(2500),
});
@@ -475,6 +482,7 @@ export const checkSandboxEnvironment = async (): Promise<{
try {
const response = await fetch(`${getServerUrl()}/api/health/environment`, {
method: 'GET',
cache: NO_STORE_CACHE_MODE,
signal: AbortSignal.timeout(5000),
});
@@ -499,7 +507,10 @@ type EventType =
| 'issue-validation:event'
| 'backlog-plan:event'
| 'ideation:stream'
| 'ideation:analysis';
| 'ideation:analysis'
| 'worktree:init-started'
| 'worktree:init-output'
| 'worktree:init-completed';
type EventCallback = (payload: unknown) => void;
@@ -556,6 +567,7 @@ export class HttpApiClient implements ElectronAPI {
const response = await fetch(`${this.serverUrl}/api/auth/token`, {
headers,
credentials: 'include',
cache: NO_STORE_CACHE_MODE,
});
if (response.status === 401 || response.status === 403) {
@@ -587,6 +599,17 @@ export class HttpApiClient implements ElectronAPI {
this.isConnecting = true;
// Wait for API key initialization to complete before attempting connection
// This prevents race conditions during app startup
waitForApiKeyInit()
.then(() => this.doConnectWebSocketInternal())
.catch((error) => {
logger.error('Failed to initialize for WebSocket connection:', error);
this.isConnecting = false;
});
}
private doConnectWebSocketInternal(): void {
// Electron mode typically authenticates with the injected API key.
// However, in external-server/cookie-auth flows, the API key may be unavailable.
// In that case, fall back to the same wsToken/cookie authentication used in web mode
@@ -771,6 +794,7 @@ export class HttpApiClient implements ElectronAPI {
const response = await fetch(`${this.serverUrl}${endpoint}`, {
headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth
cache: NO_STORE_CACHE_MODE,
});
if (response.status === 401 || response.status === 403) {
@@ -825,13 +849,14 @@ export class HttpApiClient implements ElectronAPI {
return response.json();
}
private async httpDelete<T>(endpoint: string): Promise<T> {
private async httpDelete<T>(endpoint: string, body?: unknown): Promise<T> {
// Ensure API key is initialized before making request
await waitForApiKeyInit();
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: 'DELETE',
headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth
body: body ? JSON.stringify(body) : undefined,
});
if (response.status === 401 || response.status === 403) {
@@ -1415,6 +1440,67 @@ export class HttpApiClient implements ElectronAPI {
error?: string;
}> => this.get('/api/setup/opencode-status'),
// OpenCode Dynamic Model Discovery
getOpencodeModels: (
refresh?: boolean
): Promise<{
success: boolean;
models?: Array<{
id: string;
name: string;
modelString: string;
provider: string;
description: string;
supportsTools: boolean;
supportsVision: boolean;
tier: string;
default?: boolean;
}>;
count?: number;
cached?: boolean;
error?: string;
}> => this.get(`/api/setup/opencode/models${refresh ? '?refresh=true' : ''}`),
refreshOpencodeModels: (): Promise<{
success: boolean;
models?: Array<{
id: string;
name: string;
modelString: string;
provider: string;
description: string;
supportsTools: boolean;
supportsVision: boolean;
tier: string;
default?: boolean;
}>;
count?: number;
error?: string;
}> => this.post('/api/setup/opencode/models/refresh'),
getOpencodeProviders: (): Promise<{
success: boolean;
providers?: Array<{
id: string;
name: string;
authenticated: boolean;
authMethod?: 'oauth' | 'api_key';
}>;
authenticated?: Array<{
id: string;
name: string;
authenticated: boolean;
authMethod?: 'oauth' | 'api_key';
}>;
error?: string;
}> => this.get('/api/setup/opencode/providers'),
clearOpencodeCache: (): Promise<{
success: boolean;
message?: string;
error?: string;
}> => this.post('/api/setup/opencode/cache/clear'),
onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent('agent:stream', callback);
},
@@ -1459,7 +1545,8 @@ export class HttpApiClient implements ElectronAPI {
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) =>
this.post('/api/features/update', {
projectPath,
@@ -1467,6 +1554,7 @@ export class HttpApiClient implements ElectronAPI {
updates,
descriptionHistorySource,
enhancementMode,
preEnhancementDescription,
}),
delete: (projectPath: string, featureId: string) =>
this.post('/api/features/delete', { projectPath, featureId }),
@@ -1612,9 +1700,11 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/worktree/list-branches', { worktreePath }),
switchBranch: (worktreePath: string, branchName: string) =>
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
openInEditor: (worktreePath: string) =>
this.post('/api/worktree/open-in-editor', { worktreePath }),
openInEditor: (worktreePath: string, editorCommand?: string) =>
this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }),
getDefaultEditor: () => this.get('/api/worktree/default-editor'),
getAvailableEditors: () => this.get('/api/worktree/available-editors'),
refreshEditors: () => this.post('/api/worktree/refresh-editors', {}),
initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }),
startDevServer: (projectPath: string, worktreePath: string) =>
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
@@ -1622,6 +1712,37 @@ export class HttpApiClient implements ElectronAPI {
listDevServers: () => this.post('/api/worktree/list-dev-servers', {}),
getPRInfo: (worktreePath: string, branchName: string) =>
this.post('/api/worktree/pr-info', { worktreePath, branchName }),
// Init script methods
getInitScript: (projectPath: string) =>
this.get(`/api/worktree/init-script?projectPath=${encodeURIComponent(projectPath)}`),
setInitScript: (projectPath: string, content: string) =>
this.put('/api/worktree/init-script', { projectPath, content }),
deleteInitScript: (projectPath: string) =>
this.httpDelete('/api/worktree/init-script', { projectPath }),
runInitScript: (projectPath: string, worktreePath: string, branch: string) =>
this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }),
onInitScriptEvent: (
callback: (event: {
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';
payload: unknown;
}) => void
) => {
// Note: subscribeToEvent callback receives (payload) not (_, payload)
const unsub1 = this.subscribeToEvent('worktree:init-started', (payload) =>
callback({ type: 'worktree:init-started', payload })
);
const unsub2 = this.subscribeToEvent('worktree:init-output', (payload) =>
callback({ type: 'worktree:init-output', payload })
);
const unsub3 = this.subscribeToEvent('worktree:init-completed', (payload) =>
callback({ type: 'worktree:init-completed', payload })
);
return () => {
unsub1();
unsub2();
unsub3();
};
},
};
// Git API
@@ -2142,9 +2263,10 @@ export class HttpApiClient implements ElectronAPI {
removedDependencies: string[];
addedDependencies: string[];
}>;
}
},
branchName?: string
): Promise<{ success: boolean; appliedChanges?: string[]; error?: string }> =>
this.post('/api/backlog-plan/apply', { projectPath, plan }),
this.post('/api/backlog-plan/apply', { projectPath, plan, branchName }),
onEvent: (callback: (data: unknown) => void): (() => void) => {
return this.subscribeToEvent('backlog-plan:event', callback as EventCallback);

View File

@@ -12,12 +12,14 @@ import { useSetupStore } from '@/store/setup-store';
import { useAuthStore } from '@/store/auth-store';
import { getElectronAPI, isElectron } from '@/lib/electron';
import { isMac } from '@/lib/utils';
import { initializeProject } from '@/lib/project-init';
import {
initApiKey,
verifySession,
checkSandboxEnvironment,
getServerUrlSync,
getHttpApiClient,
handleServerOffline,
} from '@/lib/http-api-client';
import {
hydrateStoreFromSettings,
@@ -25,13 +27,29 @@ import {
performSettingsMigration,
} from '@/hooks/use-settings-migration';
import { Toaster } from 'sonner';
import { Menu } from 'lucide-react';
import { ThemeOption, themeOptions } from '@/config/theme-options';
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
import { LoadingState } from '@/components/ui/loading-state';
import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader';
import type { Project } from '@/lib/electron';
const logger = createLogger('RootLayout');
const SERVER_READY_MAX_ATTEMPTS = 8;
const SERVER_READY_BACKOFF_BASE_MS = 250;
const SERVER_READY_MAX_DELAY_MS = 1500;
const SERVER_READY_TIMEOUT_MS = 2000;
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
const AUTO_OPEN_HISTORY_INDEX = 0;
const SINGLE_PROJECT_COUNT = 1;
const DEFAULT_LAST_OPENED_TIME_MS = 0;
const AUTO_OPEN_STATUS = {
idle: 'idle',
opening: 'opening',
done: 'done',
} as const;
type AutoOpenStatus = (typeof AUTO_OPEN_STATUS)[keyof typeof AUTO_OPEN_STATUS];
// Apply stored theme immediately on page load (before React hydration)
// This prevents flash of default theme on login/setup pages
@@ -60,15 +78,90 @@ function applyStoredTheme(): void {
// Apply stored theme immediately (runs synchronously before render)
applyStoredTheme();
async function waitForServerReady(): Promise<boolean> {
const serverUrl = getServerUrlSync();
for (let attempt = 1; attempt <= SERVER_READY_MAX_ATTEMPTS; attempt++) {
try {
const response = await fetch(`${serverUrl}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(SERVER_READY_TIMEOUT_MS),
cache: NO_STORE_CACHE_MODE,
});
if (response.ok) {
return true;
}
} catch (error) {
logger.warn(`Server readiness check failed (attempt ${attempt})`, error);
}
const delayMs = Math.min(SERVER_READY_MAX_DELAY_MS, SERVER_READY_BACKOFF_BASE_MS * attempt);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
return false;
}
function getProjectLastOpenedMs(project: Project): number {
if (!project.lastOpened) return DEFAULT_LAST_OPENED_TIME_MS;
const parsed = Date.parse(project.lastOpened);
return Number.isNaN(parsed) ? DEFAULT_LAST_OPENED_TIME_MS : parsed;
}
function selectAutoOpenProject(
currentProject: Project | null,
projects: Project[],
projectHistory: string[]
): Project | null {
if (currentProject) return currentProject;
if (projectHistory.length > 0) {
const historyProjectId = projectHistory[AUTO_OPEN_HISTORY_INDEX];
const historyProject = projects.find((project) => project.id === historyProjectId);
if (historyProject) {
return historyProject;
}
}
if (projects.length === SINGLE_PROJECT_COUNT) {
return projects[AUTO_OPEN_HISTORY_INDEX] ?? null;
}
if (projects.length > SINGLE_PROJECT_COUNT) {
let latestProject: Project | null = projects[AUTO_OPEN_HISTORY_INDEX] ?? null;
let latestTimestamp = latestProject
? getProjectLastOpenedMs(latestProject)
: DEFAULT_LAST_OPENED_TIME_MS;
for (const project of projects) {
const openedAt = getProjectLastOpenedMs(project);
if (openedAt > latestTimestamp) {
latestTimestamp = openedAt;
latestProject = project;
}
}
return latestProject;
}
return null;
}
function RootLayoutContent() {
const location = useLocation();
const {
setIpcConnected,
projects,
currentProject,
projectHistory,
upsertAndSetCurrentProject,
getEffectiveTheme,
skipSandboxWarning,
setSkipSandboxWarning,
fetchCodexModels,
sidebarOpen,
toggleSidebar,
} = useAppStore();
const { setupComplete, codexCliStatus } = useSetupStore();
const navigate = useNavigate();
@@ -76,6 +169,7 @@ function RootLayoutContent() {
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
const authChecked = useAuthStore((s) => s.authChecked);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const settingsLoaded = useAuthStore((s) => s.settingsLoaded);
const { openFileBrowser } = useFileBrowser();
// Load project settings when switching projects
@@ -85,6 +179,22 @@ function RootLayoutContent() {
const isLoginRoute = location.pathname === '/login';
const isLoggedOutRoute = location.pathname === '/logged-out';
const isDashboardRoute = location.pathname === '/dashboard';
const isBoardRoute = location.pathname === '/board';
const isRootRoute = location.pathname === '/';
const [autoOpenStatus, setAutoOpenStatus] = useState<AutoOpenStatus>(AUTO_OPEN_STATUS.idle);
const autoOpenCandidate = selectAutoOpenProject(currentProject, projects, projectHistory);
const canAutoOpen =
authChecked &&
isAuthenticated &&
settingsLoaded &&
setupComplete &&
!isLoginRoute &&
!isLoggedOutRoute &&
!isSetupRoute &&
!!autoOpenCandidate;
const shouldAutoOpen = canAutoOpen && autoOpenStatus !== AUTO_OPEN_STATUS.done;
const shouldBlockForSettings =
authChecked && isAuthenticated && !settingsLoaded && !isLoginRoute && !isLoggedOutRoute;
// Sandbox environment check state
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
@@ -218,9 +328,11 @@ function RootLayoutContent() {
// Works for ALL modes (unified flow)
useEffect(() => {
const handleLoggedOut = () => {
logger.warn('automaker:logged-out event received!');
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
if (location.pathname !== '/logged-out') {
logger.warn('Navigating to /logged-out due to logged-out event');
navigate({ to: '/logged-out' });
}
};
@@ -236,6 +348,7 @@ function RootLayoutContent() {
// Redirects to login page which will detect server is offline and show error UI.
useEffect(() => {
const handleServerOffline = () => {
logger.warn('automaker:server-offline event received!');
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
// Navigate to login - the login page will detect server is offline and show appropriate UI
@@ -266,6 +379,12 @@ function RootLayoutContent() {
// Initialize API key for Electron mode
await initApiKey();
const serverReady = await waitForServerReady();
if (!serverReady) {
handleServerOffline();
return;
}
// 1. Verify session (Single Request, ALL modes)
let isValid = false;
try {
@@ -302,13 +421,28 @@ function RootLayoutContent() {
// Hydrate store with the final settings (merged if migration occurred)
hydrateStoreFromSettings(finalSettings);
// Signal that settings hydration is complete so useSettingsSync can start
// CRITICAL: Wait for React to render the hydrated state before
// signaling completion. Zustand updates are synchronous, but React
// hasn't necessarily re-rendered yet. This prevents race conditions
// where useSettingsSync reads state before the UI has updated.
await new Promise((resolve) => setTimeout(resolve, 0));
// Signal that settings hydration is complete FIRST.
// This ensures useSettingsSync's waitForMigrationComplete() will resolve
// immediately when it starts after auth state change, preventing it from
// syncing default empty state to the server.
signalMigrationComplete();
// Mark auth as checked only after settings hydration succeeded.
useAuthStore
.getState()
.setAuthState({ isAuthenticated: true, authChecked: true });
// Now mark auth as checked AND settings as loaded.
// The settingsLoaded flag ensures useSettingsSync won't start syncing
// until settings have been properly hydrated, even if authChecked was
// set earlier by login-view.
useAuthStore.getState().setAuthState({
isAuthenticated: true,
authChecked: true,
settingsLoaded: true,
});
return;
}
@@ -373,17 +507,38 @@ function RootLayoutContent() {
// - If authenticated but setup incomplete: force /setup
// - If authenticated and setup complete: allow access to app
useEffect(() => {
logger.debug('Routing effect triggered:', {
authChecked,
isAuthenticated,
settingsLoaded,
setupComplete,
pathname: location.pathname,
});
// Wait for auth check to complete before enforcing any redirects
if (!authChecked) return;
if (!authChecked) {
logger.debug('Auth not checked yet, skipping routing');
return;
}
// Unauthenticated -> force /logged-out (but allow /login so user can authenticate)
if (!isAuthenticated) {
logger.warn('Not authenticated, redirecting to /logged-out. Auth state:', {
authChecked,
isAuthenticated,
settingsLoaded,
currentPath: location.pathname,
});
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
navigate({ to: '/logged-out' });
}
return;
}
// Wait for settings to be loaded before making setupComplete-based routing decisions
// This prevents redirecting to /setup before we know the actual setupComplete value
if (!settingsLoaded) return;
// Authenticated -> determine whether setup is required
if (!setupComplete && location.pathname !== '/setup') {
navigate({ to: '/setup' });
@@ -394,7 +549,46 @@ function RootLayoutContent() {
if (setupComplete && location.pathname === '/setup') {
navigate({ to: '/dashboard' });
}
}, [authChecked, isAuthenticated, setupComplete, location.pathname, navigate]);
}, [authChecked, isAuthenticated, settingsLoaded, setupComplete, location.pathname, navigate]);
// Fallback: If auth is checked and authenticated but settings not loaded,
// it means login-view or another component set auth state before __root.tsx's
// auth flow completed. Load settings now to prevent sync with empty state.
useEffect(() => {
// Only trigger if auth is valid but settings aren't loaded yet
// This handles the case where login-view sets authChecked=true before we finish our auth flow
if (!authChecked || !isAuthenticated || settingsLoaded) {
logger.debug('Fallback skipped:', { authChecked, isAuthenticated, settingsLoaded });
return;
}
logger.info('Auth valid but settings not loaded - triggering fallback load');
const loadSettings = async () => {
const api = getHttpApiClient();
try {
logger.debug('Fetching settings in fallback...');
const settingsResult = await api.settings.getGlobal();
logger.debug('Settings fetched:', settingsResult.success ? 'success' : 'failed');
if (settingsResult.success && settingsResult.settings) {
const { settings: finalSettings } = await performSettingsMigration(
settingsResult.settings as unknown as Parameters<typeof performSettingsMigration>[0]
);
logger.debug('Settings migrated, hydrating stores...');
hydrateStoreFromSettings(finalSettings);
await new Promise((resolve) => setTimeout(resolve, 0));
signalMigrationComplete();
logger.debug('Setting settingsLoaded=true');
useAuthStore.getState().setAuthState({ settingsLoaded: true });
logger.info('Fallback settings load completed successfully');
}
} catch (error) {
logger.error('Failed to load settings in fallback:', error);
}
};
loadSettings();
}, [authChecked, isAuthenticated, settingsLoaded]);
useEffect(() => {
setGlobalFileBrowser(openFileBrowser);
@@ -428,7 +622,10 @@ function RootLayoutContent() {
// Redirect from welcome page based on project state
useEffect(() => {
if (isMounted && location.pathname === '/') {
if (isMounted && isRootRoute) {
if (!settingsLoaded || shouldAutoOpen) {
return;
}
if (currentProject) {
// Project is selected, go to board
navigate({ to: '/board' });
@@ -437,14 +634,66 @@ function RootLayoutContent() {
navigate({ to: '/dashboard' });
}
}
}, [isMounted, currentProject, location.pathname, navigate]);
}, [isMounted, currentProject, isRootRoute, navigate, shouldAutoOpen, settingsLoaded]);
// Auto-open the most recent project on startup
useEffect(() => {
if (!canAutoOpen) return;
if (autoOpenStatus !== AUTO_OPEN_STATUS.idle) return;
if (!autoOpenCandidate) return;
setAutoOpenStatus(AUTO_OPEN_STATUS.opening);
const openProject = async () => {
try {
const initResult = await initializeProject(autoOpenCandidate.path);
if (!initResult.success) {
logger.warn('Auto-open project failed:', initResult.error);
if (isRootRoute) {
navigate({ to: '/dashboard' });
}
return;
}
if (!currentProject || currentProject.id !== autoOpenCandidate.id) {
upsertAndSetCurrentProject(
autoOpenCandidate.path,
autoOpenCandidate.name,
autoOpenCandidate.theme
);
}
if (isRootRoute) {
navigate({ to: '/board' });
}
} catch (error) {
logger.error('Auto-open project crashed:', error);
if (isRootRoute) {
navigate({ to: '/dashboard' });
}
} finally {
setAutoOpenStatus(AUTO_OPEN_STATUS.done);
}
};
void openProject();
}, [
canAutoOpen,
autoOpenStatus,
autoOpenCandidate,
currentProject,
navigate,
upsertAndSetCurrentProject,
isRootRoute,
]);
// Bootstrap Codex models on app startup (after auth completes)
useEffect(() => {
// Only fetch if authenticated and Codex CLI is available
if (!authChecked || !isAuthenticated) return;
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated;
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.hasApiKey;
if (!isCodexAvailable) return;
// Fetch models in the background
@@ -512,6 +761,22 @@ function RootLayoutContent() {
);
}
if (shouldBlockForSettings) {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<LoadingState message="Loading settings..." />
</main>
);
}
if (shouldAutoOpen) {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<LoadingState message="Opening project..." />
</main>
);
}
// Show setup page (full screen, no sidebar) - authenticated only
if (isSetupRoute) {
return (
@@ -549,6 +814,16 @@ function RootLayoutContent() {
/>
)}
<Sidebar />
{/* Mobile menu toggle button - only shows when sidebar is closed on mobile */}
{!sidebarOpen && (
<button
onClick={toggleSidebar}
className="fixed top-3 left-3 z-50 p-2 rounded-lg bg-card/90 backdrop-blur-sm border border-border shadow-lg lg:hidden"
aria-label="Open menu"
>
<Menu className="w-5 h-5 text-foreground" />
</button>
)}
<div
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}

View File

@@ -23,6 +23,7 @@ import type {
PipelineConfig,
PipelineStep,
PromptCustomization,
ModelDefinition,
} from '@automaker/types';
import {
getAllCursorModelIds,
@@ -33,6 +34,8 @@ import {
} from '@automaker/types';
const logger = createLogger('AppStore');
const OPENCODE_BEDROCK_PROVIDER_ID = 'amazon-bedrock';
const OPENCODE_BEDROCK_MODEL_PREFIX = `${OPENCODE_BEDROCK_PROVIDER_ID}/`;
// Re-export types for convenience
export type {
@@ -80,6 +83,9 @@ export type ThemeMode =
// LocalStorage key for theme persistence (fallback when server settings aren't available)
export const THEME_STORAGE_KEY = 'automaker:theme';
// Maximum number of output lines to keep in init script state (prevents unbounded memory growth)
export const MAX_INIT_OUTPUT_LINES = 500;
/**
* Get the theme from localStorage as a fallback
* Used before server settings are loaded (e.g., on login/setup pages)
@@ -114,6 +120,12 @@ function saveThemeToStorage(theme: ThemeMode): void {
setItem(THEME_STORAGE_KEY, theme);
}
function persistEffectiveThemeForProject(project: Project | null, fallbackTheme: ThemeMode): void {
const projectTheme = project?.theme as ThemeMode | undefined;
const themeToStore = projectTheme ?? fallbackTheme;
saveThemeToStorage(themeToStore);
}
export type BoardViewMode = 'kanban' | 'graph';
export interface ApiKeys {
@@ -463,6 +475,14 @@ export interface PersistedTerminalSettings {
maxSessions: number;
}
/** State for worktree init script execution */
export interface InitScriptState {
status: 'idle' | 'running' | 'success' | 'failed';
branch: string;
output: string[];
error?: string;
}
export interface AppState {
// Project state
projects: Project[];
@@ -516,6 +536,8 @@ export interface AppState {
defaultSkipTests: boolean; // Default value for skip tests when creating new features
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running)
planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch
addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch
// Worktree Settings
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: true)
@@ -564,8 +586,19 @@ export interface AppState {
codexEnableImages: boolean; // Enable image processing
// OpenCode CLI Settings (global)
enabledOpencodeModels: OpencodeModelId[]; // Which OpenCode models are available in feature modal
// Static OpenCode settings are persisted via SETTINGS_FIELDS_TO_SYNC
enabledOpencodeModels: OpencodeModelId[]; // Which static OpenCode models are available
opencodeDefaultModel: OpencodeModelId; // Default OpenCode model selection
// Dynamic models are session-only (not persisted) because they're discovered at runtime
// from `opencode models` CLI and depend on current provider authentication state
dynamicOpencodeModels: ModelDefinition[]; // Dynamically discovered models from OpenCode CLI
enabledDynamicModelIds: string[]; // Which dynamic models are enabled
cachedOpencodeProviders: Array<{
id: string;
name: string;
authenticated: boolean;
authMethod?: string;
}>; // Cached providers
// Claude Agent SDK Settings
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
@@ -574,6 +607,9 @@ export interface AppState {
// MCP Servers
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
// Editor Configuration
defaultEditorCommand: string | null; // Default editor for "Open In" action
// Skills Configuration
enableSkills: boolean; // Enable Skills functionality (loads from .claude/skills/ directories)
skillsSources: Array<'user' | 'project'>; // Which directories to load Skills from
@@ -661,6 +697,18 @@ export interface AppState {
// Whether the worktree panel row is visible (default: true)
worktreePanelVisibleByProject: Record<string, boolean>;
// Init Script Indicator Visibility (per-project, keyed by project path)
// Whether to show the floating init script indicator panel (default: true)
showInitScriptIndicatorByProject: Record<string, boolean>;
// Default Delete Branch With Worktree (per-project, keyed by project path)
// Whether to default the "delete branch" checkbox when deleting a worktree (default: false)
defaultDeleteBranchByProject: Record<string, boolean>;
// Auto-dismiss Init Script Indicator (per-project, keyed by project path)
// Whether to auto-dismiss the indicator after completion (default: true)
autoDismissInitScriptIndicatorByProject: Record<string, boolean>;
// UI State (previously in localStorage, now synced via API)
/** Whether worktree panel is collapsed in board view */
worktreePanelCollapsed: boolean;
@@ -668,6 +716,9 @@ export interface AppState {
lastProjectDir: string;
/** Recently accessed folders for quick access */
recentFolders: string[];
// Init Script State (keyed by "projectPath::branch" to support concurrent scripts)
initScriptState: Record<string, InitScriptState>;
}
// Claude Usage interface matching the server response
@@ -881,6 +932,8 @@ export interface AppActions {
setDefaultSkipTests: (skip: boolean) => void;
setEnableDependencyBlocking: (enabled: boolean) => void;
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
// Worktree Settings actions
setUseWorktrees: (enabled: boolean) => void;
@@ -949,11 +1002,20 @@ export interface AppActions {
setEnabledOpencodeModels: (models: OpencodeModelId[]) => void;
setOpencodeDefaultModel: (model: OpencodeModelId) => void;
toggleOpencodeModel: (model: OpencodeModelId, enabled: boolean) => void;
setDynamicOpencodeModels: (models: ModelDefinition[]) => void;
setEnabledDynamicModelIds: (ids: string[]) => void;
toggleDynamicModel: (modelId: string, enabled: boolean) => void;
setCachedOpencodeProviders: (
providers: Array<{ id: string; name: string; authenticated: boolean; authMethod?: string }>
) => void;
// Claude Agent SDK Settings actions
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
// Editor Configuration actions
setDefaultEditorCommand: (command: string | null) => void;
// Prompt Customization actions
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
@@ -1071,6 +1133,18 @@ export interface AppActions {
setWorktreePanelVisible: (projectPath: string, visible: boolean) => void;
getWorktreePanelVisible: (projectPath: string) => boolean;
// Init Script Indicator Visibility actions (per-project)
setShowInitScriptIndicator: (projectPath: string, visible: boolean) => void;
getShowInitScriptIndicator: (projectPath: string) => boolean;
// Default Delete Branch actions (per-project)
setDefaultDeleteBranch: (projectPath: string, deleteBranch: boolean) => void;
getDefaultDeleteBranch: (projectPath: string) => boolean;
// Auto-dismiss Init Script Indicator actions (per-project)
setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void;
getAutoDismissInitScriptIndicator: (projectPath: string) => boolean;
// UI State actions (previously in localStorage, now synced via API)
setWorktreePanelCollapsed: (collapsed: boolean) => void;
setLastProjectDir: (dir: string) => void;
@@ -1099,6 +1173,19 @@ export interface AppActions {
}>
) => void;
// Init Script State actions (keyed by projectPath::branch to support concurrent scripts)
setInitScriptState: (
projectPath: string,
branch: string,
state: Partial<InitScriptState>
) => void;
appendInitScriptOutput: (projectPath: string, branch: string, content: string) => void;
clearInitScriptState: (projectPath: string, branch: string) => void;
getInitScriptState: (projectPath: string, branch: string) => InitScriptState | null;
getInitScriptStatesForProject: (
projectPath: string
) => Array<{ key: string; state: InitScriptState }>;
// Reset
reset: () => void;
}
@@ -1131,6 +1218,8 @@ const initialState: AppState = {
defaultSkipTests: true, // Default to manual verification (tests disabled)
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified)
planUseSelectedWorktreeBranch: true, // Default to enabled (Plan creates features on selected worktree branch)
addFeatureUseSelectedWorktreeBranch: false, // Default to disabled (Add Feature uses normal defaults)
useWorktrees: true, // Default to enabled (git worktree isolation)
currentWorktreeByProject: {},
worktreesByProject: {},
@@ -1150,10 +1239,14 @@ const initialState: AppState = {
codexEnableWebSearch: false, // Default to disabled
codexEnableImages: false, // Default to disabled
enabledOpencodeModels: getAllOpencodeModelIds(), // All OpenCode models enabled by default
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Default to Claude Sonnet 4.5
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Default to OpenCode free tier
dynamicOpencodeModels: [], // Empty until fetched from OpenCode CLI
enabledDynamicModelIds: [], // Empty until user enables dynamic models
cachedOpencodeProviders: [], // Empty until fetched from OpenCode CLI
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
mcpServers: [], // No MCP servers configured by default
defaultEditorCommand: null, // Auto-detect: Cursor > VS Code > first available
enableSkills: true, // Skills enabled by default
skillsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
enableSubagents: true, // Subagents enabled by default
@@ -1195,10 +1288,14 @@ const initialState: AppState = {
codexModelsLastFetched: null,
pipelineConfigByProject: {},
worktreePanelVisibleByProject: {},
showInitScriptIndicatorByProject: {},
defaultDeleteBranchByProject: {},
autoDismissInitScriptIndicatorByProject: {},
// UI State (previously in localStorage, now synced via API)
worktreePanelCollapsed: false,
lastProjectDir: '',
recentFolders: [],
initScriptState: {},
};
export const useAppStore = create<AppState & AppActions>()((set, get) => ({
@@ -1241,13 +1338,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
};
const isCurrent = get().currentProject?.id === projectId;
const nextCurrentProject = isCurrent ? null : get().currentProject;
set({
projects: remainingProjects,
trashedProjects: [trashedProject, ...existingTrash],
currentProject: isCurrent ? null : get().currentProject,
currentProject: nextCurrentProject,
currentView: isCurrent ? 'welcome' : get().currentView,
});
persistEffectiveThemeForProject(nextCurrentProject, get().theme);
},
restoreTrashedProject: (projectId) => {
@@ -1266,6 +1366,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
currentProject: samePathProject,
currentView: 'board',
});
persistEffectiveThemeForProject(samePathProject, get().theme);
return;
}
@@ -1283,6 +1384,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
currentProject: restoredProject,
currentView: 'board',
});
persistEffectiveThemeForProject(restoredProject, get().theme);
},
deleteTrashedProject: (projectId) => {
@@ -1302,6 +1404,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
setCurrentProject: (project) => {
set({ currentProject: project });
persistEffectiveThemeForProject(project, get().theme);
if (project) {
set({ currentView: 'board' });
// Add to project history (MRU order)
@@ -1385,6 +1488,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
projectHistoryIndex: newIndex,
currentView: 'board',
});
persistEffectiveThemeForProject(targetProject, get().theme);
}
},
@@ -1418,6 +1522,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
projectHistoryIndex: newIndex,
currentView: 'board',
});
persistEffectiveThemeForProject(targetProject, get().theme);
}
},
@@ -1477,12 +1582,14 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
// Also update currentProject if it's the same project
const currentProject = get().currentProject;
if (currentProject?.id === projectId) {
const updatedTheme = theme === null ? undefined : theme;
set({
currentProject: {
...currentProject,
theme: theme === null ? undefined : theme,
theme: updatedTheme,
},
});
persistEffectiveThemeForProject({ ...currentProject, theme: updatedTheme }, get().theme);
}
},
@@ -1741,6 +1848,30 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
},
setPlanUseSelectedWorktreeBranch: async (enabled) => {
const previous = get().planUseSelectedWorktreeBranch;
set({ planUseSelectedWorktreeBranch: enabled });
// Sync to server settings file
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
const ok = await syncSettingsToServer();
if (!ok) {
logger.error('Failed to sync planUseSelectedWorktreeBranch setting to server - reverting');
set({ planUseSelectedWorktreeBranch: previous });
}
},
setAddFeatureUseSelectedWorktreeBranch: async (enabled) => {
const previous = get().addFeatureUseSelectedWorktreeBranch;
set({ addFeatureUseSelectedWorktreeBranch: enabled });
// Sync to server settings file
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
const ok = await syncSettingsToServer();
if (!ok) {
logger.error(
'Failed to sync addFeatureUseSelectedWorktreeBranch setting to server - reverting'
);
set({ addFeatureUseSelectedWorktreeBranch: previous });
}
},
// Worktree Settings actions
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
@@ -1909,6 +2040,34 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
? [...state.enabledOpencodeModels, model]
: state.enabledOpencodeModels.filter((m) => m !== model),
})),
setDynamicOpencodeModels: (models) => {
// Dynamic models depend on CLI authentication state and are re-discovered each session.
// Persist enabled model IDs, but do not auto-enable new models.
const filteredModels = models.filter(
(model) =>
model.provider !== OPENCODE_BEDROCK_PROVIDER_ID &&
!model.id.startsWith(OPENCODE_BEDROCK_MODEL_PREFIX)
);
const currentEnabled = get().enabledDynamicModelIds;
const newModelIds = filteredModels.map((m) => m.id);
const filteredEnabled = currentEnabled.filter((modelId) => newModelIds.includes(modelId));
const nextEnabled = currentEnabled.length === 0 ? [] : filteredEnabled;
set({ dynamicOpencodeModels: filteredModels, enabledDynamicModelIds: nextEnabled });
},
setEnabledDynamicModelIds: (ids) => set({ enabledDynamicModelIds: ids }),
toggleDynamicModel: (modelId, enabled) =>
set((state) => ({
enabledDynamicModelIds: enabled
? [...state.enabledDynamicModelIds, modelId]
: state.enabledDynamicModelIds.filter((id) => id !== modelId),
})),
setCachedOpencodeProviders: (providers) =>
set({
cachedOpencodeProviders: providers.filter(
(provider) => provider.id !== OPENCODE_BEDROCK_PROVIDER_ID
),
}),
// Claude Agent SDK Settings actions
setAutoLoadClaudeMd: async (enabled) => {
@@ -1933,6 +2092,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
set({ skipSandboxWarning: previous });
}
},
// Editor Configuration actions
setDefaultEditorCommand: (command) => set({ defaultEditorCommand: command }),
// Prompt Customization actions
setPromptCustomization: async (customization) => {
set({ promptCustomization: customization });
@@ -3110,6 +3272,51 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
return get().worktreePanelVisibleByProject[projectPath] ?? true;
},
// Init Script Indicator Visibility actions (per-project)
setShowInitScriptIndicator: (projectPath, visible) => {
set({
showInitScriptIndicatorByProject: {
...get().showInitScriptIndicatorByProject,
[projectPath]: visible,
},
});
},
getShowInitScriptIndicator: (projectPath) => {
// Default to true (visible) if not set
return get().showInitScriptIndicatorByProject[projectPath] ?? true;
},
// Default Delete Branch actions (per-project)
setDefaultDeleteBranch: (projectPath, deleteBranch) => {
set({
defaultDeleteBranchByProject: {
...get().defaultDeleteBranchByProject,
[projectPath]: deleteBranch,
},
});
},
getDefaultDeleteBranch: (projectPath) => {
// Default to false (don't delete branch) if not set
return get().defaultDeleteBranchByProject[projectPath] ?? false;
},
// Auto-dismiss Init Script Indicator actions (per-project)
setAutoDismissInitScriptIndicator: (projectPath, autoDismiss) => {
set({
autoDismissInitScriptIndicatorByProject: {
...get().autoDismissInitScriptIndicatorByProject,
[projectPath]: autoDismiss,
},
});
},
getAutoDismissInitScriptIndicator: (projectPath) => {
// Default to true (auto-dismiss enabled) if not set
return get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true;
},
// UI State actions (previously in localStorage, now synced via API)
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
@@ -3123,6 +3330,62 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
set({ recentFolders: updated });
},
// Init Script State actions (keyed by "projectPath::branch")
setInitScriptState: (projectPath, branch, state) => {
const key = `${projectPath}::${branch}`;
const current = get().initScriptState[key] || {
status: 'idle',
branch,
output: [],
};
set({
initScriptState: {
...get().initScriptState,
[key]: { ...current, ...state },
},
});
},
appendInitScriptOutput: (projectPath, branch, content) => {
const key = `${projectPath}::${branch}`;
// Initialize state if absent to avoid dropping output due to event-order races
const current = get().initScriptState[key] || {
status: 'idle' as const,
branch,
output: [],
};
// Append new content and enforce fixed-size buffer to prevent memory bloat
const newOutput = [...current.output, content].slice(-MAX_INIT_OUTPUT_LINES);
set({
initScriptState: {
...get().initScriptState,
[key]: {
...current,
output: newOutput,
},
},
});
},
clearInitScriptState: (projectPath, branch) => {
const key = `${projectPath}::${branch}`;
const { [key]: _, ...rest } = get().initScriptState;
set({ initScriptState: rest });
},
getInitScriptState: (projectPath, branch) => {
const key = `${projectPath}::${branch}`;
return get().initScriptState[key] || null;
},
getInitScriptStatesForProject: (projectPath) => {
const prefix = `${projectPath}::`;
const states = get().initScriptState;
return Object.entries(states)
.filter(([key]) => key.startsWith(prefix))
.map(([key, state]) => ({ key, state }));
},
// Reset
reset: () => set(initialState),
}));

View File

@@ -5,6 +5,8 @@ interface AuthState {
authChecked: boolean;
/** Whether the user is currently authenticated (web mode: valid session cookie) */
isAuthenticated: boolean;
/** Whether settings have been loaded and hydrated from server */
settingsLoaded: boolean;
}
interface AuthActions {
@@ -15,15 +17,18 @@ interface AuthActions {
const initialState: AuthState = {
authChecked: false,
isAuthenticated: false,
settingsLoaded: false,
};
/**
* Web authentication state.
*
* Intentionally NOT persisted: source of truth is the server session cookie.
* Intentionally NOT persisted: source of truth is server session cookie.
*/
export const useAuthStore = create<AuthState & AuthActions>((set) => ({
...initialState,
setAuthState: (state) => set(state),
setAuthState: (state) => {
set({ ...state });
},
resetAuth: () => set(initialState),
}));

View File

@@ -7,6 +7,7 @@ export interface CliStatus {
path: string | null;
version: string | null;
method: string;
hasApiKey?: boolean;
error?: string;
}

View File

@@ -884,7 +884,10 @@ export interface WorktreeAPI {
}>;
// Open a worktree directory in the editor
openInEditor: (worktreePath: string) => Promise<{
openInEditor: (
worktreePath: string,
editorCommand?: string
) => Promise<{
success: boolean;
result?: {
message: string;
@@ -903,6 +906,30 @@ export interface WorktreeAPI {
error?: string;
}>;
// Get all available code editors
getAvailableEditors: () => Promise<{
success: boolean;
result?: {
editors: Array<{
name: string;
command: string;
}>;
};
error?: string;
}>;
// Refresh editor cache and re-detect available editors
refreshEditors: () => Promise<{
success: boolean;
result?: {
editors: Array<{
name: string;
command: string;
}>;
message: string;
};
error?: string;
}>;
// Initialize git repository in a project
initGit: (projectPath: string) => Promise<{
success: boolean;
@@ -988,6 +1015,50 @@ export interface WorktreeAPI {
};
error?: string;
}>;
// Get init script content for a project
getInitScript: (projectPath: string) => Promise<{
success: boolean;
exists: boolean;
content: string;
path: string;
error?: string;
}>;
// Set init script content for a project
setInitScript: (
projectPath: string,
content: string
) => Promise<{
success: boolean;
path?: string;
error?: string;
}>;
// Delete init script for a project
deleteInitScript: (projectPath: string) => Promise<{
success: boolean;
error?: string;
}>;
// Run (or re-run) init script for a worktree
runInitScript: (
projectPath: string,
worktreePath: string,
branch: string
) => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
// Subscribe to init script events
onInitScriptEvent: (
callback: (event: {
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';
payload: unknown;
}) => void
) => () => void;
}
export interface GitAPI {

View File

@@ -3,9 +3,10 @@ import { routeTree } from '../routeTree.gen';
// Use browser history in web mode (for e2e tests and dev), memory history in Electron
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined;
const BOARD_ROUTE_PATH = '/board';
const history = isElectron
? createMemoryHistory({ initialEntries: [window.location.pathname || '/'] })
? createMemoryHistory({ initialEntries: [BOARD_ROUTE_PATH] })
: createBrowserHistory();
export const router = createRouter({

View File

@@ -0,0 +1,30 @@
#!/bin/bash
# Example worktree init script for Automaker
# Copy this content to Settings > Worktrees > Init Script
# Or save directly as .automaker/worktree-init.sh in your project
echo "=========================================="
echo " Worktree Init Script Starting..."
echo "=========================================="
echo ""
echo "Current directory: $(pwd)"
echo "Branch: $(git branch --show-current 2>/dev/null || echo 'unknown')"
echo ""
# Install dependencies
echo "[1/1] Installing npm dependencies..."
if [ -f "package.json" ]; then
if npm install; then
echo "Dependencies installed successfully!"
else
echo "ERROR: npm install failed with exit code $?"
exit 1
fi
else
echo "No package.json found, skipping npm install"
fi
echo ""
echo "=========================================="
echo " Worktree initialization complete!"
echo "=========================================="

View File

@@ -69,8 +69,12 @@ export function resolveModelString(
return modelKey;
}
// OpenCode model - pass through unchanged
// Supports: opencode/big-pickle, opencode-sonnet, amazon-bedrock/anthropic.claude-*
// OpenCode model (static or dynamic) - pass through unchanged
// This handles models like:
// - opencode-* (Automaker routing prefix)
// - opencode/* (free tier models)
// - amazon-bedrock/* (AWS Bedrock models)
// - provider/model-name (dynamic models like github-copilot/gpt-4o, google/gemini-2.5-pro)
if (isOpencodeModel(modelKey)) {
console.log(`[ModelResolver] Using OpenCode model: ${modelKey}`);
return modelKey;

343
libs/platform/src/editor.ts Normal file
View File

@@ -0,0 +1,343 @@
/**
* Cross-platform editor detection and launching utilities
*
* Handles:
* - Detecting available code editors on the system
* - Cross-platform editor launching (handles Windows .cmd files)
* - Caching of detected editors for performance
*/
import { execFile, spawn, type ChildProcess } from 'child_process';
import { promisify } from 'util';
import { homedir } from 'os';
import { join } from 'path';
import { access } from 'fs/promises';
import type { EditorInfo } from '@automaker/types';
const execFileAsync = promisify(execFile);
// Platform detection
const isWindows = process.platform === 'win32';
const isMac = process.platform === 'darwin';
// Cache with TTL for editor detection
let cachedEditors: EditorInfo[] | null = null;
let cacheTimestamp: number = 0;
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
/**
* Check if the editor cache is still valid
*/
function isCacheValid(): boolean {
return cachedEditors !== null && Date.now() - cacheTimestamp < CACHE_TTL_MS;
}
/**
* Clear the editor detection cache
* Useful when editors may have been installed/uninstalled
*/
export function clearEditorCache(): void {
cachedEditors = null;
cacheTimestamp = 0;
}
/**
* Check if a CLI command exists in PATH
* Uses platform-specific command lookup (where on Windows, which on Unix)
*/
export async function commandExists(cmd: string): Promise<boolean> {
try {
const whichCmd = isWindows ? 'where' : 'which';
await execFileAsync(whichCmd, [cmd]);
return true;
} catch {
return false;
}
}
/**
* Check if a macOS app bundle exists and return the path if found
* Checks both /Applications and ~/Applications
*/
async function findMacApp(appName: string): Promise<string | null> {
if (!isMac) return null;
// Check /Applications first
const systemAppPath = join('/Applications', `${appName}.app`);
try {
await access(systemAppPath);
return systemAppPath;
} catch {
// Not in /Applications
}
// Check ~/Applications (used by JetBrains Toolbox and others)
const userAppPath = join(homedir(), 'Applications', `${appName}.app`);
try {
await access(userAppPath);
return userAppPath;
} catch {
return null;
}
}
/**
* Editor definition with CLI command and macOS app bundle name
*/
interface EditorDefinition {
name: string;
cliCommand: string;
cliAliases?: readonly string[];
macAppName: string;
/** If true, only available on macOS */
macOnly?: boolean;
}
const ANTIGRAVITY_CLI_COMMANDS = ['antigravity', 'agy'] as const;
const [PRIMARY_ANTIGRAVITY_COMMAND, ...LEGACY_ANTIGRAVITY_COMMANDS] = ANTIGRAVITY_CLI_COMMANDS;
/**
* List of supported editors in priority order
*/
const SUPPORTED_EDITORS: EditorDefinition[] = [
{ name: 'Cursor', cliCommand: 'cursor', macAppName: 'Cursor' },
{ name: 'VS Code', cliCommand: 'code', macAppName: 'Visual Studio Code' },
{
name: 'VS Code Insiders',
cliCommand: 'code-insiders',
macAppName: 'Visual Studio Code - Insiders',
},
{ name: 'Kiro', cliCommand: 'kiro', macAppName: 'Kiro' },
{ name: 'Zed', cliCommand: 'zed', macAppName: 'Zed' },
{ name: 'Sublime Text', cliCommand: 'subl', macAppName: 'Sublime Text' },
{ name: 'Windsurf', cliCommand: 'windsurf', macAppName: 'Windsurf' },
{ name: 'Trae', cliCommand: 'trae', macAppName: 'Trae' },
{ name: 'Rider', cliCommand: 'rider', macAppName: 'Rider' },
{ name: 'WebStorm', cliCommand: 'webstorm', macAppName: 'WebStorm' },
{ name: 'Xcode', cliCommand: 'xed', macAppName: 'Xcode', macOnly: true },
{ name: 'Android Studio', cliCommand: 'studio', macAppName: 'Android Studio' },
{
name: 'Antigravity',
cliCommand: PRIMARY_ANTIGRAVITY_COMMAND,
cliAliases: LEGACY_ANTIGRAVITY_COMMANDS,
macAppName: 'Antigravity',
},
];
/**
* Check if Xcode is fully installed (not just Command Line Tools)
* xed command requires full Xcode.app, not just CLT
*/
async function isXcodeFullyInstalled(): Promise<boolean> {
if (!isMac) return false;
try {
// Check if xcode-select points to full Xcode, not just CommandLineTools
const { stdout } = await execFileAsync('xcode-select', ['-p']);
const devPath = stdout.trim();
// Full Xcode path: /Applications/Xcode.app/Contents/Developer
// Command Line Tools: /Library/Developer/CommandLineTools
const isPointingToXcode = devPath.includes('Xcode.app');
if (!isPointingToXcode && devPath.includes('CommandLineTools')) {
// Check if xed command exists (indicates CLT are installed)
const xedExists = await commandExists('xed');
// Check if Xcode.app actually exists
const xcodeAppPath = await findMacApp('Xcode');
if (xedExists && xcodeAppPath) {
console.warn(
'Xcode is installed but xcode-select is pointing to Command Line Tools. ' +
'To use Xcode as an editor, run: sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer'
);
}
}
return isPointingToXcode;
} catch {
return false;
}
}
/**
* Try to find an editor - checks CLI first, then macOS app bundle
* Returns EditorInfo if found, null otherwise
*/
async function findEditor(definition: EditorDefinition): Promise<EditorInfo | null> {
// Skip macOS-only editors on other platforms
if (definition.macOnly && !isMac) {
return null;
}
// Special handling for Xcode: verify full installation, not just xed command
if (definition.name === 'Xcode') {
if (!(await isXcodeFullyInstalled())) {
return null;
}
}
// Try CLI command first (works on all platforms)
const cliCandidates = [definition.cliCommand, ...(definition.cliAliases ?? [])];
for (const cliCommand of cliCandidates) {
if (await commandExists(cliCommand)) {
return { name: definition.name, command: cliCommand };
}
}
// Try macOS app bundle (checks /Applications and ~/Applications)
if (isMac) {
const appPath = await findMacApp(definition.macAppName);
if (appPath) {
// Use 'open -a' with full path for apps not in /Applications
return { name: definition.name, command: `open -a "${appPath}"` };
}
}
return null;
}
/**
* Get the platform-specific file manager
*/
function getFileManagerInfo(): EditorInfo {
if (isMac) {
return { name: 'Finder', command: 'open' };
} else if (isWindows) {
return { name: 'Explorer', command: 'explorer' };
} else {
return { name: 'File Manager', command: 'xdg-open' };
}
}
/**
* Detect all available code editors on the system
* Results are cached for 5 minutes for performance
*/
export async function detectAllEditors(): Promise<EditorInfo[]> {
// Return cached result if still valid
if (isCacheValid() && cachedEditors) {
return cachedEditors;
}
// Check all editors in parallel for better performance
const editorChecks = SUPPORTED_EDITORS.map((def) => findEditor(def));
const results = await Promise.all(editorChecks);
// Filter out null results (editors not found)
const editors = results.filter((e): e is EditorInfo => e !== null);
// Always add file manager as fallback
editors.push(getFileManagerInfo());
// Update cache
cachedEditors = editors;
cacheTimestamp = Date.now();
return editors;
}
/**
* Detect the default (first available) code editor on the system
* Returns the highest priority editor that is installed
*/
export async function detectDefaultEditor(): Promise<EditorInfo> {
const editors = await detectAllEditors();
// Return first editor (highest priority) - always exists due to file manager fallback
return editors[0];
}
/**
* Find a specific editor by command
* Returns the editor info if available, null otherwise
*/
export async function findEditorByCommand(command: string): Promise<EditorInfo | null> {
const editors = await detectAllEditors();
return editors.find((e) => e.command === command) ?? null;
}
/**
* Open a path in the specified editor
*
* Handles cross-platform differences:
* - On Windows, uses spawn with shell:true to handle .cmd batch scripts
* - On macOS, handles 'open -a' style commands for app bundles
* - On Linux, uses direct execution
*
* @param targetPath - The file or directory path to open
* @param editorCommand - The editor command to use (optional, uses default if not specified)
* @returns Promise that resolves with editor info when launched, rejects on error
*/
export async function openInEditor(
targetPath: string,
editorCommand?: string
): Promise<{ editorName: string }> {
// Determine which editor to use
let editor: EditorInfo;
if (editorCommand) {
const found = await findEditorByCommand(editorCommand);
if (found) {
editor = found;
} else {
// Fall back to default if specified editor not found
editor = await detectDefaultEditor();
}
} else {
editor = await detectDefaultEditor();
}
// Execute the editor
await executeEditorCommand(editor.command, targetPath);
return { editorName: editor.name };
}
/**
* Execute an editor command with a path argument
* Handles platform-specific differences in command execution
*/
async function executeEditorCommand(command: string, targetPath: string): Promise<void> {
// Handle 'open -a "AppPath"' style commands (macOS app bundles)
if (command.startsWith('open -a ')) {
const appPath = command.replace('open -a ', '').replace(/"/g, '');
await execFileAsync('open', ['-a', appPath, targetPath]);
return;
}
// On Windows, editor CLI commands are typically .cmd batch scripts
// spawn with shell:true is required to execute them properly
if (isWindows) {
return new Promise((resolve, reject) => {
const child: ChildProcess = spawn(command, [targetPath], {
shell: true,
stdio: 'ignore',
detached: true,
});
// Unref to allow the parent process to exit independently
child.unref();
child.on('error', (err) => {
reject(err);
});
// Resolve after a small delay to catch immediate spawn errors
// Editors run in background, so we don't wait for them to exit
setTimeout(() => resolve(), 100);
});
}
// Unix/macOS: use execFile for direct execution
await execFileAsync(command, [targetPath]);
}
/**
* Open a path in the platform's default file manager
* Always available as a fallback option
*/
export async function openInFileManager(targetPath: string): Promise<{ editorName: string }> {
const fileManager = getFileManagerInfo();
await execFileAsync(fileManager.command, [targetPath]);
return { editorName: fileManager.name };
}

View File

@@ -97,6 +97,7 @@ export {
getCodexCliPaths,
getCodexConfigDir,
getCodexAuthPath,
getGitBashPaths,
getOpenCodeCliPaths,
getOpenCodeConfigDir,
getOpenCodeAuthPath,
@@ -130,6 +131,7 @@ export {
findCodexCliPath,
getCodexAuthIndicators,
type CodexAuthIndicators,
findGitBashPath,
findOpenCodeCliPath,
getOpenCodeAuthIndicators,
type OpenCodeAuthIndicators,
@@ -157,3 +159,14 @@ export {
// Port configuration
export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './config/ports.js';
// Editor detection and launching (cross-platform)
export {
commandExists,
clearEditorCache,
detectAllEditors,
detectDefaultEditor,
findEditorByCommand,
openInEditor,
openInFileManager,
} from './editor.js';

View File

@@ -232,6 +232,87 @@ export function getClaudeProjectsDir(): string {
return path.join(getClaudeConfigDir(), 'projects');
}
/**
* Enumerate directories matching a prefix pattern and return full paths
* Used to resolve dynamic directory names like version numbers
*/
function enumerateMatchingPaths(
parentDir: string,
prefix: string,
...subPathParts: string[]
): string[] {
try {
if (!fsSync.existsSync(parentDir)) {
return [];
}
const entries = fsSync.readdirSync(parentDir);
const matching = entries.filter((entry) => entry.startsWith(prefix));
return matching.map((entry) => path.join(parentDir, entry, ...subPathParts));
} catch {
return [];
}
}
/**
* Get common Git Bash installation paths on Windows
* Git Bash is needed for running shell scripts cross-platform
*/
export function getGitBashPaths(): string[] {
if (process.platform !== 'win32') {
return [];
}
const homeDir = os.homedir();
const localAppData = process.env.LOCALAPPDATA || '';
// Dynamic paths that require directory enumeration
// winget installs to: LocalAppData\Microsoft\WinGet\Packages\Git.Git_<hash>\bin\bash.exe
const wingetGitPaths = localAppData
? enumerateMatchingPaths(
path.join(localAppData, 'Microsoft', 'WinGet', 'Packages'),
'Git.Git_',
'bin',
'bash.exe'
)
: [];
// GitHub Desktop bundles Git at: LocalAppData\GitHubDesktop\app-<version>\resources\app\git\cmd\bash.exe
const githubDesktopPaths = localAppData
? enumerateMatchingPaths(
path.join(localAppData, 'GitHubDesktop'),
'app-',
'resources',
'app',
'git',
'cmd',
'bash.exe'
)
: [];
return [
// Standard Git for Windows installations
'C:\\Program Files\\Git\\bin\\bash.exe',
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
// User-local installations
path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
// Scoop package manager
path.join(homeDir, 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'),
// Chocolatey
path.join(
process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey',
'lib',
'git',
'tools',
'bin',
'bash.exe'
),
// winget installations (dynamically resolved)
...wingetGitPaths,
// GitHub Desktop bundled Git (dynamically resolved)
...githubDesktopPaths,
].filter(Boolean);
}
/**
* Get common shell paths for shell detection
* Includes both full paths and short names to match $SHELL or PATH entries
@@ -550,6 +631,8 @@ function getAllAllowedSystemPaths(): string[] {
getOpenCodeAuthPath(),
// Shell paths
...getShellPaths(),
// Git Bash paths (for Windows cross-platform shell script execution)
...getGitBashPaths(),
// Node.js system paths
...getNodeSystemPaths(),
getScoopNodePath(),
@@ -883,6 +966,13 @@ export async function findCodexCliPath(): Promise<string | null> {
return findFirstExistingPath(getCodexCliPaths());
}
/**
* Find Git Bash on Windows and return its path
*/
export async function findGitBashPath(): Promise<string | null> {
return findFirstExistingPath(getGitBashPaths());
}
/**
* Get Claude authentication status by checking various indicators
*/

View File

@@ -399,7 +399,7 @@ describe('enhancement.ts', () => {
const modes = getAvailableEnhancementModes();
expect(Array.isArray(modes)).toBe(true);
expect(modes.length).toBe(4);
expect(modes.length).toBe(5);
});
it('should include all valid modes', () => {
@@ -409,6 +409,7 @@ describe('enhancement.ts', () => {
expect(modes).toContain('technical');
expect(modes).toContain('simplify');
expect(modes).toContain('acceptance');
expect(modes).toContain('ux-reviewer');
});
it('should return modes in consistent order', () => {

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