Compare commits

..

50 Commits

Author SHA1 Message Date
Stefan de Vogelaere
b88c940a36 feat: unify Claude API key and profile system with flexible key sourcing
- Add ApiKeySource type ('inline' | 'env' | 'credentials') to ClaudeApiProfile
- Allow profiles to source API keys from credentials.json or environment variables
- Add provider templates: OpenRouter, MiniMax, MiniMax (China)
- Auto-migrate existing users with Anthropic key to "Direct Anthropic" profile
- Update all API call sites to pass credentials for key resolution
- Add API key source selector to profile creation UI
- Increment settings version to 5 for migration support

This allows users to:
- Share a single API key across multiple profile configurations
- Use environment variables for CI/CD deployments
- Easily switch between providers without re-entering keys
2026-01-19 17:28:28 +01:00
Stefan de Vogelaere
10b49bd3b4 Merge remote-tracking branch 'origin/v0.13.0rc' into feature/claude-code-max-glm-api-keys 2026-01-19 14:42:15 +01:00
DhanushSantosh
63b8eb0991 chore: refresh package-lock 2026-01-19 17:22:55 +05:30
Stefan de Vogelaere
a52c0461e5 feat: add external terminal support with cross-platform detection (#565)
* feat(platform): add cross-platform openInTerminal utility

Add utility function to open a terminal in a specified directory:
- macOS: Uses Terminal.app via AppleScript
- Windows: Tries Windows Terminal, falls back to cmd
- Linux: Tries common terminal emulators (gnome-terminal,
  konsole, xfce4-terminal, xterm, x-terminal-emulator)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(server): add open-in-terminal endpoint

Add POST /open-in-terminal endpoint to open a system terminal in the
worktree directory using the cross-platform openInTerminal utility.

The endpoint validates that worktreePath is provided and is an
absolute path for security.

Extracted from PR #558.

* feat(ui): add Open in Terminal action to worktree dropdown

Add "Open in Terminal" option to the worktree actions dropdown menu.
This opens the system terminal in the worktree directory.

Changes:
- Add openInTerminal method to http-api-client
- Add Terminal icon and menu item to worktree-actions-dropdown
- Add onOpenInTerminal prop to WorktreeTab component
- Add handleOpenInTerminal handler to use-worktree-actions hook
- Wire up handler in worktree-panel for both mobile and desktop views

Extracted from PR #558.

* fix(ui): open in terminal navigates to Automaker terminal view

Instead of opening the system terminal, the "Open in Terminal" action
now opens Automaker's built-in terminal with the worktree directory:

- Add pendingTerminalCwd state to app store
- Update use-worktree-actions to set pending cwd and navigate to /terminal
- Add effect in terminal-view to create session with pending cwd

This matches the original PR #558 behavior.

* feat(ui): add terminal open mode setting (new tab vs split)

Add a setting to choose how "Open in Terminal" behaves:
- New Tab: Creates a new tab named after the branch (default)
- Split: Adds to current tab as a split view

Changes:
- Add openTerminalMode setting to terminal state ('newTab' | 'split')
- Update terminal-view to respect the setting
- Add UI in Terminal Settings to toggle the behavior
- Rename pendingTerminalCwd to pendingTerminal with branch name

The new tab mode names tabs after the branch for easy identification.
The split mode is useful for comparing terminals side by side.

* feat(ui): display branch name in terminal header with git icon

- Move branch name display from tab name to terminal header
- Show full branch name (no truncation) with GitBranch icon
- Display branch name for both 'new tab' and 'split' modes
- Persist openTerminalMode setting to server and include in import/export
- Update settings dropdown to simplified "New Tab" label

* feat: add external terminal support with cross-platform detection

Add support for opening worktree directories in external terminals
(iTerm2, Warp, Ghostty, System Terminal, etc.) while retaining the
integrated terminal as the default option.

Changes:
- Add terminal detection for macOS, Windows, and Linux
- Add "Open in Terminal" split-button in worktree dropdown
- Add external terminal selection in Settings > Terminal
- Add default open mode setting (new tab vs split)
- Display branch name in terminal panel header
- Support 20+ terminals across platforms

Part of #558, Closes #550

* fix: address PR review comments

- Add nonce parameter to terminal navigation to allow reopening same
  worktree multiple times
- Fix shell path escaping in editor.ts using single-quote wrapper
- Add validatePathParams middleware to open-in-external-terminal route
- Remove redundant validation block from createOpenInExternalTerminalHandler
- Remove unused pendingTerminal state and setPendingTerminal action
- Remove unused getTerminalInfo function from editor.ts

* fix: address PR review security and validation issues

- Add runtime type check for worktreePath in open-in-terminal handler
- Fix Windows Terminal detection using commandExists before spawn
- Fix xterm shell injection by using sh -c with escapeShellArg
- Use loose equality for null/undefined in useEffectiveDefaultTerminal
- Consolidate duplicate imports from open-in-terminal.js

* chore: update package-lock.json

* fix: use response.json() to prevent disposal race condition in E2E test

Replace response.body() with response.json() in open-existing-project.spec.ts
to fix the "Response has been disposed" error. This matches the pattern used
in other test files.

* Revert "fix: use response.json() to prevent disposal race condition in E2E test"

This reverts commit 36bdf8c24a.

* fix: address PR review feedback for terminal feature

- Add explicit validation for worktreePath in createOpenInExternalTerminalHandler
- Add aria-label to refresh button in terminal settings for accessibility
- Only show "no terminals" message when not refreshing
- Reset initialCwdHandledRef on failure to allow retries
- Use z.coerce.number() for nonce URL param to handle string coercion
- Preserve branchName when creating layout for empty tab
- Update getDefaultTerminal return type to allow null result

---------

Co-authored-by: Kacper <kacperlachowiczwp.pl@wp.pl>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 10:22:26 +01:00
Shirone
e73c92b031 Merge pull request #582 from stefandevo/fix/e2e-response-disposal-race
fix: prevent response disposal race condition in E2E test
2026-01-19 00:02:04 +00:00
Web Dev Cody
09151aa3c8 Merge pull request #590 from AutoMaker-Org/automode-api
feat: implement cursor model migration and enhance auto mode function…
2026-01-18 18:59:59 -05:00
Shirone
d6300f33ca fix: skip PR assignment for main worktree and refine metadata fallback logic
This update modifies the list handler to skip PR assignment for the main worktree, preventing confusion when displaying PRs on the main branch tab. Additionally, the fallback logic for assigning stored metadata is refined to only apply if the PR state is 'OPEN', ensuring more accurate representation of PRs.
2026-01-19 00:49:56 +01:00
webdevcody
4b0d1399b1 feat: implement cursor model migration and enhance auto mode functionality
This commit introduces significant updates to the cursor model handling and auto mode features. The cursor model IDs have been standardized to a canonical format, ensuring backward compatibility while migrating legacy IDs. New endpoints for starting and stopping the auto mode loop have been added, allowing for better control over project-specific auto mode operations.

Key changes:
- Updated cursor model IDs to use the 'cursor-' prefix for consistency.
- Added new API endpoints: `/start` and `/stop` for managing auto mode.
- Enhanced the status endpoint to provide detailed project-specific auto mode information.
- Improved error handling and logging throughout the auto mode service.
- Migrated legacy model IDs to their canonical counterparts in various components.

This update aims to streamline the user experience and ensure a smooth transition for existing users while providing new functionalities.
2026-01-18 18:42:52 -05:00
Stefan de Vogelaere
55a34a9f1f feat: add auto-login for dev mode and fix log box formatting (#567)
* feat: add auto-login for dev mode and fix log box formatting

Add AUTOMAKER_AUTO_LOGIN environment variable that, when set to 'true',
automatically creates a session for web mode users without requiring
them to enter the API key. Useful for development environments.

Also fix formatting issues in console log boxes:
- API Key box: add right border, show auto-login status and tips
- Claude auth warning: add separator line, fix emoji spacing
- Server info box: use consistent 71-char width, proper padding
- Port conflict error: use same width, proper dynamic padding

Environment variables:
- AUTOMAKER_AUTO_LOGIN=true: Skip login prompt, auto-create session
- AUTOMAKER_API_KEY: Use a fixed API key (existing)
- AUTOMAKER_HIDE_API_KEY=true: Hide the API key banner (existing)

* fix: add production safeguard to auto-login and extract log box constant

- Add NODE_ENV !== 'production' check to prevent auto-login in production
- Extract magic number 67 to BOX_CONTENT_WIDTH constant in auth.ts and index.ts
- Document AUTOMAKER_AUTO_LOGIN env var in CLAUDE.md and README.md
2026-01-18 23:48:00 +01:00
Stefan de Vogelaere
c4652190eb feat: add three viewing modes for app specification (#566)
* feat: add three viewing modes for app specification

Introduces View, Edit, and Source modes for the spec page:

- View: Clean read-only display with cards, badges, and accordions
- Edit: Structured form-based editor for all spec fields
- Source: Raw XML editor for advanced users

Also adds @automaker/spec-parser shared package for XML parsing
between server and client.

* fix: address PR review feedback

- Replace array index keys with stable UUIDs in array-field-editor,
  features-section, and roadmap-section components
- Replace regex-based XML parsing with fast-xml-parser for robustness
- Simplify renderContent logic in spec-view by removing dead code paths

* fix: convert git+ssh URLs to https in package-lock.json

* fix: address PR review feedback for spec visualiser

- Remove unused RefreshCw import from spec-view.tsx
- Add explicit parsedSpec check in renderContent for robustness
- Hide save button in view mode since it's read-only
- Remove GripVertical drag handles since drag-and-drop is not implemented
- Rename Map imports to MapIcon to avoid shadowing global Map
- Escape tagName in xml-utils.ts RegExp functions for safety
- Add aria-label attributes for accessibility on mode tabs

* fix: address additional PR review feedback

- Fix Textarea controlled/uncontrolled warning with default value
- Preserve IDs in useEffect sync to avoid unnecessary remounts
- Consolidate lucide-react imports
- Add JSDoc note about tag attributes limitation in xml-utils.ts
- Remove redundant disabled prop from SpecModeTabs
2026-01-18 23:45:43 +01:00
Web Dev Cody
af95dae73a Merge pull request #574 from stefandevo/fix/v0.13.0rc
fix: use getTerminalFontFamily for dev server logs terminal font
2026-01-18 17:17:44 -05:00
Web Dev Cody
1c1d9d30a7 Merge pull request #583 from stefandevo/fix/initial-theme
fix: prevent new projects from overriding global theme setting
2026-01-18 17:17:20 -05:00
webdevcody
3faebfa3fe refactor: update migration process to selectively copy specific application data files
This commit refines the migration functionality in the SettingsService to focus on migrating only specific application data files from the legacy Electron userData directory. The migration now explicitly handles files such as settings.json, credentials.json, and agent-sessions, while excluding internal caches. Enhanced logging provides clearer insights into the migration process, including skipped items and errors encountered.

Key changes:
- Modified migration logic to target specific application data files and directories.
- Improved logging for migration status and error handling.
- Introduced a new private method, `copyDirectory`, to facilitate directory copying.
2026-01-18 16:29:55 -05:00
webdevcody
d0eaf0e51d feat: enhance migration process to copy entire data directory from legacy Electron userData location
This update expands the migration functionality in the SettingsService to include the entire data directory, rather than just specific files. The migration now handles all files and directories, including settings.json, credentials.json, sessions-metadata.json, and conversation histories. Additionally, logging has been improved to reflect the migration of all items and to provide clearer information on the migration process.

Key changes:
- Updated migration logic to recursively copy all contents from the legacy directory.
- Enhanced logging for migration status and errors.
- Added a new private method, `copyDirectoryContents`, to facilitate the recursive copying of files and directories.
2026-01-18 16:25:25 -05:00
webdevcody
da80729f56 feat: implement migration of settings from legacy Electron userData directory
This commit introduces a new feature in the SettingsService to migrate user settings from the legacy Electron userData directory to the new shared data directory. The migration process checks for the existence of settings in both locations and handles the transfer of settings.json and credentials.json files if necessary. It also includes logging for successful migrations and any errors encountered during the process, ensuring a smooth transition for users upgrading from previous versions.

Key changes:
- Added `migrateFromLegacyElectronPath` method to handle migration logic.
- Implemented platform-specific paths for legacy settings based on the operating system.
- Enhanced error handling and logging for migration operations.
2026-01-18 16:10:04 -05:00
DhanushSantosh
749fb3a5c1 fix: add token query parameter support to auth middleware for web mode image loading
The /api/fs/image endpoint requires authentication, but when loading images via
CSS background-image or img tags, only query parameters can be used (headers
cannot be set). Web mode passes the session token as a query parameter (?token=...),
but the auth middleware didn't recognize it, causing image requests to fail.

This fix adds support for the 'token' query parameter in the checkAuthentication
function, allowing the auth middleware to validate web mode session tokens when
they're passed as query parameters.

Now image loads work correctly in web mode by:
1. Client passes session token in URL: ?token={sessionToken}
2. Auth middleware recognizes and validates the token query parameter
3. Image endpoint successfully serves the image after authentication

This fixes the issue where kanban board background images were not visible
in web mode.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 21:23:18 +05:30
DhanushSantosh
dd26de9f55 fix: add authentication validation to image endpoint for web mode
Adds authentication checks to the /api/fs/image endpoint to validate
session tokens in web mode. This ensures background images and other
image assets load correctly in web mode by validating:
- session token from query parameter (web mode)
- API key from query parameter (Electron mode)
- session cookie (web mode fallback)
- X-API-Key and X-Session-Token headers

This fixes the issue where kanban board background images were not
visible in web mode because the image request lacked proper authentication.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 21:13:10 +05:30
Stefan de Vogelaere
b6cb926cbe fix: also remove theme calculation from dashboard-view
Missed this code path which is used when opening projects from the
dashboard after completing setup.
2026-01-18 16:18:58 +01:00
Stefan de Vogelaere
eb30ef71f9 fix: prevent response disposal race condition in E2E test
Wrap route.fetch() and response.json() in try/catch blocks to handle
cases where the response is disposed before it can be accessed. Falls
back to route.continue() to let the original request proceed normally.

This fixes the intermittent "Response has been disposed" error in
open-existing-project.spec.ts that occurs due to timing issues in CI.
2026-01-18 16:13:53 +01:00
Stefan de Vogelaere
75fe579e93 fix: prevent new projects from overriding global theme setting
When creating new projects, the theme was always explicitly set even when
matching the global theme. This caused "Use Global Theme" to be unchecked,
preventing global theme changes from affecting the project.

Now theme is only set on new projects when explicitly provided or when
recovering a trashed project's theme preference.
2026-01-18 16:12:32 +01:00
Stefan de Vogelaere
8ab9dc5a11 fix: use user's terminal font settings for dev server logs
XtermLogViewer was passing DEFAULT_TERMINAL_FONT directly to xterm.js,
but this value is 'default' - a sentinel string for the dropdown selector,
not a valid CSS font family. Also the font size was hardcoded to 13px.

Now reads the user's font preference from terminalState:
- fontFamily: Uses getTerminalFontFamily() to convert to CSS font stack
- defaultFontSize: Uses store value when fontSize prop not provided

Also adds useEffects to update font settings dynamically when they change.

This ensures dev server logs respect Settings > Terminal settings.
2026-01-18 15:22:21 +01:00
Dhanush Santosh
96202d4bc2 Merge pull request #573 from DhanushSantosh/patchcraft
fix: resolve data directory persistence between Electron and Web modes
2026-01-18 19:36:09 +05:30
DhanushSantosh
f68aee6a19 fix: prevent response disposal race condition in E2E test 2026-01-18 19:29:32 +05:30
DhanushSantosh
7795d81183 merge: resolve conflicts with upstream/v0.13.0rc 2026-01-18 19:21:56 +05:30
Dhanush Santosh
0c053dab48 Merge pull request #578 from stefandevo/fix/v0.13.0rc-e2e-ci
fix: improve project-switcher data-testid for uniqueness and special chars
2026-01-18 19:14:32 +05:30
Stefan de Vogelaere
1ede7e7e6a refactor: extract sanitizeForTestId to shared utility
Address PR review comments by:
- Creating shared sanitizeForTestId utility in apps/ui/src/lib/utils.ts
- Updating ProjectSwitcherItem to use the shared utility
- Adding matching helper to test utils for E2E tests
- Updating all E2E tests to use the sanitization helper

This ensures the component and tests use identical sanitization logic,
making tests robust against project names with special characters.
2026-01-18 14:36:31 +01:00
DhanushSantosh
980006d40e fix: use setItem helper and safer Playwright selector in tests
- Replace direct localStorage.setItem() with setItem helper in use-settings-migration.ts (line 472) for consistent storage-availability checks and error handling
- Replace brittle attribute selector with Playwright's getByRole in open-existing-project.spec.ts (line 162) to handle names containing special characters

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 19:06:07 +05:30
Stefan de Vogelaere
ef2dcbacd4 fix: improve project-switcher data-testid for uniqueness and special chars
The data-testid generation was using only the sanitized project name which
could produce collisions and didn't handle special characters properly.

Changes:
- Combine stable project.id with sanitized name: project-switcher-{id}-{name}
- Expand sanitization to remove non-alphanumeric chars (except hyphens)
- Collapse multiple hyphens and trim leading/trailing hyphens
- Update E2E tests to use ends-with selector for matching

This ensures test IDs are deterministic, unique, and safe for CSS selectors.
2026-01-18 14:29:04 +01:00
DhanushSantosh
505a2b1e0b docs: enhance docstrings to reach 80% coverage threshold
- Expanded docstrings in use-settings-migration.ts for parseLocalStorageSettings, localStorageHasMoreData, mergeSettings, and performSettingsMigration
- Expanded docstrings in use-settings-sync.ts for getSettingsFieldValue and hasSettingsFieldChanged helper functions
- Added detailed parameter and return value documentation
- Improved clarity on migration flow and settings merging logic

This brings docstring coverage from 77.78% to 80%+ to satisfy CodeRabbit checks.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 18:42:41 +05:30
DhanushSantosh
2e57553639 Merge remote-tracking branch 'upstream/v0.13.0rc' into patchcraft 2026-01-18 18:21:34 +05:30
DhanushSantosh
f37812247d fix: resolve data directory persistence between Electron and Web modes
This commit fixes bidirectional data synchronization between Electron and Web
modes by addressing multiple interconnected issues:

**Core Fixes:**

1. **Electron userData Path (main.ts)**
   - Explicitly set userData path in development using app.setPath()
   - Navigate from __dirname to project root instead of relying on process.cwd()
   - Ensures Electron reads from /data instead of ~/.config/Automaker

2. **Server DataDir Path (main.ts, start-automaker.sh)**
   - Fixed startServer() to use __dirname for reliable path calculation
   - Export DATA_DIR environment variable in start-automaker.sh
   - Server now consistently uses shared /data directory

3. **Settings Sync Protection (settings-service.ts)**
   - Modified wipe protection to distinguish legitimate removals from accidents
   - Allow empty projects array if trashedProjects has items
   - Prevent false-positive wipe detection when removing projects

4. **Diagnostics & Logging**
   - Enhanced cache loading logging in use-settings-migration.ts
   - Detailed migration decision logs for troubleshooting
   - Track project counts from both cache and server

**Impact:**
- Projects created in Electron now appear in Web mode after restart
- Projects removed in Web mode stay removed in Electron after restart
- Settings changes sync bidirectionally across mode switches
- No more data loss or project duplication issues

**Testing:**
- Verified Electron uses /home/dhanush/Projects/automaker/data
- Confirmed server startup logs show correct DATA_DIR
- Tested project persistence across mode restarts
- Validated no writes to ~/.config/Automaker in dev mode

Fixes: Data persistence between Electron and Web modes

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 18:21:14 +05:30
Stefan de Vogelaere
53298106e9 feat: add Claude API provider profiles for alternative endpoints
Add support for managing multiple Claude-compatible API endpoints
(z.AI GLM, AWS Bedrock, etc.) through provider profiles in settings.

Features:
- New ClaudeApiProfile type with base URL, API key, model mappings
- Pre-configured z.AI GLM template with correct model names
- Profile selector in Settings > Claude > API Profiles
- Clean switching between profiles and direct Anthropic API
- Immediate persistence to prevent data loss on restart

Profile support added to all execution paths:
- Agent service (chat)
- Ideation service
- Auto-mode service (feature agents, enhancements)
- Simple query service (title generation, descriptions, etc.)
- Backlog planning, commit messages, spec generation
- GitHub issue validation, suggestions

Environment variables set when profile is active:
- ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN/API_KEY
- ANTHROPIC_DEFAULT_HAIKU/SONNET/OPUS_MODEL
- API_TIMEOUT_MS, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
2026-01-18 13:50:41 +01:00
DhanushSantosh
484d4c65d5 fix: use shared data directory for Electron and web modes
CRITICAL: Electron was using ~/.config/@automaker/app/data/ while web mode
used ./data/, causing projects to never sync between modes.

In development mode, both now use the shared project root ./data directory.
In production, Electron uses its isolated userData directory for app portability.

This ensures:
- Electron projects sync to the same server data directory as web mode
- Projects opened in Electron immediately appear in web mode
- Server restart doesn't lose projects from either mode

The issue was on line 487 where DATA_DIR was set to app.getPath('userData')
instead of the shared project ./data directory.

Fixes the fundamental problem where projects never appeared in web mode
even though they were in the server's settings file.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 16:25:35 +05:30
Shirone
327aef89a2 Merge pull request #562 from AutoMaker-Org/feature/v0.12.0rc-1768688900786-5ea1
refactor: standardize PR state representation across the application
2026-01-18 10:45:59 +00:00
Shirone
44e665f1bf fix: adress pr comments 2026-01-18 00:22:27 +01:00
Shirone
5b1e0105f4 refactor: standardize PR state representation across the application
Updated the PR state handling to use a consistent uppercase format ('OPEN', 'MERGED', 'CLOSED') throughout the codebase. This includes changes to the worktree metadata interface, PR creation logic, and related tests to ensure uniformity and prevent potential mismatches in state representation.

Additionally, modified the GitHub PR fetching logic to retrieve all PR states, allowing for better detection of state changes.

This refactor enhances clarity and consistency in how PR states are managed and displayed.
2026-01-17 23:58:19 +01:00
webdevcody
832d10e133 refactor: replace Loader2 with Spinner component across the application
This update standardizes the loading indicators by replacing all instances of Loader2 with the new Spinner component. The Spinner component provides a consistent look and feel for loading states throughout the UI, enhancing the user experience.

Changes include:
- Updated loading indicators in various components such as popovers, modals, and views.
- Ensured that the Spinner component is used with appropriate sizes for different contexts.

No functional changes were made; this is purely a visual and structural improvement.
2026-01-17 17:58:16 -05:00
DhanushSantosh
7b7ac72c14 fix: use shared data directory for Electron and web modes
CRITICAL FIX: Electron and web mode were using DIFFERENT data directories:
- Electron: Docker volume 'automaker-data' (isolated from host)
- Web: Local ./data directory (host filesystem)

This caused projects opened in Electron to never appear in web mode because
they were synced to a completely separate Docker volume.

Solution: Mount the host's ./data directory into both containers
This ensures Electron and web mode always share the same data directory
and all projects are immediately visible across modes.

Now when you:
1. Open projects in Electron → synced to ./data
2. Switch to web mode → loads from same ./data
3. Restart server → both see the same projects

Fixes issue where projects opened in Electron don't appear in web mode.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 03:06:09 +05:30
DhanushSantosh
9137f0e75f fix: keep localStorage cache in sync with server settings
When switching between Electron and web modes or when the server temporarily
stops, web mode was falling back to stale localStorage data instead of fresh
server data.

This fix:
1. Updates localStorage cache whenever fresh server settings are fetched
2. Updates localStorage cache whenever settings are synced to server
3. Prioritizes fresh settings cache over old Zustand persisted storage

This ensures that:
- Web mode always sees the latest projects even after mode switches
- Switching from Electron to web mode immediately shows new projects
- Server restarts don't cause web mode to use stale cached data

Fixes issue where projects opened in Electron didn't appear in web mode
after stopping and restarting the server.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 02:46:31 +05:30
DhanushSantosh
b66efae5b7 fix: sync projects immediately instead of debouncing
Projects are critical data that must persist across mode switches (Electron/web).
Previously, project changes were debounced by 1 second, which could cause data
loss if:
1. User switched from Electron to web mode quickly
2. App closed before debounce timer fired
3. Network temporarily unavailable during debounce window

This change makes project array changes sync immediately (syncNow) instead of
using the 1-second debounce, ensuring projects are always persisted to the
server right away and visible in both Electron and web modes.

Fixes issue where projects opened in Electron didn't appear in web mode.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 02:30:16 +05:30
DhanushSantosh
2a8706e714 fix: add session token to image URLs for web mode authentication
In web mode, image loads may not send session cookies due to proxy/CORS
restrictions. This adds the session token as a query parameter to ensure
images load correctly with proper authentication in web mode.

Fixes custom project icons and images not loading in web mode.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 02:21:47 +05:30
DhanushSantosh
174c02cb79 fix: automatically remove projects with non-existent paths
When a project fails to initialize because the directory no longer exists
(e.g., test artifacts, deleted folders), automatically remove it from the
project list instead of showing the error repeatedly on every reload.

This prevents users from being stuck with broken project references in their
settings after testing or when project directories are moved/deleted.

The user is notified with a toast message explaining the removal.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 02:09:28 +05:30
DhanushSantosh
a7f7898ee4 fix: persist session token to localStorage for web mode page reload survival
Web mode sessions were being lost on page reload because the session token was
stored only in memory (cachedSessionToken). When the page reloaded, the token
was cleared and verifySession() would fail, redirecting users to login.

This commit adds localStorage persistence for the session token, ensuring:
1. Token survives page reloads in web mode
2. verifySession() can use the persisted token from localStorage
3. Token is cleared properly on logout
4. Graceful fallback if localStorage is unavailable (SSR, disabled storage)

The HTTP-only cookie alone isn't sufficient for web mode due to SameSite cookie
restrictions and potential proxy issues with credentials forwarding.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 02:02:10 +05:30
DhanushSantosh
fdad82bf88 fix: enable WebSocket proxying in Vite dev server
Enables ws: true for /api proxy to properly forward WebSocket connections through the development server in web mode. This ensures real-time features work correctly when developing in browser mode.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 01:52:11 +05:30
DhanushSantosh
b0b49764b9 fix: add localhost to CORS_ORIGIN for web mode development
The web mode launcher was setting CORS_ORIGIN to only include the system
hostname and 127.0.0.1, but users access via http://localhost:3007 which
wasn't in the allowed list.

Now includes:
- http://localhost:3007 (primary dev URL)
- http://$HOSTNAME:3007 (system hostname if needed)
- http://127.0.0.1:3007 (loopback IP)

Also cleaned up debug logging from CORS check since root cause is now clear.

Fixes: Persistent "Not allowed by CORS" errors in web mode

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 01:50:41 +05:30
DhanushSantosh
e10cb83adc debug: add CORS logging to diagnose origin rejection
Added detailed logging to see:
- What origin is being sent
- How the hostname is parsed
- Why origins are being accepted/rejected

This will help us understand why CORS is still failing in web mode.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 01:47:53 +05:30
DhanushSantosh
b8875f71a5 fix: improve CORS configuration to handle localhost and private IPs
The CORS check was too strict for local development. Changed to:
- Parse origin URL properly to extract hostname
- Allow all localhost origins (any port)
- Allow all 127.0.0.1 origins (loopback IP)
- Allow all private network IPs (192.168.x.x, 10.x.x.x, 172.x.x.x)
- Keep security by rejecting unknown origins

This fixes CORS errors when accessing from http://localhost:3007
or other local addresses during web mode development.

Fixes: "Not allowed by CORS" errors in web mode

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 01:45:10 +05:30
DhanushSantosh
4186b80a82 fix: use relative URLs in web mode to leverage Vite proxy
In web mode, the API client was hardcoding localhost:3008, which bypassed
the Vite proxy and caused CORS errors. Now it uses relative URLs (just /api)
in web mode, allowing the proxy to handle routing and making requests appear
same-origin.

- Web mode: Use relative URLs for proxy routing (no CORS issues)
- Electron mode: Continue using hardcoded localhost:3008

This allows the Vite proxy configuration to actually work in web mode.

Fixes: Persistent CORS errors in web mode development

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 01:41:21 +05:30
DhanushSantosh
7eae0215f2 chore: update package-lock.json 2026-01-18 01:38:09 +05:30
DhanushSantosh
4cd84a4734 fix: add API proxy to Vite dev server for web mode CORS
When running in web mode (npm run dev:web), the frontend on localhost:3007
was making cross-origin requests to the backend on localhost:3008, causing
CORS errors.

Added Vite proxy configuration to forward /api requests from the dev server
to the backend. This makes all API calls appear same-origin to the browser,
eliminating CORS blocks during development.

Now web mode users can access http://localhost:3007 without CORS errors.

Fixes: CORS "Not allowed by CORS" errors in web mode

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 01:37:49 +05:30
220 changed files with 8388 additions and 1459 deletions

View File

@@ -172,4 +172,5 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali
- `DATA_DIR` - Data storage directory (default: ./data)
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing
- `AUTOMAKER_AUTO_LOGIN=true` - Skip login prompt in development (disabled when NODE_ENV=production)
- `VITE_HOSTNAME` - Hostname for frontend API URLs (default: localhost)

View File

@@ -389,6 +389,7 @@ npm run lint
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
- `AUTOMAKER_SKIP_SANDBOX_WARNING` - Skip sandbox warning dialog (useful for dev/CI)
- `AUTOMAKER_AUTO_LOGIN=true` - Skip login prompt in development (ignored when NODE_ENV=production)
### Authentication Setup

View File

@@ -91,6 +91,9 @@ const PORT = parseInt(process.env.PORT || '3008', 10);
const HOST = process.env.HOST || '0.0.0.0';
const HOSTNAME = process.env.HOSTNAME || 'localhost';
const DATA_DIR = process.env.DATA_DIR || './data';
logger.info('[SERVER_STARTUP] process.env.DATA_DIR:', process.env.DATA_DIR);
logger.info('[SERVER_STARTUP] Resolved DATA_DIR:', DATA_DIR);
logger.info('[SERVER_STARTUP] process.cwd():', process.cwd());
const ENABLE_REQUEST_LOGGING_DEFAULT = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
// Runtime-configurable request logging flag (can be changed via settings)
@@ -110,24 +113,37 @@ export function isRequestLoggingEnabled(): boolean {
return requestLoggingEnabled;
}
// Width for log box content (excluding borders)
const BOX_CONTENT_WIDTH = 67;
// Check for required environment variables
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
if (!hasAnthropicKey) {
const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH);
const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH);
const w2 = 'Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH);
const w3 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH);
const w4 = 'Or use the setup wizard in Settings to configure authentication.'.padEnd(
BOX_CONTENT_WIDTH
);
logger.warn(`
╔═══════════════════════════════════════════════════════════════════════
⚠️ WARNING: No Claude authentication configured
║ ║
The Claude Agent SDK requires authentication to function.
Set your Anthropic API key:
export ANTHROPIC_API_KEY="sk-ant-..."
Or use the setup wizard in Settings to configure authentication.
╚═══════════════════════════════════════════════════════════════════════╝
╔═════════════════════════════════════════════════════════════════════╗
${wHeader}
╠═════════════════════════════════════════════════════════════════════╣
${w1}
${w2}
${w3}
${w4}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
`);
} else {
logger.info('✓ ANTHROPIC_API_KEY detected (API key auth)');
logger.info('✓ ANTHROPIC_API_KEY detected');
}
// Initialize security
@@ -175,14 +191,25 @@ app.use(
return;
}
// For local development, allow localhost origins
if (
origin.startsWith('http://localhost:') ||
origin.startsWith('http://127.0.0.1:') ||
origin.startsWith('http://[::1]:')
) {
callback(null, origin);
return;
// For local development, allow all localhost/loopback origins (any port)
try {
const url = new URL(origin);
const hostname = url.hostname;
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
hostname.startsWith('172.')
) {
callback(null, origin);
return;
}
} catch (err) {
// Ignore URL parsing errors
}
// Reject other origins by default for security
@@ -226,6 +253,23 @@ eventHookService.initialize(events, settingsService, eventHistoryService);
// Initialize services
(async () => {
// Migrate settings from legacy Electron userData location if needed
// This handles users upgrading from versions that stored settings in ~/.config/Automaker (Linux),
// ~/Library/Application Support/Automaker (macOS), or %APPDATA%\Automaker (Windows)
// to the new shared ./data directory
try {
const migrationResult = await settingsService.migrateFromLegacyElectronPath();
if (migrationResult.migrated) {
logger.info(`Settings migrated from legacy location: ${migrationResult.legacyPath}`);
logger.info(`Migrated files: ${migrationResult.migratedFiles.join(', ')}`);
}
if (migrationResult.errors.length > 0) {
logger.warn('Migration errors:', migrationResult.errors);
}
} catch (err) {
logger.warn('Failed to check for legacy settings migration:', err);
}
// Apply logging settings from saved settings
try {
const settings = await settingsService.getGlobalSettings();
@@ -618,40 +662,74 @@ const startServer = (port: number, host: string) => {
? 'enabled (password protected)'
: 'enabled'
: 'disabled';
const portStr = port.toString().padEnd(4);
// Build URLs for display
const listenAddr = `${host}:${port}`;
const httpUrl = `http://${HOSTNAME}:${port}`;
const wsEventsUrl = `ws://${HOSTNAME}:${port}/api/events`;
const wsTerminalUrl = `ws://${HOSTNAME}:${port}/api/terminal/ws`;
const healthUrl = `http://${HOSTNAME}:${port}/api/health`;
const sHeader = '🚀 Automaker Backend Server'.padEnd(BOX_CONTENT_WIDTH);
const s1 = `Listening: ${listenAddr}`.padEnd(BOX_CONTENT_WIDTH);
const s2 = `HTTP API: ${httpUrl}`.padEnd(BOX_CONTENT_WIDTH);
const s3 = `WebSocket: ${wsEventsUrl}`.padEnd(BOX_CONTENT_WIDTH);
const s4 = `Terminal WS: ${wsTerminalUrl}`.padEnd(BOX_CONTENT_WIDTH);
const s5 = `Health: ${healthUrl}`.padEnd(BOX_CONTENT_WIDTH);
const s6 = `Terminal: ${terminalStatus}`.padEnd(BOX_CONTENT_WIDTH);
logger.info(`
╔═══════════════════════════════════════════════════════╗
Automaker Backend Server
╠═══════════════════════════════════════════════════════╣
Listening: ${host}:${port}${' '.repeat(Math.max(0, 34 - host.length - port.toString().length))}
HTTP API: http://${HOSTNAME}:${portStr}
WebSocket: ws://${HOSTNAME}:${portStr}/api/events
Terminal: ws://${HOSTNAME}:${portStr}/api/terminal/ws
Health: http://${HOSTNAME}:${portStr}/api/health
Terminal: ${terminalStatus.padEnd(37)}
╚═══════════════════════════════════════════════════════╝
╔═════════════════════════════════════════════════════════════════════
${sHeader}
╠═════════════════════════════════════════════════════════════════════
${s1}
${s2}
${s3}
${s4}
${s5}
${s6}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
`);
});
server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
const portStr = port.toString();
const nextPortStr = (port + 1).toString();
const killCmd = `lsof -ti:${portStr} | xargs kill -9`;
const altCmd = `PORT=${nextPortStr} npm run dev:server`;
const eHeader = `❌ ERROR: Port ${portStr} is already in use`.padEnd(BOX_CONTENT_WIDTH);
const e1 = 'Another process is using this port.'.padEnd(BOX_CONTENT_WIDTH);
const e2 = 'To fix this, try one of:'.padEnd(BOX_CONTENT_WIDTH);
const e3 = '1. Kill the process using the port:'.padEnd(BOX_CONTENT_WIDTH);
const e4 = ` ${killCmd}`.padEnd(BOX_CONTENT_WIDTH);
const e5 = '2. Use a different port:'.padEnd(BOX_CONTENT_WIDTH);
const e6 = ` ${altCmd}`.padEnd(BOX_CONTENT_WIDTH);
const e7 = '3. Use the init.sh script which handles this:'.padEnd(BOX_CONTENT_WIDTH);
const e8 = ' ./init.sh'.padEnd(BOX_CONTENT_WIDTH);
logger.error(`
╔═══════════════════════════════════════════════════════╗
❌ ERROR: Port ${port} is already in use
╠═══════════════════════════════════════════════════════╣
Another process is using this port.
To fix this, try one of:
1. Kill the process using the port:
lsof -ti:${port} | xargs kill -9
2. Use a different port:
PORT=${port + 1} npm run dev:server
3. Use the init.sh script which handles this:
./init.sh
╚═══════════════════════════════════════════════════════╝
╔═════════════════════════════════════════════════════════════════════
${eHeader}
╠═════════════════════════════════════════════════════════════════════
${e1}
${e2}
${e3}
${e4}
${e5}
${e6}
${e7}
${e8}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
`);
process.exit(1);
} else {

View File

@@ -130,21 +130,47 @@ function ensureApiKey(): string {
// API key - always generated/loaded on startup for CSRF protection
const API_KEY = ensureApiKey();
// Width for log box content (excluding borders)
const BOX_CONTENT_WIDTH = 67;
// Print API key to console for web mode users (unless suppressed for production logging)
if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') {
const autoLoginEnabled = process.env.AUTOMAKER_AUTO_LOGIN === 'true';
const autoLoginStatus = autoLoginEnabled ? 'enabled (auto-login active)' : 'disabled';
// Build box lines with exact padding
const header = '🔐 API Key for Web Mode Authentication'.padEnd(BOX_CONTENT_WIDTH);
const line1 = "When accessing via browser, you'll be prompted to enter this key:".padEnd(
BOX_CONTENT_WIDTH
);
const line2 = API_KEY.padEnd(BOX_CONTENT_WIDTH);
const line3 = 'In Electron mode, authentication is handled automatically.'.padEnd(
BOX_CONTENT_WIDTH
);
const line4 = `Auto-login (AUTOMAKER_AUTO_LOGIN): ${autoLoginStatus}`.padEnd(BOX_CONTENT_WIDTH);
const tipHeader = '💡 Tips'.padEnd(BOX_CONTENT_WIDTH);
const line5 = 'Set AUTOMAKER_API_KEY env var to use a fixed key'.padEnd(BOX_CONTENT_WIDTH);
const line6 = 'Set AUTOMAKER_AUTO_LOGIN=true to skip the login prompt'.padEnd(BOX_CONTENT_WIDTH);
logger.info(`
╔═══════════════════════════════════════════════════════════════════════
🔐 API Key for Web Mode Authentication
╠═══════════════════════════════════════════════════════════════════════
When accessing via browser, you'll be prompted to enter this key:
${API_KEY}
In Electron mode, authentication is handled automatically.
💡 Tip: Set AUTOMAKER_API_KEY env var to use a fixed key for dev
╚═══════════════════════════════════════════════════════════════════════╝
╔═════════════════════════════════════════════════════════════════════╗
${header}
╠═════════════════════════════════════════════════════════════════════╣
║ ║
${line1}
║ ║
${line2}
║ ║
${line3}
║ ║
${line4}
║ ║
╠═════════════════════════════════════════════════════════════════════╣
${tipHeader}
╠═════════════════════════════════════════════════════════════════════╣
${line5}
${line6}
╚═════════════════════════════════════════════════════════════════════╝
`);
} else {
logger.info('API key banner hidden (AUTOMAKER_HIDE_API_KEY=true)');
@@ -320,6 +346,15 @@ function checkAuthentication(
return { authenticated: false, errorType: 'invalid_api_key' };
}
// Check for session token in query parameter (web mode - needed for image loads)
const queryToken = query.token;
if (queryToken) {
if (validateSession(queryToken)) {
return { authenticated: true };
}
return { authenticated: false, errorType: 'invalid_session' };
}
// Check for session cookie (web mode)
const sessionToken = cookies[SESSION_COOKIE_NAME];
if (sessionToken && validateSession(sessionToken)) {
@@ -335,8 +370,9 @@ function checkAuthentication(
* Accepts either:
* 1. X-API-Key header (for Electron mode)
* 2. X-Session-Token header (for web mode with explicit token)
* 3. apiKey query parameter (fallback for cases where headers can't be set)
* 4. Session cookie (for web mode)
* 3. apiKey query parameter (fallback for Electron, cases where headers can't be set)
* 4. token query parameter (fallback for web mode, needed for image loads via CSS/img tags)
* 5. Session cookie (for web mode)
*/
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
const result = checkAuthentication(

View File

@@ -5,7 +5,12 @@
import type { SettingsService } from '../services/settings-service.js';
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
import { createLogger } from '@automaker/utils';
import type { MCPServerConfig, McpServerConfig, PromptCustomization } from '@automaker/types';
import type {
MCPServerConfig,
McpServerConfig,
PromptCustomization,
ClaudeApiProfile,
} from '@automaker/types';
import {
mergeAutoModePrompts,
mergeAgentPrompts,
@@ -345,3 +350,56 @@ export async function getCustomSubagents(
return Object.keys(merged).length > 0 ? merged : undefined;
}
/** Result from getActiveClaudeApiProfile */
export interface ActiveClaudeApiProfileResult {
/** The active profile, or undefined if using direct Anthropic API */
profile: ClaudeApiProfile | undefined;
/** Credentials for resolving 'credentials' apiKeySource */
credentials: import('@automaker/types').Credentials | undefined;
}
/**
* Get the active Claude API profile and credentials from global settings.
* Returns both the profile and credentials for resolving 'credentials' apiKeySource.
*
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
* @returns Promise resolving to object with profile and credentials
*/
export async function getActiveClaudeApiProfile(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<ActiveClaudeApiProfileResult> {
if (!settingsService) {
return { profile: undefined, credentials: undefined };
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const credentials = await settingsService.getCredentials();
const profiles = globalSettings.claudeApiProfiles || [];
const activeProfileId = globalSettings.activeClaudeApiProfileId;
// No active profile selected - use direct Anthropic API
if (!activeProfileId) {
return { profile: undefined, credentials };
}
// Find the active profile by ID
const activeProfile = profiles.find((p) => p.id === activeProfileId);
if (activeProfile) {
logger.info(`${logPrefix} Using Claude API profile: ${activeProfile.name}`);
return { profile: activeProfile, credentials };
} else {
logger.warn(
`${logPrefix} Active profile ID "${activeProfileId}" not found, falling back to direct Anthropic API`
);
return { profile: undefined, credentials };
}
} catch (error) {
logger.error(`${logPrefix} Failed to load Claude API profile:`, error);
return { profile: undefined, credentials: undefined };
}
}

View File

@@ -5,18 +5,14 @@
import * as secureFs from './secure-fs.js';
import * as path from 'path';
import type { PRState, WorktreePRInfo } from '@automaker/types';
// Re-export types for backwards compatibility
export type { PRState, WorktreePRInfo };
/** Maximum length for sanitized branch names in filesystem paths */
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
export interface WorktreePRInfo {
number: number;
url: string;
title: string;
state: string;
createdAt: string;
}
export interface WorktreeMetadata {
branch: string;
createdAt: string;

View File

@@ -10,7 +10,12 @@ import { BaseProvider } from './base-provider.js';
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
const logger = createLogger('ClaudeProvider');
import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types';
import {
getThinkingTokenBudget,
validateBareModelId,
type ClaudeApiProfile,
type Credentials,
} from '@automaker/types';
import type {
ExecuteOptions,
ProviderMessage,
@@ -21,9 +26,19 @@ import type {
// Explicit allowlist of environment variables to pass to the SDK.
// Only these vars are passed - nothing else from process.env leaks through.
const ALLOWED_ENV_VARS = [
// Authentication
'ANTHROPIC_API_KEY',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
// Endpoint configuration
'ANTHROPIC_BASE_URL',
'API_TIMEOUT_MS',
// Model mappings
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
// Traffic control
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
// System vars (always from process.env)
'PATH',
'HOME',
'SHELL',
@@ -33,16 +48,108 @@ const ALLOWED_ENV_VARS = [
'LC_ALL',
];
// System vars are always passed from process.env regardless of profile
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'];
/**
* Build environment for the SDK with only explicitly allowed variables
* Build environment for the SDK with only explicitly allowed variables.
* When a profile is provided, uses profile configuration (clean switch - don't inherit from process.env).
* When no profile is provided, uses direct Anthropic API settings from process.env.
*
* @param profile - Optional Claude API profile for alternative endpoint configuration
* @param credentials - Optional credentials object for resolving 'credentials' apiKeySource
*/
function buildEnv(): Record<string, string | undefined> {
function buildEnv(
profile?: ClaudeApiProfile,
credentials?: Credentials
): Record<string, string | undefined> {
const env: Record<string, string | undefined> = {};
for (const key of ALLOWED_ENV_VARS) {
if (profile) {
// Use profile configuration (clean switch - don't inherit non-system vars from process.env)
logger.debug('Building environment from Claude API profile:', {
name: profile.name,
apiKeySource: profile.apiKeySource ?? 'inline',
});
// Resolve API key based on source strategy
let apiKey: string | undefined;
const source = profile.apiKeySource ?? 'inline'; // Default to inline for backwards compat
switch (source) {
case 'inline':
apiKey = profile.apiKey;
break;
case 'env':
apiKey = process.env.ANTHROPIC_API_KEY;
break;
case 'credentials':
apiKey = credentials?.apiKeys?.anthropic;
break;
}
// Warn if no API key found
if (!apiKey) {
logger.warn(`No API key found for profile "${profile.name}" with source "${source}"`);
}
// Authentication
if (profile.useAuthToken) {
env['ANTHROPIC_AUTH_TOKEN'] = apiKey;
} else {
env['ANTHROPIC_API_KEY'] = apiKey;
}
// Endpoint configuration
env['ANTHROPIC_BASE_URL'] = profile.baseUrl;
if (profile.timeoutMs) {
env['API_TIMEOUT_MS'] = String(profile.timeoutMs);
}
// Model mappings
if (profile.modelMappings?.haiku) {
env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = profile.modelMappings.haiku;
}
if (profile.modelMappings?.sonnet) {
env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = profile.modelMappings.sonnet;
}
if (profile.modelMappings?.opus) {
env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = profile.modelMappings.opus;
}
// Traffic control
if (profile.disableNonessentialTraffic) {
env['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'] = '1';
}
} else {
// Use direct Anthropic API - two modes:
// 1. API Key mode: ANTHROPIC_API_KEY from credentials/env
// 2. Claude Max plan: Uses CLI OAuth auth (SDK handles this automatically)
//
// IMPORTANT: Do NOT set any profile vars (base URL, model mappings, etc.)
// This ensures clean switching - only pass through what's in process.env
if (process.env.ANTHROPIC_API_KEY) {
env['ANTHROPIC_API_KEY'] = process.env.ANTHROPIC_API_KEY;
}
// If using Claude Max plan via CLI auth, the SDK handles auth automatically
// when no API key is provided. We don't set ANTHROPIC_AUTH_TOKEN here
// unless it was explicitly set in process.env (rare edge case).
if (process.env.ANTHROPIC_AUTH_TOKEN) {
env['ANTHROPIC_AUTH_TOKEN'] = process.env.ANTHROPIC_AUTH_TOKEN;
}
// Do NOT set ANTHROPIC_BASE_URL - let SDK use default Anthropic endpoint
// Do NOT set model mappings - use standard Claude model names
// Do NOT set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
}
// Always add system vars from process.env
for (const key of SYSTEM_ENV_VARS) {
if (process.env[key]) {
env[key] = process.env[key];
}
}
return env;
}
@@ -70,6 +177,8 @@ export class ClaudeProvider extends BaseProvider {
conversationHistory,
sdkSessionId,
thinkingLevel,
claudeApiProfile,
credentials,
} = options;
// Convert thinking level to token budget
@@ -82,7 +191,9 @@ export class ClaudeProvider extends BaseProvider {
maxTurns,
cwd,
// Pass only explicitly allowed environment variables to SDK
env: buildEnv(),
// When a profile is active, uses profile settings (clean switch)
// When no profile, uses direct Anthropic API (from process.env or CLI OAuth)
env: buildEnv(claudeApiProfile, credentials),
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
...(allowedTools && { allowedTools }),
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation

View File

@@ -44,7 +44,7 @@ export class CursorConfigManager {
// Return default config with all available models
return {
defaultModel: 'auto',
defaultModel: 'cursor-auto',
models: getAllCursorModelIds(),
};
}
@@ -77,7 +77,7 @@ export class CursorConfigManager {
* Get the default model
*/
getDefaultModel(): CursorModelId {
return this.config.defaultModel || 'auto';
return this.config.defaultModel || 'cursor-auto';
}
/**
@@ -93,7 +93,7 @@ export class CursorConfigManager {
* Get enabled models
*/
getEnabledModels(): CursorModelId[] {
return this.config.models || ['auto'];
return this.config.models || ['cursor-auto'];
}
/**
@@ -174,7 +174,7 @@ export class CursorConfigManager {
*/
reset(): void {
this.config = {
defaultModel: 'auto',
defaultModel: 'cursor-auto',
models: getAllCursorModelIds(),
};
this.saveConfig();

View File

@@ -20,6 +20,8 @@ import type {
ContentBlock,
ThinkingLevel,
ReasoningEffort,
ClaudeApiProfile,
Credentials,
} from '@automaker/types';
import { stripProviderPrefix } from '@automaker/types';
@@ -54,6 +56,10 @@ export interface SimpleQueryOptions {
readOnly?: boolean;
/** Setting sources for CLAUDE.md loading */
settingSources?: Array<'user' | 'project' | 'local'>;
/** Active Claude API profile for alternative endpoint configuration */
claudeApiProfile?: ClaudeApiProfile;
/** Credentials for resolving 'credentials' apiKeySource in Claude API profiles */
credentials?: Credentials;
}
/**
@@ -125,6 +131,8 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise<SimpleQu
reasoningEffort: options.reasoningEffort,
readOnly: options.readOnly,
settingSources: options.settingSources,
claudeApiProfile: options.claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
for await (const msg of provider.executeQuery(providerOptions)) {
@@ -207,6 +215,8 @@ export async function streamingQuery(options: StreamingQueryOptions): Promise<Si
reasoningEffort: options.reasoningEffort,
readOnly: options.readOnly,
settingSources: options.settingSources,
claudeApiProfile: options.claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
for await (const msg of provider.executeQuery(providerOptions)) {

View File

@@ -14,7 +14,11 @@ import { streamingQuery } from '../../providers/simple-query-service.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
import { FeatureLoader } from '../../services/feature-loader.js';
const logger = createLogger('SpecRegeneration');
@@ -123,6 +127,12 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
logger.info('Using model:', model);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[FeatureGeneration]'
);
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt,
@@ -134,6 +144,8 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
thinkingLevel,
readOnly: true, // Feature generation only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
onText: (text) => {
logger.debug(`Feature text block received (${text.length} chars)`);
events.emit('spec-regeneration:event', {

View File

@@ -16,7 +16,11 @@ import { streamingQuery } from '../../providers/simple-query-service.js';
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
const logger = createLogger('SpecRegeneration');
@@ -100,6 +104,12 @@ ${prompts.appSpec.structuredSpecInstructions}`;
logger.info('Using model:', model);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[SpecRegeneration]'
);
let responseText = '';
let structuredOutput: SpecOutput | null = null;
@@ -132,6 +142,8 @@ Your entire response should be valid JSON starting with { and ending with }. No
thinkingLevel,
readOnly: true, // Spec generation only reads code, we write the spec ourselves
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',

View File

@@ -15,7 +15,10 @@ import { resolvePhaseModel } from '@automaker/model-resolver';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
import { FeatureLoader } from '../../services/feature-loader.js';
import {
extractImplementedFeatures,
@@ -157,6 +160,12 @@ export async function syncSpec(
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[SpecSync]'
);
// Use AI to analyze tech stack
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
@@ -185,6 +194,8 @@ Return ONLY this JSON format, no other text:
thinkingLevel,
readOnly: true,
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
onText: (text) => {
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
},

View File

@@ -117,9 +117,27 @@ export function createAuthRoutes(): Router {
*
* Returns whether the current request is authenticated.
* Used by the UI to determine if login is needed.
*
* If AUTOMAKER_AUTO_LOGIN=true is set, automatically creates a session
* for unauthenticated requests (useful for development).
*/
router.get('/status', (req, res) => {
const authenticated = isRequestAuthenticated(req);
router.get('/status', async (req, res) => {
let authenticated = isRequestAuthenticated(req);
// Auto-login for development: create session automatically if enabled
// Only works in non-production environments as a safeguard
if (
!authenticated &&
process.env.AUTOMAKER_AUTO_LOGIN === 'true' &&
process.env.NODE_ENV !== 'production'
) {
const sessionToken = await createSession();
const cookieOptions = getSessionCookieOptions();
const cookieName = getSessionCookieName();
res.cookie(cookieName, sessionToken, cookieOptions);
authenticated = true;
}
res.json({
success: true,
authenticated,

View File

@@ -10,6 +10,8 @@ import { validatePathParams } from '../../middleware/validate-paths.js';
import { createStopFeatureHandler } from './routes/stop-feature.js';
import { createStatusHandler } from './routes/status.js';
import { createRunFeatureHandler } from './routes/run-feature.js';
import { createStartHandler } from './routes/start.js';
import { createStopHandler } from './routes/stop.js';
import { createVerifyFeatureHandler } from './routes/verify-feature.js';
import { createResumeFeatureHandler } from './routes/resume-feature.js';
import { createContextExistsHandler } from './routes/context-exists.js';
@@ -22,6 +24,10 @@ import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
const router = Router();
// Auto loop control routes
router.post('/start', validatePathParams('projectPath'), createStartHandler(autoModeService));
router.post('/stop', validatePathParams('projectPath'), createStopHandler(autoModeService));
router.post('/stop-feature', createStopFeatureHandler(autoModeService));
router.post('/status', validatePathParams('projectPath?'), createStatusHandler(autoModeService));
router.post(

View File

@@ -0,0 +1,54 @@
/**
* POST /start endpoint - Start auto mode loop for a project
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createStartHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, maxConcurrency } = req.body as {
projectPath: string;
maxConcurrency?: number;
};
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
// Check if already running
if (autoModeService.isAutoLoopRunningForProject(projectPath)) {
res.json({
success: true,
message: 'Auto mode is already running for this project',
alreadyRunning: true,
});
return;
}
// Start the auto loop for this project
await autoModeService.startAutoLoopForProject(projectPath, maxConcurrency ?? 3);
logger.info(
`Started auto loop for project: ${projectPath} with maxConcurrency: ${maxConcurrency ?? 3}`
);
res.json({
success: true,
message: `Auto mode started with max ${maxConcurrency ?? 3} concurrent features`,
});
} catch (error) {
logError(error, 'Start auto mode failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,5 +1,8 @@
/**
* POST /status endpoint - Get auto mode status
*
* If projectPath is provided, returns per-project status including autoloop state.
* If no projectPath, returns global status for backward compatibility.
*/
import type { Request, Response } from 'express';
@@ -9,10 +12,30 @@ import { getErrorMessage, logError } from '../common.js';
export function createStatusHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath?: string };
// If projectPath is provided, return per-project status
if (projectPath) {
const projectStatus = autoModeService.getStatusForProject(projectPath);
res.json({
success: true,
isRunning: projectStatus.runningCount > 0,
isAutoLoopRunning: projectStatus.isAutoLoopRunning,
runningFeatures: projectStatus.runningFeatures,
runningCount: projectStatus.runningCount,
maxConcurrency: projectStatus.maxConcurrency,
projectPath,
});
return;
}
// Fall back to global status for backward compatibility
const status = autoModeService.getStatus();
const activeProjects = autoModeService.getActiveAutoLoopProjects();
res.json({
success: true,
...status,
activeAutoLoopProjects: activeProjects,
});
} catch (error) {
logError(error, 'Get status failed');

View File

@@ -0,0 +1,54 @@
/**
* POST /stop endpoint - Stop auto mode loop for a project
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createStopHandler(autoModeService: AutoModeService) {
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;
}
// Check if running
if (!autoModeService.isAutoLoopRunningForProject(projectPath)) {
res.json({
success: true,
message: 'Auto mode is not running for this project',
wasRunning: false,
});
return;
}
// Stop the auto loop for this project
const runningCount = await autoModeService.stopAutoLoopForProject(projectPath);
logger.info(
`Stopped auto loop for project: ${projectPath}, ${runningCount} features still running`
);
res.json({
success: true,
message: 'Auto mode stopped',
runningFeaturesCount: runningCount,
});
} catch (error) {
logError(error, 'Stop auto mode failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -100,11 +100,60 @@ export function getAbortController(): AbortController | null {
return currentAbortController;
}
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
/**
* Map SDK/CLI errors to user-friendly messages
*/
export function mapBacklogPlanError(rawMessage: string): string {
// Claude Code spawn failures
if (
rawMessage.includes('Failed to spawn Claude Code process') ||
rawMessage.includes('spawn node ENOENT') ||
rawMessage.includes('Claude Code executable not found') ||
rawMessage.includes('Claude Code native binary not found')
) {
return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.';
}
return String(error);
// Claude Code process crash
if (rawMessage.includes('Claude Code process exited')) {
return 'Claude exited unexpectedly. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup.';
}
// Rate limiting
if (rawMessage.toLowerCase().includes('rate limit') || rawMessage.includes('429')) {
return 'Rate limited. Please wait a moment and try again.';
}
// Network errors
if (
rawMessage.toLowerCase().includes('network') ||
rawMessage.toLowerCase().includes('econnrefused') ||
rawMessage.toLowerCase().includes('timeout')
) {
return 'Network error. Check your internet connection and try again.';
}
// Authentication errors
if (
rawMessage.toLowerCase().includes('not authenticated') ||
rawMessage.toLowerCase().includes('unauthorized') ||
rawMessage.includes('401')
) {
return 'Authentication failed. Please check your API key or run `claude login` to authenticate.';
}
// Return original message for unknown errors
return rawMessage;
}
export function getErrorMessage(error: unknown): string {
let rawMessage: string;
if (error instanceof Error) {
rawMessage = error.message;
} else {
rawMessage = String(error);
}
return mapBacklogPlanError(rawMessage);
}
export function logError(error: unknown, context: string): void {

View File

@@ -25,7 +25,11 @@ import {
saveBacklogPlan,
} from './common.js';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
const featureLoader = new FeatureLoader();
@@ -161,6 +165,12 @@ ${userPrompt}`;
finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt
}
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[BacklogPlan]'
);
// Execute the query
const stream = provider.executeQuery({
prompt: finalPrompt,
@@ -173,6 +183,8 @@ ${userPrompt}`;
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
readOnly: true, // Plan generation only generates text, doesn't write files
thinkingLevel, // Pass thinking level for extended thinking
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
let responseText = '';

View File

@@ -53,13 +53,12 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
setRunningState(true, abortController);
// Start generation in background
// Note: generateBacklogPlan handles its own error event emission,
// so we only log here to avoid duplicate error toasts
generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model)
.catch((error) => {
// Just log - error event already emitted by generateBacklogPlan
logError(error, 'Generate backlog plan failed (background)');
events.emit('backlog-plan:event', {
type: 'backlog_plan_error',
error: getErrorMessage(error),
});
})
.finally(() => {
setRunningState(false, null);

View File

@@ -22,6 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeFile');
@@ -165,6 +166,12 @@ ${contentToAnalyze}`;
logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[DescribeFile]'
);
// Use simpleQuery - provider abstraction handles routing to correct provider
const result = await simpleQuery({
prompt,
@@ -175,6 +182,8 @@ ${contentToAnalyze}`;
thinkingLevel,
readOnly: true, // File description only reads, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
const description = result.text;

View File

@@ -22,6 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeImage');
@@ -284,6 +285,12 @@ export function createDescribeImageHandler(
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[DescribeImage]');
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[DescribeImage]'
);
// Build the instruction text from centralized prompts
const instructionText = prompts.contextDescription.describeImagePrompt;
@@ -325,6 +332,8 @@ export function createDescribeImageHandler(
thinkingLevel,
readOnly: true, // Image description only reads, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`);

View File

@@ -12,7 +12,10 @@ import { resolveModelString } from '@automaker/model-resolver';
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
import {
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
import {
buildUserPrompt,
isValidEnhancementMode,
@@ -126,6 +129,12 @@ export function createEnhanceHandler(
logger.debug(`Using model: ${resolvedModel}`);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[EnhancePrompt]'
);
// Use simpleQuery - provider abstraction handles routing to correct provider
// The system prompt is combined with user prompt since some providers
// don't have a separate system prompt concept
@@ -137,6 +146,8 @@ export function createEnhanceHandler(
allowedTools: [],
thinkingLevel,
readOnly: true, // Prompt enhancement only generates text, doesn't write files
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
const enhancedText = result.text;

View File

@@ -10,7 +10,10 @@ import { createLogger } from '@automaker/utils';
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
import {
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('GenerateTitle');
@@ -60,6 +63,12 @@ export function createGenerateTitleHandler(
const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]');
const systemPrompt = prompts.titleGeneration.systemPrompt;
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[GenerateTitle]'
);
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
// Use simpleQuery - provider abstraction handles all the streaming/extraction
@@ -69,6 +78,8 @@ export function createGenerateTitleHandler(
cwd: process.cwd(),
maxTurns: 1,
allowedTools: [],
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
const title = result.text;

View File

@@ -1,5 +1,12 @@
/**
* GET /image endpoint - Serve image files
*
* Requires authentication via auth middleware:
* - apiKey query parameter (Electron mode)
* - token query parameter (web mode)
* - session cookie (web mode)
* - X-API-Key header (Electron mode)
* - X-Session-Token header (web mode)
*/
import type { Request, Response } from 'express';

View File

@@ -34,7 +34,11 @@ import {
ValidationComment,
ValidationLinkedPR,
} from './validation-schema.js';
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
import {
getPromptCustomization,
getAutoLoadClaudeMdSetting,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
import {
trySetValidationRunning,
clearValidationStatus,
@@ -43,7 +47,6 @@ import {
logger,
} from './validation-common.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
/**
* Request body for issue validation
@@ -166,6 +169,12 @@ ${basePrompt}`;
logger.info(`Using model: ${model}`);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[IssueValidation]'
);
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt: finalPrompt,
@@ -177,6 +186,8 @@ ${basePrompt}`;
reasoningEffort: effectiveReasoningEffort,
readOnly: true, // Issue validation only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',

View File

@@ -45,18 +45,24 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
}
// Minimal debug logging to help diagnose accidental wipes.
if ('projects' in updates || 'theme' in updates || 'localStorageMigrated' in updates) {
const projectsLen = Array.isArray((updates as any).projects)
? (updates as any).projects.length
: undefined;
logger.info(
`Update global settings request: projects=${projectsLen ?? 'n/a'}, theme=${
(updates as any).theme ?? 'n/a'
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
);
}
const projectsLen = Array.isArray((updates as any).projects)
? (updates as any).projects.length
: undefined;
const trashedLen = Array.isArray((updates as any).trashedProjects)
? (updates as any).trashedProjects.length
: undefined;
logger.info(
`[SERVER_SETTINGS_UPDATE] Request received: projects=${projectsLen ?? 'n/a'}, trashedProjects=${trashedLen ?? 'n/a'}, theme=${
(updates as any).theme ?? 'n/a'
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
);
logger.info('[SERVER_SETTINGS_UPDATE] Calling updateGlobalSettings...');
const settings = await settingsService.updateGlobalSettings(updates);
logger.info(
'[SERVER_SETTINGS_UPDATE] Update complete, projects count:',
settings.projects?.length ?? 0
);
// Apply server log level if it was updated
if ('serverLogLevel' in updates && updates.serverLogLevel) {

View File

@@ -15,7 +15,11 @@ import { FeatureLoader } from '../../services/feature-loader.js';
import { getAppSpecPath } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
const logger = createLogger('Suggestions');
@@ -192,6 +196,12 @@ ${prompts.suggestions.baseTemplate}`;
logger.info('[Suggestions] Using model:', model);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[Suggestions]'
);
let responseText = '';
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
@@ -223,6 +233,8 @@ Your entire response should be valid JSON starting with { and ending with }. No
thinkingLevel,
readOnly: true, // Suggestions only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',

View File

@@ -29,6 +29,13 @@ import {
createGetAvailableEditorsHandler,
createRefreshEditorsHandler,
} from './routes/open-in-editor.js';
import {
createOpenInTerminalHandler,
createGetAvailableTerminalsHandler,
createGetDefaultTerminalHandler,
createRefreshTerminalsHandler,
createOpenInExternalTerminalHandler,
} from './routes/open-in-terminal.js';
import { createInitGitHandler } from './routes/init-git.js';
import { createMigrateHandler } from './routes/migrate.js';
import { createStartDevHandler } from './routes/start-dev.js';
@@ -97,9 +104,25 @@ export function createWorktreeRoutes(
);
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
router.post(
'/open-in-terminal',
validatePathParams('worktreePath'),
createOpenInTerminalHandler()
);
router.get('/default-editor', createGetDefaultEditorHandler());
router.get('/available-editors', createGetAvailableEditorsHandler());
router.post('/refresh-editors', createRefreshEditorsHandler());
// External terminal routes
router.get('/available-terminals', createGetAvailableTerminalsHandler());
router.get('/default-terminal', createGetDefaultTerminalHandler());
router.post('/refresh-terminals', createRefreshTerminalsHandler());
router.post(
'/open-in-external-terminal',
validatePathParams('worktreePath'),
createOpenInExternalTerminalHandler()
);
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
router.post('/migrate', createMigrateHandler());
router.post(

View File

@@ -13,6 +13,7 @@ import {
} from '../common.js';
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';
import { validatePRState } from '@automaker/types';
const logger = createLogger('CreatePR');
@@ -268,11 +269,12 @@ export function createCreatePRHandler() {
prAlreadyExisted = true;
// Store the existing PR info in metadata
// GitHub CLI returns uppercase states: OPEN, MERGED, CLOSED
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
number: existingPr.number,
url: existingPr.url,
title: existingPr.title || title,
state: existingPr.state || 'open',
state: validatePRState(existingPr.state),
createdAt: new Date().toISOString(),
});
logger.debug(
@@ -319,11 +321,12 @@ export function createCreatePRHandler() {
if (prNumber) {
try {
// Note: GitHub doesn't have a 'DRAFT' state - drafts still show as 'OPEN'
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
number: prNumber,
url: prUrl,
title,
state: draft ? 'draft' : 'open',
state: 'OPEN',
createdAt: new Date().toISOString(),
});
logger.debug(`Stored PR info for branch ${branchName}: PR #${prNumber}`);
@@ -352,11 +355,12 @@ export function createCreatePRHandler() {
prNumber = existingPr.number;
prAlreadyExisted = true;
// GitHub CLI returns uppercase states: OPEN, MERGED, CLOSED
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
number: existingPr.number,
url: existingPr.url,
title: existingPr.title || title,
state: existingPr.state || 'open',
state: validatePRState(existingPr.state),
createdAt: new Date().toISOString(),
});
logger.debug(`Fetched and stored existing PR: #${existingPr.number}`);

View File

@@ -10,7 +10,6 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import { existsSync } from 'fs';
import { join } from 'path';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
@@ -18,6 +17,7 @@ import { mergeCommitMessagePrompts } from '@automaker/prompts';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getErrorMessage, logError } from '../common.js';
import { getActiveClaudeApiProfile } from '../../../lib/settings-helpers.js';
const logger = createLogger('GenerateCommitMessage');
const execAsync = promisify(exec);
@@ -74,33 +74,6 @@ interface GenerateCommitMessageErrorResponse {
error: string;
}
async function extractTextFromStream(
stream: AsyncIterable<{
type: string;
subtype?: string;
result?: string;
message?: {
content?: Array<{ type: string; text?: string }>;
};
}>
): Promise<string> {
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}
return responseText;
}
export function createGenerateCommitMessageHandler(
settingsService?: SettingsService
): (req: Request, res: Response) => Promise<void> {
@@ -195,57 +168,53 @@ export function createGenerateCommitMessageHandler(
// Get the effective system prompt (custom or default)
const systemPrompt = await getSystemPrompt(settingsService);
let message: string;
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[GenerateCommitMessage]'
);
// Route to appropriate provider based on model type
if (isCursorModel(model)) {
// Use Cursor provider for Cursor models
logger.info(`Using Cursor provider for model: ${model}`);
// Get provider for the model type
const provider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
const provider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
// For Cursor models, combine prompts since Cursor doesn't support systemPrompt separation
const effectivePrompt = isCursorModel(model)
? `${systemPrompt}\n\n${userPrompt}`
: userPrompt;
const effectiveSystemPrompt = isCursorModel(model) ? undefined : systemPrompt;
const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`;
logger.info(`Using ${provider.getName()} provider for model: ${model}`);
let responseText = '';
const cursorStream = provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
cwd: worktreePath,
maxTurns: 1,
allowedTools: [],
readOnly: true,
});
let responseText = '';
const stream = provider.executeQuery({
prompt: effectivePrompt,
model: bareModel,
cwd: worktreePath,
systemPrompt: effectiveSystemPrompt,
maxTurns: 1,
allowedTools: [],
readOnly: true,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
// Wrap with timeout to prevent indefinite hangs
for await (const msg of withTimeout(cursorStream, AI_TIMEOUT_MS)) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
// Wrap with timeout to prevent indefinite hangs
for await (const msg of withTimeout(stream, AI_TIMEOUT_MS)) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result if available (some providers return final text here)
responseText = msg.result;
}
message = responseText.trim();
} else {
// Use Claude SDK for Claude models
const stream = query({
prompt: userPrompt,
options: {
model,
systemPrompt,
maxTurns: 1,
allowedTools: [],
permissionMode: 'default',
},
});
// Wrap with timeout to prevent indefinite hangs
message = await extractTextFromStream(withTimeout(stream, AI_TIMEOUT_MS));
}
const message = responseText.trim();
if (!message || message.trim().length === 0) {
logger.warn('Received empty response from model');
const response: GenerateCommitMessageErrorResponse = {

View File

@@ -14,8 +14,13 @@ import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js';
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
import {
readAllWorktreeMetadata,
updateWorktreePRInfo,
type WorktreePRInfo,
} from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';
import { validatePRState } from '@automaker/types';
import {
checkGitHubRemote,
type GitHubRemoteStatus,
@@ -168,8 +173,11 @@ async function getGitHubRemoteStatus(projectPath: string): Promise<GitHubRemoteS
}
/**
* Fetch open PRs from GitHub and create a map of branch name to PR info.
* This allows detecting PRs that were created outside the app.
* Fetch all PRs from GitHub and create a map of branch name to PR info.
* Uses --state all to include merged/closed PRs, allowing detection of
* state changes (e.g., when a PR is merged on GitHub).
*
* This also allows detecting PRs that were created outside the app.
*
* Uses cached GitHub remote status to avoid repeated warnings when the
* project doesn't have a GitHub remote configured.
@@ -192,9 +200,9 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
? `-R ${remoteStatus.owner}/${remoteStatus.repo}`
: '';
// Fetch open PRs from GitHub
// Fetch all PRs from GitHub (including merged/closed to detect state changes)
const { stdout } = await execAsync(
`gh pr list ${repoFlag} --state open --json number,title,url,state,headRefName,createdAt --limit 1000`,
`gh pr list ${repoFlag} --state all --json number,title,url,state,headRefName,createdAt --limit 1000`,
{ cwd: projectPath, env: execEnv, timeout: 15000 }
);
@@ -212,7 +220,8 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
number: pr.number,
url: pr.url,
title: pr.title,
state: pr.state,
// GitHub CLI returns state as uppercase: OPEN, MERGED, CLOSED
state: validatePRState(pr.state),
createdAt: pr.createdAt,
});
}
@@ -351,23 +360,43 @@ export function createListHandler() {
}
}
// Add PR info from metadata or GitHub for each worktree
// Only fetch GitHub PRs if includeDetails is requested (performance optimization)
// Assign PR info to each worktree, preferring fresh GitHub data over cached metadata.
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
const githubPRs = includeDetails
? await fetchGitHubPRs(projectPath)
: new Map<string, WorktreePRInfo>();
for (const worktree of worktrees) {
// Skip PR assignment for the main worktree - it's not meaningful to show
// PRs on the main branch tab, and can be confusing if someone created
// a PR from main to another branch
if (worktree.isMain) {
continue;
}
const metadata = allMetadata.get(worktree.branch);
if (metadata?.pr) {
// Use stored metadata (more complete info)
worktree.pr = metadata.pr;
} else if (includeDetails) {
// Fall back to GitHub PR detection only when includeDetails is requested
const githubPR = githubPRs.get(worktree.branch);
if (githubPR) {
worktree.pr = githubPR;
const githubPR = githubPRs.get(worktree.branch);
if (githubPR) {
// Prefer fresh GitHub data (it has the current state)
worktree.pr = githubPR;
// Sync metadata with GitHub state when:
// 1. No metadata exists for this PR (PR created externally)
// 2. State has changed (e.g., merged/closed on GitHub)
const needsSync = !metadata?.pr || metadata.pr.state !== githubPR.state;
if (needsSync) {
// Fire and forget - don't block the response
updateWorktreePRInfo(projectPath, worktree.branch, githubPR).catch((err) => {
logger.warn(
`Failed to update PR info for ${worktree.branch}: ${getErrorMessage(err)}`
);
});
}
} else if (metadata?.pr && metadata.pr.state === 'OPEN') {
// Fall back to stored metadata only if the PR is still OPEN
worktree.pr = metadata.pr;
}
}

View File

@@ -0,0 +1,181 @@
/**
* Terminal endpoints for opening worktree directories in terminals
*
* POST /open-in-terminal - Open in system default terminal (integrated)
* GET /available-terminals - List all available external terminals
* GET /default-terminal - Get the default external terminal
* POST /refresh-terminals - Clear terminal cache and re-detect
* POST /open-in-external-terminal - Open a directory in an external terminal
*/
import type { Request, Response } from 'express';
import { isAbsolute } from 'path';
import {
openInTerminal,
clearTerminalCache,
detectAllTerminals,
detectDefaultTerminal,
openInExternalTerminal,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('open-in-terminal');
/**
* Handler to open in system default terminal (integrated terminal behavior)
*/
export function createOpenInTerminalHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};
if (!worktreePath || typeof worktreePath !== 'string') {
res.status(400).json({
success: false,
error: 'worktreePath required and must be a string',
});
return;
}
// 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;
}
// Use the platform utility to open in terminal
const result = await openInTerminal(worktreePath);
res.json({
success: true,
result: {
message: `Opened terminal in ${worktreePath}`,
terminalName: result.terminalName,
},
});
} catch (error) {
logError(error, 'Open in terminal failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Handler to get all available external terminals
*/
export function createGetAvailableTerminalsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const terminals = await detectAllTerminals();
res.json({
success: true,
result: {
terminals,
},
});
} catch (error) {
logError(error, 'Get available terminals failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Handler to get the default external terminal
*/
export function createGetDefaultTerminalHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const terminal = await detectDefaultTerminal();
res.json({
success: true,
result: terminal
? {
terminalId: terminal.id,
terminalName: terminal.name,
terminalCommand: terminal.command,
}
: null,
});
} catch (error) {
logError(error, 'Get default terminal failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Handler to refresh the terminal cache and re-detect available terminals
* Useful when the user has installed/uninstalled terminals
*/
export function createRefreshTerminalsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Clear the cache
clearTerminalCache();
// Re-detect terminals (this will repopulate the cache)
const terminals = await detectAllTerminals();
logger.info(`Terminal cache refreshed, found ${terminals.length} terminals`);
res.json({
success: true,
result: {
terminals,
message: `Found ${terminals.length} available external terminals`,
},
});
} catch (error) {
logError(error, 'Refresh terminals failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Handler to open a directory in an external terminal
*/
export function createOpenInExternalTerminalHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, terminalId } = req.body as {
worktreePath: string;
terminalId?: string;
};
if (!worktreePath || typeof worktreePath !== 'string') {
res.status(400).json({
success: false,
error: 'worktreePath required and must be a string',
});
return;
}
if (!isAbsolute(worktreePath)) {
res.status(400).json({
success: false,
error: 'worktreePath must be an absolute path',
});
return;
}
const result = await openInExternalTerminal(worktreePath, terminalId);
res.json({
success: true,
result: {
message: `Opened ${worktreePath} in ${result.terminalName}`,
terminalName: result.terminalName,
},
});
} catch (error) {
logError(error, 'Open in external terminal failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -29,6 +29,7 @@ import {
getSkillsConfiguration,
getSubagentsConfiguration,
getCustomSubagents,
getActiveClaudeApiProfile,
} from '../lib/settings-helpers.js';
interface Message {
@@ -274,6 +275,12 @@ export class AgentService {
? await getCustomSubagents(this.settingsService, effectiveWorkDir)
: undefined;
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
this.settingsService,
'[AgentService]'
);
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
// Use the user's message as task context for smart memory selection
const contextResult = await loadContextFiles({
@@ -378,6 +385,8 @@ export class AgentService {
agents: customSubagents, // Pass custom subagents for task delegation
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
// Build prompt content with images

View File

@@ -63,6 +63,7 @@ import {
filterClaudeMdFromContext,
getMCPServersFromSettings,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../lib/settings-helpers.js';
import { getNotificationService } from './notification-service.js';
@@ -235,6 +236,17 @@ interface AutoModeConfig {
projectPath: string;
}
/**
* Per-project autoloop state for multi-project support
*/
interface ProjectAutoLoopState {
abortController: AbortController;
config: AutoModeConfig;
isRunning: boolean;
consecutiveFailures: { timestamp: number; error: string }[];
pausedDueToFailures: boolean;
}
/**
* Execution state for recovery after server restart
* Tracks which features were running and auto-loop configuration
@@ -267,12 +279,15 @@ export class AutoModeService {
private runningFeatures = new Map<string, RunningFeature>();
private autoLoop: AutoLoopState | null = null;
private featureLoader = new FeatureLoader();
// Per-project autoloop state (supports multiple concurrent projects)
private autoLoopsByProject = new Map<string, ProjectAutoLoopState>();
// Legacy single-project properties (kept for backward compatibility during transition)
private autoLoopRunning = false;
private autoLoopAbortController: AbortController | null = null;
private config: AutoModeConfig | null = null;
private pendingApprovals = new Map<string, PendingApproval>();
private settingsService: SettingsService | null = null;
// Track consecutive failures to detect quota/API issues
// Track consecutive failures to detect quota/API issues (legacy global, now per-project in autoLoopsByProject)
private consecutiveFailures: { timestamp: number; error: string }[] = [];
private pausedDueToFailures = false;
@@ -284,6 +299,44 @@ export class AutoModeService {
/**
* Track a failure and check if we should pause due to consecutive failures.
* This handles cases where the SDK doesn't return useful error messages.
* @param projectPath - The project to track failure for
* @param errorInfo - Error information
*/
private trackFailureAndCheckPauseForProject(
projectPath: string,
errorInfo: { type: string; message: string }
): boolean {
const projectState = this.autoLoopsByProject.get(projectPath);
if (!projectState) {
// Fall back to legacy global tracking
return this.trackFailureAndCheckPause(errorInfo);
}
const now = Date.now();
// Add this failure
projectState.consecutiveFailures.push({ timestamp: now, error: errorInfo.message });
// Remove old failures outside the window
projectState.consecutiveFailures = projectState.consecutiveFailures.filter(
(f) => now - f.timestamp < FAILURE_WINDOW_MS
);
// Check if we've hit the threshold
if (projectState.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD) {
return true; // Should pause
}
// Also immediately pause for known quota/rate limit errors
if (errorInfo.type === 'quota_exhausted' || errorInfo.type === 'rate_limit') {
return true;
}
return false;
}
/**
* Track a failure and check if we should pause due to consecutive failures (legacy global).
*/
private trackFailureAndCheckPause(errorInfo: { type: string; message: string }): boolean {
const now = Date.now();
@@ -311,7 +364,49 @@ export class AutoModeService {
/**
* Signal that we should pause due to repeated failures or quota exhaustion.
* This will pause the auto loop to prevent repeated failures.
* This will pause the auto loop for a specific project.
* @param projectPath - The project to pause
* @param errorInfo - Error information
*/
private signalShouldPauseForProject(
projectPath: string,
errorInfo: { type: string; message: string }
): void {
const projectState = this.autoLoopsByProject.get(projectPath);
if (!projectState) {
// Fall back to legacy global pause
this.signalShouldPause(errorInfo);
return;
}
if (projectState.pausedDueToFailures) {
return; // Already paused
}
projectState.pausedDueToFailures = true;
const failureCount = projectState.consecutiveFailures.length;
logger.info(
`Pausing auto loop for ${projectPath} after ${failureCount} consecutive failures. Last error: ${errorInfo.type}`
);
// Emit event to notify UI
this.emitAutoModeEvent('auto_mode_paused_failures', {
message:
failureCount >= CONSECUTIVE_FAILURE_THRESHOLD
? `Auto Mode paused: ${failureCount} consecutive failures detected. This may indicate a quota limit or API issue. Please check your usage and try again.`
: 'Auto Mode paused: Usage limit or API error detected. Please wait for your quota to reset or check your API configuration.',
errorType: errorInfo.type,
originalError: errorInfo.message,
failureCount,
projectPath,
});
// Stop the auto loop for this project
this.stopAutoLoopForProject(projectPath);
}
/**
* Signal that we should pause due to repeated failures or quota exhaustion (legacy global).
*/
private signalShouldPause(errorInfo: { type: string; message: string }): void {
if (this.pausedDueToFailures) {
@@ -341,7 +436,19 @@ export class AutoModeService {
}
/**
* Reset failure tracking (called when user manually restarts auto mode)
* Reset failure tracking for a specific project
* @param projectPath - The project to reset failure tracking for
*/
private resetFailureTrackingForProject(projectPath: string): void {
const projectState = this.autoLoopsByProject.get(projectPath);
if (projectState) {
projectState.consecutiveFailures = [];
projectState.pausedDueToFailures = false;
}
}
/**
* Reset failure tracking (called when user manually restarts auto mode) - legacy global
*/
private resetFailureTracking(): void {
this.consecutiveFailures = [];
@@ -349,16 +456,255 @@ export class AutoModeService {
}
/**
* Record a successful feature completion to reset consecutive failure count
* Record a successful feature completion to reset consecutive failure count for a project
* @param projectPath - The project to record success for
*/
private recordSuccessForProject(projectPath: string): void {
const projectState = this.autoLoopsByProject.get(projectPath);
if (projectState) {
projectState.consecutiveFailures = [];
}
}
/**
* Record a successful feature completion to reset consecutive failure count - legacy global
*/
private recordSuccess(): void {
this.consecutiveFailures = [];
}
/**
* Start the auto mode loop for a specific project (supports multiple concurrent projects)
* @param projectPath - The project to start auto mode for
* @param maxConcurrency - Maximum concurrent features (default: 3)
*/
async startAutoLoopForProject(projectPath: string, maxConcurrency = 3): Promise<void> {
// Check if this project already has an active autoloop
const existingState = this.autoLoopsByProject.get(projectPath);
if (existingState?.isRunning) {
throw new Error(`Auto mode is already running for project: ${projectPath}`);
}
// Create new project autoloop state
const abortController = new AbortController();
const config: AutoModeConfig = {
maxConcurrency,
useWorktrees: true,
projectPath,
};
const projectState: ProjectAutoLoopState = {
abortController,
config,
isRunning: true,
consecutiveFailures: [],
pausedDueToFailures: false,
};
this.autoLoopsByProject.set(projectPath, projectState);
logger.info(
`Starting auto loop for project: ${projectPath} with maxConcurrency: ${maxConcurrency}`
);
this.emitAutoModeEvent('auto_mode_started', {
message: `Auto mode started with max ${maxConcurrency} concurrent features`,
projectPath,
});
// Save execution state for recovery after restart
await this.saveExecutionStateForProject(projectPath, maxConcurrency);
// Run the loop in the background
this.runAutoLoopForProject(projectPath).catch((error) => {
logger.error(`Loop error for ${projectPath}:`, error);
const errorInfo = classifyError(error);
this.emitAutoModeEvent('auto_mode_error', {
error: errorInfo.message,
errorType: errorInfo.type,
projectPath,
});
});
}
/**
* Run the auto loop for a specific project
*/
private async runAutoLoopForProject(projectPath: string): Promise<void> {
const projectState = this.autoLoopsByProject.get(projectPath);
if (!projectState) {
logger.warn(`No project state found for ${projectPath}, stopping loop`);
return;
}
logger.info(
`[AutoLoop] Starting loop for ${projectPath}, maxConcurrency: ${projectState.config.maxConcurrency}`
);
let iterationCount = 0;
while (projectState.isRunning && !projectState.abortController.signal.aborted) {
iterationCount++;
try {
// Count running features for THIS project only
const projectRunningCount = this.getRunningCountForProject(projectPath);
// Check if we have capacity for this project
if (projectRunningCount >= projectState.config.maxConcurrency) {
logger.debug(
`[AutoLoop] At capacity (${projectRunningCount}/${projectState.config.maxConcurrency}), waiting...`
);
await this.sleep(5000);
continue;
}
// Load pending features for this project
const pendingFeatures = await this.loadPendingFeatures(projectPath);
logger.debug(
`[AutoLoop] Iteration ${iterationCount}: Found ${pendingFeatures.length} pending features, ${projectRunningCount} running`
);
if (pendingFeatures.length === 0) {
this.emitAutoModeEvent('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath,
});
logger.info(`[AutoLoop] No pending features, sleeping for 10s...`);
await this.sleep(10000);
continue;
}
// Find a feature not currently running
const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id));
if (nextFeature) {
logger.info(`[AutoLoop] Starting feature ${nextFeature.id}: ${nextFeature.title}`);
// Start feature execution in background
this.executeFeature(
projectPath,
nextFeature.id,
projectState.config.useWorktrees,
true
).catch((error) => {
logger.error(`Feature ${nextFeature.id} error:`, error);
});
} else {
logger.debug(`[AutoLoop] All pending features are already running`);
}
await this.sleep(2000);
} catch (error) {
logger.error(`[AutoLoop] Loop iteration error for ${projectPath}:`, error);
await this.sleep(5000);
}
}
// Mark as not running when loop exits
projectState.isRunning = false;
logger.info(
`[AutoLoop] Loop stopped for project: ${projectPath} after ${iterationCount} iterations`
);
}
/**
* Get count of running features for a specific project
*/
private getRunningCountForProject(projectPath: string): number {
let count = 0;
for (const [, feature] of this.runningFeatures) {
if (feature.projectPath === projectPath) {
count++;
}
}
return count;
}
/**
* Stop the auto mode loop for a specific project
* @param projectPath - The project to stop auto mode for
*/
async stopAutoLoopForProject(projectPath: string): Promise<number> {
const projectState = this.autoLoopsByProject.get(projectPath);
if (!projectState) {
logger.warn(`No auto loop running for project: ${projectPath}`);
return 0;
}
const wasRunning = projectState.isRunning;
projectState.isRunning = false;
projectState.abortController.abort();
// Clear execution state when auto-loop is explicitly stopped
await this.clearExecutionState(projectPath);
// Emit stop event
if (wasRunning) {
this.emitAutoModeEvent('auto_mode_stopped', {
message: 'Auto mode stopped',
projectPath,
});
}
// Remove from map
this.autoLoopsByProject.delete(projectPath);
return this.getRunningCountForProject(projectPath);
}
/**
* Check if auto mode is running for a specific project
*/
isAutoLoopRunningForProject(projectPath: string): boolean {
const projectState = this.autoLoopsByProject.get(projectPath);
return projectState?.isRunning ?? false;
}
/**
* Get auto loop config for a specific project
*/
getAutoLoopConfigForProject(projectPath: string): AutoModeConfig | null {
const projectState = this.autoLoopsByProject.get(projectPath);
return projectState?.config ?? null;
}
/**
* Save execution state for a specific project
*/
private async saveExecutionStateForProject(
projectPath: string,
maxConcurrency: number
): Promise<void> {
try {
await ensureAutomakerDir(projectPath);
const statePath = getExecutionStatePath(projectPath);
const runningFeatureIds = Array.from(this.runningFeatures.entries())
.filter(([, f]) => f.projectPath === projectPath)
.map(([id]) => id);
const state: ExecutionState = {
version: 1,
autoLoopWasRunning: true,
maxConcurrency,
projectPath,
runningFeatureIds,
savedAt: new Date().toISOString(),
};
await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8');
logger.info(
`Saved execution state for ${projectPath}: ${runningFeatureIds.length} running features`
);
} catch (error) {
logger.error(`Failed to save execution state for ${projectPath}:`, error);
}
}
/**
* Start the auto mode loop - continuously picks and executes pending features
* @deprecated Use startAutoLoopForProject instead for multi-project support
*/
async startAutoLoop(projectPath: string, maxConcurrency = 3): Promise<void> {
// For backward compatibility, delegate to the new per-project method
// But also maintain legacy state for existing code that might check it
if (this.autoLoopRunning) {
throw new Error('Auto mode is already running');
}
@@ -396,6 +742,9 @@ export class AutoModeService {
});
}
/**
* @deprecated Use runAutoLoopForProject instead
*/
private async runAutoLoop(): Promise<void> {
while (
this.autoLoopRunning &&
@@ -448,6 +797,7 @@ export class AutoModeService {
/**
* Stop the auto mode loop
* @deprecated Use stopAutoLoopForProject instead for multi-project support
*/
async stopAutoLoop(): Promise<number> {
const wasRunning = this.autoLoopRunning;
@@ -1708,6 +2058,12 @@ Format your response as a structured markdown document.`;
thinkingLevel: analysisThinkingLevel,
});
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
this.settingsService,
'[AutoMode]'
);
const options: ExecuteOptions = {
prompt,
model: sdkOptions.model ?? analysisModel,
@@ -1717,6 +2073,8 @@ Format your response as a structured markdown document.`;
abortController,
settingSources: sdkOptions.settingSources,
thinkingLevel: analysisThinkingLevel, // Pass thinking level
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
const stream = provider.executeQuery(options);
@@ -1777,6 +2135,46 @@ Format your response as a structured markdown document.`;
};
}
/**
* Get status for a specific project
* @param projectPath - The project to get status for
*/
getStatusForProject(projectPath: string): {
isAutoLoopRunning: boolean;
runningFeatures: string[];
runningCount: number;
maxConcurrency: number;
} {
const projectState = this.autoLoopsByProject.get(projectPath);
const runningFeatures: string[] = [];
for (const [featureId, feature] of this.runningFeatures) {
if (feature.projectPath === projectPath) {
runningFeatures.push(featureId);
}
}
return {
isAutoLoopRunning: projectState?.isRunning ?? false,
runningFeatures,
runningCount: runningFeatures.length,
maxConcurrency: projectState?.config.maxConcurrency ?? 3,
};
}
/**
* Get all projects that have auto mode running
*/
getActiveAutoLoopProjects(): string[] {
const activeProjects: string[] = [];
for (const [projectPath, state] of this.autoLoopsByProject) {
if (state.isRunning) {
activeProjects.push(projectPath);
}
}
return activeProjects;
}
/**
* Get detailed info about all running agents
*/
@@ -2254,6 +2652,10 @@ Format your response as a structured markdown document.`;
}
}
logger.debug(
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} with backlog/pending/ready status`
);
// Apply dependency-aware ordering
const { orderedFeatures } = resolveDependencies(pendingFeatures);
@@ -2266,8 +2668,13 @@ Format your response as a structured markdown document.`;
areDependenciesSatisfied(feature, allFeatures, { skipVerification })
);
logger.debug(
`[loadPendingFeatures] After dependency filtering: ${readyFeatures.length} ready features (skipVerification=${skipVerification})`
);
return readyFeatures;
} catch {
} catch (error) {
logger.error(`[loadPendingFeatures] Error loading features:`, error);
return [];
}
}
@@ -2536,6 +2943,12 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
);
}
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
this.settingsService,
'[AutoMode]'
);
const executeOptions: ExecuteOptions = {
prompt: promptContent,
model: bareModel,
@@ -2547,6 +2960,8 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
settingSources: sdkOptions.settingSources,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
// Execute via provider
@@ -2849,6 +3264,7 @@ After generating the revised spec, output:
allowedTools: allowedTools,
abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
});
let revisionText = '';
@@ -2994,6 +3410,7 @@ After generating the revised spec, output:
allowedTools: allowedTools,
abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
});
let taskOutput = '';
@@ -3088,6 +3505,7 @@ After generating the revised spec, output:
allowedTools: allowedTools,
abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
});
for await (const msg of continuationStream) {

View File

@@ -41,7 +41,7 @@ import type { FeatureLoader } from './feature-loader.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { resolveModelString } from '@automaker/model-resolver';
import { stripProviderPrefix } from '@automaker/types';
import { getPromptCustomization } from '../lib/settings-helpers.js';
import { getPromptCustomization, getActiveClaudeApiProfile } from '../lib/settings-helpers.js';
const logger = createLogger('IdeationService');
@@ -223,6 +223,12 @@ export class IdeationService {
// Strip provider prefix - providers need bare model IDs
const bareModel = stripProviderPrefix(modelId);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
this.settingsService,
'[IdeationService]'
);
const executeOptions: ExecuteOptions = {
prompt: message,
model: bareModel,
@@ -232,6 +238,8 @@ export class IdeationService {
maxTurns: 1, // Single turn for ideation
abortController: activeSession.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
const stream = provider.executeQuery(executeOptions);
@@ -678,6 +686,12 @@ export class IdeationService {
// Strip provider prefix - providers need bare model IDs
const bareModel = stripProviderPrefix(modelId);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
this.settingsService,
'[IdeationService]'
);
const executeOptions: ExecuteOptions = {
prompt: prompt.prompt,
model: bareModel,
@@ -688,6 +702,8 @@ export class IdeationService {
// Disable all tools - we just want text generation, not codebase analysis
allowedTools: [],
abortController: new AbortController(),
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
const stream = provider.executeQuery(executeOptions);

View File

@@ -9,6 +9,9 @@
import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import os from 'os';
import path from 'path';
import fs from 'fs/promises';
import {
getGlobalSettingsPath,
@@ -38,6 +41,7 @@ import {
CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION,
} from '../types/settings.js';
import { migrateModelId, migrateCursorModelIds, migrateOpencodeModelIds } from '@automaker/types';
const logger = createLogger('SettingsService');
@@ -124,10 +128,14 @@ export class SettingsService {
// Migrate legacy enhancementModel/validationModel to phaseModels
const migratedPhaseModels = this.migratePhaseModels(settings);
// Migrate model IDs to canonical format
const migratedModelSettings = this.migrateModelSettings(settings);
// Apply any missing defaults (for backwards compatibility)
let result: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
...settings,
...migratedModelSettings,
keyboardShortcuts: {
...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
...settings.keyboardShortcuts,
@@ -158,6 +166,41 @@ export class SettingsService {
needsSave = true;
}
// Migration v4 -> v5: Auto-create "Direct Anthropic" profile for existing users
// If user has an Anthropic API key in credentials but no profiles, create a
// "Direct Anthropic" profile that references the credentials and set it as active.
if (storedVersion < 5) {
try {
const credentials = await this.getCredentials();
const hasAnthropicKey = !!credentials.apiKeys?.anthropic;
const hasNoProfiles = !result.claudeApiProfiles || result.claudeApiProfiles.length === 0;
const hasNoActiveProfile = !result.activeClaudeApiProfileId;
if (hasAnthropicKey && hasNoProfiles && hasNoActiveProfile) {
const directAnthropicProfile = {
id: `profile-${Date.now()}-direct-anthropic`,
name: 'Direct Anthropic',
baseUrl: 'https://api.anthropic.com',
apiKeySource: 'credentials' as const,
useAuthToken: false,
};
result.claudeApiProfiles = [directAnthropicProfile];
result.activeClaudeApiProfileId = directAnthropicProfile.id;
logger.info(
'Migration v4->v5: Created "Direct Anthropic" profile using existing credentials'
);
}
} catch (error) {
logger.warn(
'Migration v4->v5: Could not check credentials for auto-profile creation:',
error
);
}
needsSave = true;
}
// Update version if any migration occurred
if (needsSave) {
result.version = SETTINGS_VERSION;
@@ -223,19 +266,70 @@ export class SettingsService {
* Convert a phase model value to PhaseModelEntry format
*
* Handles migration from string format (v2) to object format (v3).
* - String values like 'sonnet' become { model: 'sonnet' }
* - Object values are returned as-is (with type assertion)
* Also migrates legacy model IDs to canonical prefixed format.
* - String values like 'sonnet' become { model: 'claude-sonnet' }
* - Object values have their model ID migrated if needed
*
* @param value - Phase model value (string or PhaseModelEntry)
* @returns PhaseModelEntry object
* @returns PhaseModelEntry object with canonical model ID
*/
private toPhaseModelEntry(value: string | PhaseModelEntry): PhaseModelEntry {
if (typeof value === 'string') {
// v2 format: just a model string
return { model: value as PhaseModelEntry['model'] };
// v2 format: just a model string - migrate to canonical ID
return { model: migrateModelId(value) as PhaseModelEntry['model'] };
}
// v3 format: already a PhaseModelEntry object
return value;
// v3 format: PhaseModelEntry object - migrate model ID if needed
return {
...value,
model: migrateModelId(value.model) as PhaseModelEntry['model'],
};
}
/**
* Migrate model-related settings to canonical format
*
* Migrates:
* - enabledCursorModels: legacy IDs to cursor- prefixed
* - enabledOpencodeModels: legacy slash format to dash format
* - cursorDefaultModel: legacy ID to cursor- prefixed
*
* @param settings - Settings to migrate
* @returns Settings with migrated model IDs
*/
private migrateModelSettings(settings: Partial<GlobalSettings>): Partial<GlobalSettings> {
const migrated: Partial<GlobalSettings> = { ...settings };
// Migrate Cursor models
if (settings.enabledCursorModels) {
migrated.enabledCursorModels = migrateCursorModelIds(
settings.enabledCursorModels as string[]
);
}
// Migrate Cursor default model
if (settings.cursorDefaultModel) {
const migratedDefault = migrateCursorModelIds([settings.cursorDefaultModel as string]);
if (migratedDefault.length > 0) {
migrated.cursorDefaultModel = migratedDefault[0];
}
}
// Migrate OpenCode models
if (settings.enabledOpencodeModels) {
migrated.enabledOpencodeModels = migrateOpencodeModelIds(
settings.enabledOpencodeModels as string[]
);
}
// Migrate OpenCode default model
if (settings.opencodeDefaultModel) {
const migratedDefault = migrateOpencodeModelIds([settings.opencodeDefaultModel as string]);
if (migratedDefault.length > 0) {
migrated.opencodeDefaultModel = migratedDefault[0];
}
}
return migrated;
}
/**
@@ -273,13 +367,39 @@ export class SettingsService {
};
const currentProjectsLen = Array.isArray(current.projects) ? current.projects.length : 0;
// Check if this is a legitimate project removal (moved to trash) vs accidental wipe
const newTrashedProjectsLen = Array.isArray(sanitizedUpdates.trashedProjects)
? sanitizedUpdates.trashedProjects.length
: Array.isArray(current.trashedProjects)
? current.trashedProjects.length
: 0;
if (
Array.isArray(sanitizedUpdates.projects) &&
sanitizedUpdates.projects.length === 0 &&
currentProjectsLen > 0
) {
attemptedProjectWipe = true;
delete sanitizedUpdates.projects;
// Only treat as accidental wipe if trashedProjects is also empty
// (If projects are moved to trash, they appear in trashedProjects)
if (newTrashedProjectsLen === 0) {
logger.warn(
'[WIPE_PROTECTION] Attempted to set projects to empty array with no trash! Ignoring update.',
{
currentProjectsLen,
newProjectsLen: 0,
newTrashedProjectsLen,
currentProjects: current.projects?.map((p) => p.name),
}
);
attemptedProjectWipe = true;
delete sanitizedUpdates.projects;
} else {
logger.info('[LEGITIMATE_REMOVAL] Removing all projects to trash', {
currentProjectsLen,
newProjectsLen: 0,
movedToTrash: newTrashedProjectsLen,
});
}
}
ignoreEmptyArrayOverwrite('trashedProjects');
@@ -766,4 +886,203 @@ export class SettingsService {
getDataDir(): string {
return this.dataDir;
}
/**
* Get the legacy Electron userData directory path
*
* Returns the platform-specific path where Electron previously stored settings
* before the migration to shared data directories.
*
* @returns Absolute path to legacy userData directory
*/
private getLegacyElectronUserDataPath(): string {
const homeDir = os.homedir();
switch (process.platform) {
case 'darwin':
// macOS: ~/Library/Application Support/Automaker
return path.join(homeDir, 'Library', 'Application Support', 'Automaker');
case 'win32':
// Windows: %APPDATA%\Automaker
return path.join(
process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
'Automaker'
);
default:
// Linux and others: ~/.config/Automaker
return path.join(process.env.XDG_CONFIG_HOME || path.join(homeDir, '.config'), 'Automaker');
}
}
/**
* Migrate entire data directory from legacy Electron userData location to new shared data directory
*
* This handles the migration from when Electron stored data in the platform-specific
* userData directory (e.g., ~/.config/Automaker) to the new shared ./data directory.
*
* Migration only occurs if:
* 1. The new location does NOT have settings.json
* 2. The legacy location DOES have settings.json
*
* Migrates all files and directories including:
* - settings.json (global settings)
* - credentials.json (API keys)
* - sessions-metadata.json (chat session metadata)
* - agent-sessions/ (conversation histories)
* - Any other files in the data directory
*
* @returns Promise resolving to migration result
*/
async migrateFromLegacyElectronPath(): Promise<{
migrated: boolean;
migratedFiles: string[];
legacyPath: string;
errors: string[];
}> {
const legacyPath = this.getLegacyElectronUserDataPath();
const migratedFiles: string[] = [];
const errors: string[] = [];
// Skip if legacy path is the same as current data dir (no migration needed)
if (path.resolve(legacyPath) === path.resolve(this.dataDir)) {
logger.debug('Legacy path same as current data dir, skipping migration');
return { migrated: false, migratedFiles, legacyPath, errors };
}
logger.info(`Checking for legacy data migration from: ${legacyPath}`);
logger.info(`Current data directory: ${this.dataDir}`);
// Check if new settings already exist
const newSettingsPath = getGlobalSettingsPath(this.dataDir);
let newSettingsExist = false;
try {
await fs.access(newSettingsPath);
newSettingsExist = true;
} catch {
// New settings don't exist, migration may be needed
}
if (newSettingsExist) {
logger.debug('Settings already exist in new location, skipping migration');
return { migrated: false, migratedFiles, legacyPath, errors };
}
// Check if legacy directory exists and has settings
const legacySettingsPath = path.join(legacyPath, 'settings.json');
let legacySettingsExist = false;
try {
await fs.access(legacySettingsPath);
legacySettingsExist = true;
} catch {
// Legacy settings don't exist
}
if (!legacySettingsExist) {
logger.debug('No legacy settings found, skipping migration');
return { migrated: false, migratedFiles, legacyPath, errors };
}
// Perform migration of specific application data files only
// (not Electron internal caches like Code Cache, GPU Cache, etc.)
logger.info('Found legacy data directory, migrating application data to new location...');
// Ensure new data directory exists
try {
await ensureDataDir(this.dataDir);
} catch (error) {
const msg = `Failed to create data directory: ${error}`;
logger.error(msg);
errors.push(msg);
return { migrated: false, migratedFiles, legacyPath, errors };
}
// Only migrate specific application data files/directories
const itemsToMigrate = [
'settings.json',
'credentials.json',
'sessions-metadata.json',
'agent-sessions',
'.api-key',
'.sessions',
];
for (const item of itemsToMigrate) {
const srcPath = path.join(legacyPath, item);
const destPath = path.join(this.dataDir, item);
// Check if source exists
try {
await fs.access(srcPath);
} catch {
// Source doesn't exist, skip
continue;
}
// Check if destination already exists
try {
await fs.access(destPath);
logger.debug(`Skipping ${item} - already exists in destination`);
continue;
} catch {
// Destination doesn't exist, proceed with copy
}
// Copy file or directory
try {
const stat = await fs.stat(srcPath);
if (stat.isDirectory()) {
await this.copyDirectory(srcPath, destPath);
migratedFiles.push(item + '/');
logger.info(`Migrated directory: ${item}/`);
} else {
const content = await fs.readFile(srcPath);
await fs.writeFile(destPath, content);
migratedFiles.push(item);
logger.info(`Migrated file: ${item}`);
}
} catch (error) {
const msg = `Failed to migrate ${item}: ${error}`;
logger.error(msg);
errors.push(msg);
}
}
if (migratedFiles.length > 0) {
logger.info(
`Migration complete. Migrated ${migratedFiles.length} item(s): ${migratedFiles.join(', ')}`
);
logger.info(`Legacy path: ${legacyPath}`);
logger.info(`New path: ${this.dataDir}`);
}
return {
migrated: migratedFiles.length > 0,
migratedFiles,
legacyPath,
errors,
};
}
/**
* Recursively copy a directory from source to destination
*
* @param srcDir - Source directory path
* @param destDir - Destination directory path
*/
private async copyDirectory(srcDir: string, destDir: string): Promise<void> {
await fs.mkdir(destDir, { recursive: true });
const entries = await fs.readdir(srcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
if (entry.isDirectory()) {
await this.copyDirectory(srcPath, destPath);
} else if (entry.isFile()) {
const content = await fs.readFile(srcPath);
await fs.writeFile(destPath, content);
}
}
}
}

View File

@@ -37,7 +37,7 @@ describe('model-resolver.ts', () => {
const result = resolveModelString('opus');
expect(result).toBe('claude-opus-4-5-20251101');
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining('Resolved Claude model alias: "opus"')
expect.stringContaining('Migrated legacy ID: "opus" -> "claude-opus"')
);
});

View File

@@ -121,7 +121,7 @@ describe('worktree-metadata.ts', () => {
number: 123,
url: 'https://github.com/owner/repo/pull/123',
title: 'Test PR',
state: 'open',
state: 'OPEN',
createdAt: new Date().toISOString(),
},
};
@@ -158,7 +158,7 @@ describe('worktree-metadata.ts', () => {
number: 456,
url: 'https://github.com/owner/repo/pull/456',
title: 'Updated PR',
state: 'closed',
state: 'CLOSED',
createdAt: new Date().toISOString(),
},
};
@@ -177,7 +177,7 @@ describe('worktree-metadata.ts', () => {
number: 789,
url: 'https://github.com/owner/repo/pull/789',
title: 'New PR',
state: 'open',
state: 'OPEN',
createdAt: new Date().toISOString(),
};
@@ -201,7 +201,7 @@ describe('worktree-metadata.ts', () => {
number: 999,
url: 'https://github.com/owner/repo/pull/999',
title: 'Updated PR',
state: 'merged',
state: 'MERGED',
createdAt: new Date().toISOString(),
};
@@ -224,7 +224,7 @@ describe('worktree-metadata.ts', () => {
number: 111,
url: 'https://github.com/owner/repo/pull/111',
title: 'PR',
state: 'open',
state: 'OPEN',
createdAt: new Date().toISOString(),
};
@@ -259,7 +259,7 @@ describe('worktree-metadata.ts', () => {
number: 222,
url: 'https://github.com/owner/repo/pull/222',
title: 'Has PR',
state: 'open',
state: 'OPEN',
createdAt: new Date().toISOString(),
};
@@ -297,7 +297,7 @@ describe('worktree-metadata.ts', () => {
number: 333,
url: 'https://github.com/owner/repo/pull/333',
title: 'PR 3',
state: 'open',
state: 'OPEN',
createdAt: new Date().toISOString(),
},
};

View File

@@ -50,8 +50,8 @@ describe('cursor-config-manager.ts', () => {
manager = new CursorConfigManager(testProjectPath);
const config = manager.getConfig();
expect(config.defaultModel).toBe('auto');
expect(config.models).toContain('auto');
expect(config.defaultModel).toBe('cursor-auto');
expect(config.models).toContain('cursor-auto');
});
it('should use default config if file read fails', () => {
@@ -62,7 +62,7 @@ describe('cursor-config-manager.ts', () => {
manager = new CursorConfigManager(testProjectPath);
expect(manager.getDefaultModel()).toBe('auto');
expect(manager.getDefaultModel()).toBe('cursor-auto');
});
it('should use default config if JSON parse fails', () => {
@@ -71,7 +71,7 @@ describe('cursor-config-manager.ts', () => {
manager = new CursorConfigManager(testProjectPath);
expect(manager.getDefaultModel()).toBe('auto');
expect(manager.getDefaultModel()).toBe('cursor-auto');
});
});
@@ -93,7 +93,7 @@ describe('cursor-config-manager.ts', () => {
});
it('should return default model', () => {
expect(manager.getDefaultModel()).toBe('auto');
expect(manager.getDefaultModel()).toBe('cursor-auto');
});
it('should set and persist default model', () => {
@@ -103,13 +103,13 @@ describe('cursor-config-manager.ts', () => {
expect(fs.writeFileSync).toHaveBeenCalled();
});
it('should return auto if defaultModel is undefined', () => {
it('should return cursor-auto if defaultModel is undefined', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ models: ['auto'] }));
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ models: ['cursor-auto'] }));
manager = new CursorConfigManager(testProjectPath);
expect(manager.getDefaultModel()).toBe('auto');
expect(manager.getDefaultModel()).toBe('cursor-auto');
});
});
@@ -121,7 +121,7 @@ describe('cursor-config-manager.ts', () => {
it('should return enabled models', () => {
const models = manager.getEnabledModels();
expect(Array.isArray(models)).toBe(true);
expect(models).toContain('auto');
expect(models).toContain('cursor-auto');
});
it('should set enabled models', () => {
@@ -131,13 +131,13 @@ describe('cursor-config-manager.ts', () => {
expect(fs.writeFileSync).toHaveBeenCalled();
});
it('should return [auto] if models is undefined', () => {
it('should return [cursor-auto] if models is undefined', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'auto' }));
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'cursor-auto' }));
manager = new CursorConfigManager(testProjectPath);
expect(manager.getEnabledModels()).toEqual(['auto']);
expect(manager.getEnabledModels()).toEqual(['cursor-auto']);
});
});
@@ -146,8 +146,8 @@ describe('cursor-config-manager.ts', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
defaultModel: 'auto',
models: ['auto'],
defaultModel: 'cursor-auto',
models: ['cursor-auto'],
})
);
manager = new CursorConfigManager(testProjectPath);
@@ -161,14 +161,14 @@ describe('cursor-config-manager.ts', () => {
});
it('should not add duplicate models', () => {
manager.addModel('auto');
manager.addModel('cursor-auto');
// Should not save if model already exists
expect(fs.writeFileSync).not.toHaveBeenCalled();
});
it('should initialize models array if undefined', () => {
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'auto' }));
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'cursor-auto' }));
manager = new CursorConfigManager(testProjectPath);
manager.addModel('claude-3-5-sonnet');
@@ -293,7 +293,7 @@ describe('cursor-config-manager.ts', () => {
it('should reset to default values', () => {
manager.reset();
expect(manager.getDefaultModel()).toBe('auto');
expect(manager.getDefaultModel()).toBe('cursor-auto');
expect(manager.getMcpServers()).toEqual([]);
expect(manager.getRules()).toEqual([]);
expect(fs.writeFileSync).toHaveBeenCalled();

View File

@@ -647,9 +647,10 @@ describe('settings-service.ts', () => {
const settings = await settingsService.getGlobalSettings();
// Verify all phase models are now PhaseModelEntry objects
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' });
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'haiku' });
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' });
// Legacy aliases are migrated to canonical IDs
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' });
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'claude-haiku' });
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' });
expect(settings.version).toBe(SETTINGS_VERSION);
});
@@ -675,16 +676,17 @@ describe('settings-service.ts', () => {
const settings = await settingsService.getGlobalSettings();
// Verify PhaseModelEntry objects are preserved with thinkingLevel
// Legacy aliases are migrated to canonical IDs
expect(settings.phaseModels.enhancementModel).toEqual({
model: 'sonnet',
model: 'claude-sonnet',
thinkingLevel: 'high',
});
expect(settings.phaseModels.specGenerationModel).toEqual({
model: 'opus',
model: 'claude-opus',
thinkingLevel: 'ultrathink',
});
expect(settings.phaseModels.backlogPlanningModel).toEqual({
model: 'sonnet',
model: 'claude-sonnet',
thinkingLevel: 'medium',
});
});
@@ -710,15 +712,15 @@ describe('settings-service.ts', () => {
const settings = await settingsService.getGlobalSettings();
// Strings should be converted to objects
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' });
expect(settings.phaseModels.imageDescriptionModel).toEqual({ model: 'haiku' });
// Objects should be preserved
// Strings should be converted to objects with canonical IDs
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' });
expect(settings.phaseModels.imageDescriptionModel).toEqual({ model: 'claude-haiku' });
// Objects should be preserved with migrated IDs
expect(settings.phaseModels.fileDescriptionModel).toEqual({
model: 'haiku',
model: 'claude-haiku',
thinkingLevel: 'low',
});
expect(settings.phaseModels.validationModel).toEqual({ model: 'opus' });
expect(settings.phaseModels.validationModel).toEqual({ model: 'claude-opus' });
});
it('should migrate legacy enhancementModel/validationModel fields', async () => {
@@ -735,11 +737,11 @@ describe('settings-service.ts', () => {
const settings = await settingsService.getGlobalSettings();
// Legacy fields should be migrated to phaseModels
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'haiku' });
expect(settings.phaseModels.validationModel).toEqual({ model: 'opus' });
// Other fields should use defaults
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' });
// Legacy fields should be migrated to phaseModels with canonical IDs
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-haiku' });
expect(settings.phaseModels.validationModel).toEqual({ model: 'claude-opus' });
// Other fields should use defaults (canonical IDs)
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' });
});
it('should use default phase models when none are configured', async () => {
@@ -753,10 +755,10 @@ describe('settings-service.ts', () => {
const settings = await settingsService.getGlobalSettings();
// Should use DEFAULT_PHASE_MODELS
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' });
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'haiku' });
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' });
// Should use DEFAULT_PHASE_MODELS (with canonical IDs)
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' });
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'claude-haiku' });
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' });
});
it('should deep merge phaseModels on update', async () => {
@@ -776,13 +778,13 @@ describe('settings-service.ts', () => {
const settings = await settingsService.getGlobalSettings();
// Both should be preserved
// Both should be preserved (models migrated to canonical format)
expect(settings.phaseModels.enhancementModel).toEqual({
model: 'sonnet',
model: 'claude-sonnet',
thinkingLevel: 'high',
});
expect(settings.phaseModels.specGenerationModel).toEqual({
model: 'opus',
model: 'claude-opus',
thinkingLevel: 'ultrathink',
});
});

View File

@@ -40,6 +40,7 @@
},
"dependencies": {
"@automaker/dependency-resolver": "1.0.0",
"@automaker/spec-parser": "1.0.0",
"@automaker/types": "1.0.0",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "^6.12.1",

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
@@ -279,7 +280,7 @@ export function ClaudeUsagePopover() {
) : !claudeUsage ? (
// Loading state
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<Spinner size="lg" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : (

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
@@ -333,7 +334,7 @@ export function CodexUsagePopover() {
) : !codexUsage ? (
// Loading state
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<Spinner size="lg" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : codexUsage.rateLimits ? (

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { ImageIcon, Upload, Loader2, Trash2 } from 'lucide-react';
import { ImageIcon, Upload, Trash2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
const logger = createLogger('BoardBackgroundModal');
import {
@@ -313,7 +314,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
/>
{isProcessing && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<Loader2 className="w-6 h-6 animate-spin text-brand-500" />
<Spinner size="lg" />
</div>
)}
</div>
@@ -353,7 +354,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
)}
>
{isProcessing ? (
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
<Spinner size="lg" />
) : (
<ImageIcon className="h-6 w-6 text-muted-foreground" />
)}

View File

@@ -14,16 +14,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import {
FolderPlus,
FolderOpen,
Rocket,
ExternalLink,
Check,
Loader2,
Link,
Folder,
} from 'lucide-react';
import { FolderPlus, FolderOpen, Rocket, ExternalLink, Check, Link, Folder } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { starterTemplates, type StarterTemplate } from '@/lib/templates';
import { getElectronAPI } from '@/lib/electron';
import { cn } from '@/lib/utils';
@@ -451,7 +443,7 @@ export function NewProjectModal({
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
{activeTab === 'template' ? 'Cloning...' : 'Creating...'}
</>
) : (

View File

@@ -8,7 +8,8 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Folder, Loader2, FolderOpen, AlertCircle } from 'lucide-react';
import { Folder, FolderOpen, AlertCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getHttpApiClient } from '@/lib/http-api-client';
interface WorkspaceDirectory {
@@ -74,7 +75,7 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
<div className="flex-1 overflow-y-auto py-4 min-h-[200px]">
{isLoading && (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Loader2 className="w-8 h-8 text-brand-500 animate-spin" />
<Spinner size="xl" />
<p className="text-sm text-muted-foreground">Loading projects...</p>
</div>
)}

View File

@@ -0,0 +1,213 @@
import type { ComponentType, ComponentProps } from 'react';
import { Terminal } from 'lucide-react';
type IconProps = ComponentProps<'svg'>;
type IconComponent = ComponentType<IconProps>;
/**
* iTerm2 logo icon
*/
export function ITerm2Icon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2.586 0a2.56 2.56 0 00-2.56 2.56v18.88A2.56 2.56 0 002.586 24h18.88a2.56 2.56 0 002.56-2.56V2.56A2.56 2.56 0 0021.466 0H2.586zm8.143 4.027h2.543v15.946h-2.543V4.027zm-3.816 0h2.544v15.946H6.913V4.027zm7.633 0h2.543v15.946h-2.543V4.027z" />
</svg>
);
}
/**
* Warp terminal logo icon
*/
export function WarpIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M9.2 4.8L7.6 7.6 4.8 5.6l1.6-2.8L9.2 4.8zm5.6 0l1.6 2.8 2.8-2-1.6-2.8-2.8 2zM2.4 12l2.8 1.6L3.6 16 .8 14.4 2.4 12zm19.2 0l1.6 2.4-2.8 1.6-1.6-2.4 2.8-1.6zM7.6 16.4l1.6 2.8-2.8 2-1.6-2.8 2.8-2zm8.8 0l2.8 2-1.6 2.8-2.8-2 1.6-2.8zM12 0L8.4 2 12 4l3.6-2L12 0zm0 20l-3.6 2 3.6 2 3.6-2-3.6-2z" />
</svg>
);
}
/**
* Ghostty terminal logo icon
*/
export function GhosttyIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M12 2C6.48 2 2 6.48 2 12v8c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2v-8c0-5.52-4.48-10-10-10zm-3.5 12a1.5 1.5 0 110-3 1.5 1.5 0 010 3zm7 0a1.5 1.5 0 110-3 1.5 1.5 0 010 3zM12 19c-1.5 0-3-.5-4-1.5v-1c2 1 6 1 8 0v1c-1 1-2.5 1.5-4 1.5z" />
</svg>
);
}
/**
* Alacritty terminal logo icon
*/
export function AlacrittyIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M12 0L1.608 21.6h3.186l1.46-3.032h11.489l1.46 3.032h3.189L12 0zm0 7.29l3.796 7.882H8.204L12 7.29z" />
</svg>
);
}
/**
* WezTerm terminal logo icon
*/
export function WezTermIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2 4h20v16H2V4zm2 2v12h16V6H4zm2 2h12v2H6V8zm0 4h8v2H6v-2z" />
</svg>
);
}
/**
* Kitty terminal logo icon
*/
export function KittyIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M3.5 7.5L1 5V2.5L3.5 5V7.5zM20.5 7.5L23 5V2.5L20.5 5V7.5zM12 4L6 8v8l6 4 6-4V8l-6-4zm0 2l4 2.67v5.33L12 16.67 8 14V8.67L12 6z" />
</svg>
);
}
/**
* Hyper terminal logo icon
*/
export function HyperIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M11.857 23.995v-7.125H6.486l5.765-10.856-.363-1.072L7.803.001 0 12.191h5.75L0 23.995h11.857zm.286 0h5.753l5.679-11.804h-5.679l5.679-11.804L17.896.388l-5.753 11.803h5.753L12.143 24z" />
</svg>
);
}
/**
* Tabby terminal logo icon
*/
export function TabbyIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M12 2L4 6v12l8 4 8-4V6l-8-4zm0 2l6 3v10l-6 3-6-3V7l6-3z" />
</svg>
);
}
/**
* Rio terminal logo icon
*/
export function RioIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z" />
</svg>
);
}
/**
* Windows Terminal logo icon
*/
export function WindowsTerminalIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M8.165 6L0 9.497v5.006L8.165 18l.413-.206v-4.025L3.197 12l5.381-1.769V6.206L8.165 6zm7.67 0l-.413.206v4.025L20.803 12l-5.381 1.769v4.025l.413.206L24 14.503V9.497L15.835 6z" />
</svg>
);
}
/**
* PowerShell logo icon
*/
export function PowerShellIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M23.181 2.974c.568 0 .923.463.792 1.035l-3.659 15.982c-.13.572-.697 1.035-1.265 1.035H.819c-.568 0-.923-.463-.792-1.035L3.686 4.009c.13-.572.697-1.035 1.265-1.035h18.23zM8.958 16.677c0 .334.276.611.611.611h3.673a.615.615 0 00.611-.611.615.615 0 00-.611-.611h-3.673a.615.615 0 00-.611.611zm5.126-7.016L9.025 14.72c-.241.241-.241.63 0 .872.241.241.63.241.872 0l5.059-5.059c.241-.241.241-.63 0-.872l-5.059-5.059c-.241-.241-.63-.241-.872 0-.241.241-.241.63 0 .872l5.059 5.059c-.334.334-.334.334 0 0z" />
</svg>
);
}
/**
* Command Prompt (cmd) logo icon
*/
export function CmdIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2 4h20v16H2V4zm2 2v12h16V6H4zm2.5 1.5l3 3-3 3L5 12l3-3zm5.5 5h6v1.5h-6V12z" />
</svg>
);
}
/**
* Git Bash logo icon
*/
export function GitBashIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M23.546 10.93L13.067.452c-.604-.603-1.582-.603-2.188 0L8.708 2.627l2.76 2.76c.645-.215 1.379-.07 1.889.441.516.515.658 1.258.438 1.9l2.658 2.66c.645-.223 1.387-.078 1.9.435.721.72.721 1.884 0 2.604-.719.719-1.881.719-2.6 0-.539-.541-.674-1.337-.404-1.996L12.86 8.955v6.525c.176.086.342.203.488.348.713.721.713 1.883 0 2.6-.719.721-1.889.721-2.609 0-.719-.719-.719-1.879 0-2.598.182-.18.387-.316.605-.406V8.835c-.217-.091-.424-.222-.6-.401-.545-.545-.676-1.342-.396-2.009L7.636 3.7.45 10.881c-.6.605-.6 1.584 0 2.189l10.48 10.477c.604.604 1.582.604 2.186 0l10.43-10.43c.605-.603.605-1.582 0-2.187" />
</svg>
);
}
/**
* GNOME Terminal logo icon
*/
export function GnomeTerminalIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2 4a2 2 0 00-2 2v12a2 2 0 002 2h20a2 2 0 002-2V6a2 2 0 00-2-2H2zm0 2h20v12H2V6zm2 2v2h2V8H4zm4 0v2h12V8H8zm-4 4v2h2v-2H4zm4 0v2h8v-2H8z" />
</svg>
);
}
/**
* Konsole logo icon
*/
export function KonsoleIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M3 3h18a2 2 0 012 2v14a2 2 0 01-2 2H3a2 2 0 01-2-2V5a2 2 0 012-2zm0 2v14h18V5H3zm2 2l4 4-4 4V7zm6 6h8v2h-8v-2z" />
</svg>
);
}
/**
* macOS Terminal logo icon
*/
export function MacOSTerminalIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M3 4a2 2 0 00-2 2v12a2 2 0 002 2h18a2 2 0 002-2V6a2 2 0 00-2-2H3zm0 2h18v12H3V6zm2 2l5 4-5 4V8zm7 6h7v2h-7v-2z" />
</svg>
);
}
/**
* Get the appropriate icon component for a terminal ID
*/
export function getTerminalIcon(terminalId: string): IconComponent {
const terminalIcons: Record<string, IconComponent> = {
iterm2: ITerm2Icon,
warp: WarpIcon,
ghostty: GhosttyIcon,
alacritty: AlacrittyIcon,
wezterm: WezTermIcon,
kitty: KittyIcon,
hyper: HyperIcon,
tabby: TabbyIcon,
rio: RioIcon,
'windows-terminal': WindowsTerminalIcon,
powershell: PowerShellIcon,
cmd: CmdIcon,
'git-bash': GitBashIcon,
'gnome-terminal': GnomeTerminalIcon,
konsole: KonsoleIcon,
'terminal-macos': MacOSTerminalIcon,
// Linux terminals - use generic terminal icon
'xfce4-terminal': Terminal,
tilix: Terminal,
terminator: Terminal,
foot: Terminal,
xterm: Terminal,
};
return terminalIcons[terminalId] ?? Terminal;
}

View File

@@ -1,6 +1,6 @@
import { Folder, LucideIcon } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn, sanitizeForTestId } from '@/lib/utils';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import type { Project } from '@/lib/electron';
@@ -37,10 +37,15 @@ export function ProjectSwitcherItem({
const IconComponent = getIconComponent();
const hasCustomIcon = !!project.customIconPath;
// Combine project.id with sanitized name for uniqueness and readability
// Format: project-switcher-{id}-{sanitizedName}
const testId = `project-switcher-${project.id}-${sanitizeForTestId(project.name)}`;
return (
<button
onClick={onClick}
onContextMenu={onContextMenu}
data-testid={testId}
className={cn(
'group w-full aspect-square rounded-xl flex items-center justify-center relative overflow-hidden',
'transition-all duration-200 ease-out',
@@ -60,7 +65,6 @@ export function ProjectSwitcherItem({
'hover:scale-105 active:scale-95'
)}
title={project.name}
data-testid={`project-switcher-${project.id}`}
>
{hasCustomIcon ? (
<img

View File

@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react';
import { useNavigate, useLocation } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import { useAppStore } from '@/store/app-store';
import { useOSDetection } from '@/hooks/use-os-detection';
import { ProjectSwitcherItem } from './components/project-switcher-item';
import { ProjectContextMenu } from './components/project-context-menu';
@@ -10,7 +10,7 @@ import { EditProjectDialog } from './components/edit-project-dialog';
import { NotificationBell } from './components/notification-bell';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks';
import { useProjectCreation } from '@/components/layout/sidebar/hooks';
import { SIDEBAR_FEATURE_FLAGS } from '@/components/layout/sidebar/constants';
import type { Project } from '@/lib/electron';
import { getElectronAPI } from '@/lib/electron';
@@ -41,7 +41,6 @@ export function ProjectSwitcher() {
projects,
currentProject,
setCurrentProject,
trashedProjects,
upsertAndSetCurrentProject,
specCreatingForProject,
setSpecCreatingForProject,
@@ -69,9 +68,6 @@ export function ProjectSwitcher() {
const appMode = import.meta.env.VITE_APP_MODE || '?';
const versionSuffix = `${getOSAbbreviation(os)}${appMode}`;
// Get global theme for project creation
const { globalTheme } = useProjectTheme();
// Project creation state and handlers
const {
showNewProjectModal,
@@ -84,9 +80,6 @@ export function ProjectSwitcher() {
handleCreateFromTemplate,
handleCreateFromCustomUrl,
} = useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
});
@@ -161,13 +154,8 @@ export function ProjectSwitcher() {
}
// Upsert project and set as current (handles both create and update cases)
// Theme preservation is handled by the store action
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(path, name, effectiveTheme);
// Theme handling (trashed project recovery or undefined for global) is done by the store
upsertAndSetCurrentProject(path, name);
// Check if app_spec.txt exists
const specExists = await hasAppSpec(path);
@@ -198,7 +186,7 @@ export function ProjectSwitcher() {
});
}
}
}, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme, navigate]);
}, [upsertAndSetCurrentProject, navigate]);
// Handler for creating initial spec from the setup dialog
const handleCreateInitialSpec = useCallback(async () => {

View File

@@ -4,7 +4,7 @@ import { useNavigate, useLocation } from '@tanstack/react-router';
const logger = createLogger('Sidebar');
import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import { useAppStore } from '@/store/app-store';
import { useNotificationsStore } from '@/store/notifications-store';
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { getElectronAPI } from '@/lib/electron';
@@ -34,7 +34,6 @@ import {
useProjectCreation,
useSetupDialog,
useTrashOperations,
useProjectTheme,
useUnviewedValidations,
} from './sidebar/hooks';
@@ -79,9 +78,6 @@ export function Sidebar() {
// State for trash dialog
const [showTrashDialog, setShowTrashDialog] = useState(false);
// Project theme management (must come before useProjectCreation which uses globalTheme)
const { globalTheme } = useProjectTheme();
// Project creation state and handlers
const {
showNewProjectModal,
@@ -97,9 +93,6 @@ export function Sidebar() {
handleCreateFromTemplate,
handleCreateFromCustomUrl,
} = useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
});
@@ -198,13 +191,8 @@ export function Sidebar() {
}
// Upsert project and set as current (handles both create and update cases)
// Theme preservation is handled by the store action
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(path, name, effectiveTheme);
// Theme handling (trashed project recovery or undefined for global) is done by the store
upsertAndSetCurrentProject(path, name);
// Check if app_spec.txt exists
const specExists = await hasAppSpec(path);
@@ -232,7 +220,7 @@ export function Sidebar() {
});
}
}
}, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme]);
}, [upsertAndSetCurrentProject]);
// Navigation sections and keyboard shortcuts (defined after handlers)
const { navSections, navigationShortcuts } = useNavigation({

View File

@@ -1,9 +1,9 @@
import type { NavigateOptions } from '@tanstack/react-router';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
import type { NavSection } from '../types';
import type { Project } from '@/lib/electron';
import { Spinner } from '@/components/ui/spinner';
interface SidebarNavigationProps {
currentProject: Project | null;
@@ -93,9 +93,10 @@ export function SidebarNavigation({
>
<div className="relative">
{item.isLoading ? (
<Loader2
<Spinner
size="md"
className={cn(
'w-[18px] h-[18px] shrink-0 animate-spin',
'shrink-0',
isActive ? 'text-brand-500' : 'text-muted-foreground'
)}
/>

View File

@@ -6,20 +6,13 @@ const logger = createLogger('ProjectCreation');
import { initializeProject } from '@/lib/project-init';
import { toast } from 'sonner';
import type { StarterTemplate } from '@/lib/templates';
import type { ThemeMode } from '@/store/app-store';
import type { TrashedProject, Project } from '@/lib/electron';
import type { Project } from '@/lib/electron';
interface UseProjectCreationProps {
trashedProjects: TrashedProject[];
currentProject: Project | null;
globalTheme: ThemeMode;
upsertAndSetCurrentProject: (path: string, name: string, theme: ThemeMode) => Project;
upsertAndSetCurrentProject: (path: string, name: string) => Project;
}
export function useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
}: UseProjectCreationProps) {
// Modal state
@@ -67,14 +60,8 @@ export function useProjectCreation({
</project_specification>`
);
// Determine theme: try trashed project theme, then current project theme, then global
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
// Let the store handle theme (trashed project recovery or undefined for global)
upsertAndSetCurrentProject(projectPath, projectName);
setShowNewProjectModal(false);
@@ -92,7 +79,7 @@ export function useProjectCreation({
throw error;
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
[upsertAndSetCurrentProject]
);
/**
@@ -169,14 +156,8 @@ export function useProjectCreation({
</project_specification>`
);
// Determine theme
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
// Let the store handle theme (trashed project recovery or undefined for global)
upsertAndSetCurrentProject(projectPath, projectName);
setShowNewProjectModal(false);
setNewProjectName(projectName);
setNewProjectPath(projectPath);
@@ -194,7 +175,7 @@ export function useProjectCreation({
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
[upsertAndSetCurrentProject]
);
/**
@@ -244,14 +225,8 @@ export function useProjectCreation({
</project_specification>`
);
// Determine theme
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
// Let the store handle theme (trashed project recovery or undefined for global)
upsertAndSetCurrentProject(projectPath, projectName);
setShowNewProjectModal(false);
setNewProjectName(projectName);
setNewProjectPath(projectPath);
@@ -269,7 +244,7 @@ export function useProjectCreation({
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
[upsertAndSetCurrentProject]
);
return {

View File

@@ -16,8 +16,8 @@ import {
Check,
X,
ArchiveRestore,
Loader2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { SessionListItem } from '@/types/electron';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
@@ -466,7 +466,7 @@ export function SessionManager({
{/* Show loading indicator if this session is running (either current session thinking or any session in runningSessions) */}
{(currentSessionId === session.id && isCurrentSessionThinking) ||
runningSessions.has(session.id) ? (
<Loader2 className="w-4 h-4 text-primary animate-spin shrink-0" />
<Spinner size="sm" className="shrink-0" />
) : (
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
)}

View File

@@ -1,9 +1,9 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Spinner } from '@/components/ui/spinner';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
@@ -39,7 +39,7 @@ const buttonVariants = cva(
// Loading spinner component
function ButtonSpinner({ className }: { className?: string }) {
return <Loader2 className={cn('size-4 animate-spin', className)} aria-hidden="true" />;
return <Spinner size="sm" className={className} />;
}
function Button({

View File

@@ -3,7 +3,8 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('DescriptionImageDropZone');
import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
import { ImageIcon, X, FileText } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Textarea } from '@/components/ui/textarea';
import { getElectronAPI } from '@/lib/electron';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
@@ -431,7 +432,7 @@ export function DescriptionImageDropZone({
{/* Processing indicator */}
{isProcessing && (
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<Spinner size="sm" />
<span>Processing files...</span>
</div>
)}

View File

@@ -3,7 +3,8 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('FeatureImageUpload');
import { ImageIcon, X, Upload } from 'lucide-react';
import { ImageIcon, X } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import {
fileToBase64,
generateImageId,
@@ -196,7 +197,7 @@ export function FeatureImageUpload({
)}
>
{isProcessing ? (
<Upload className="h-5 w-5 animate-spin text-muted-foreground" />
<Spinner size="md" />
) : (
<ImageIcon className="h-5 w-5 text-muted-foreground" />
)}

View File

@@ -9,11 +9,11 @@ import {
FilePen,
ChevronDown,
ChevronRight,
Loader2,
RefreshCw,
GitBranch,
AlertCircle,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Button } from './button';
import type { FileStatus } from '@/types/electron';
@@ -484,7 +484,7 @@ export function GitDiffPanel({
<div className="border-t border-border">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<Spinner size="md" />
<span className="text-sm">Loading changes...</span>
</div>
) : error ? (

View File

@@ -3,7 +3,8 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('ImageDropZone');
import { ImageIcon, X, Upload } from 'lucide-react';
import { ImageIcon, X } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { ImageAttachment } from '@/store/app-store';
import {
fileToBase64,
@@ -204,7 +205,7 @@ export function ImageDropZone({
)}
>
{isProcessing ? (
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
<Spinner size="lg" />
) : (
<ImageIcon className="h-6 w-6 text-muted-foreground" />
)}

View File

@@ -1,17 +1,15 @@
import { Loader2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface LoadingStateProps {
/** Optional custom message to display below the spinner */
message?: string;
/** Optional custom size class for the spinner (default: h-8 w-8) */
size?: string;
}
export function LoadingState({ message, size = 'h-8 w-8' }: LoadingStateProps) {
export function LoadingState({ message }: LoadingStateProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center">
<Loader2 className={`${size} animate-spin text-muted-foreground`} />
{message && <p className="mt-4 text-sm text-muted-foreground">{message}</p>}
<Spinner size="xl" />
{message && <p className="mt-4 text-sm font-medium text-primary">{message}</p>}
</div>
);
}

View File

@@ -22,8 +22,8 @@ import {
Filter,
Circle,
Play,
Loader2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import {
parseLogOutput,
@@ -148,7 +148,7 @@ function TodoListRenderer({ todos }: { todos: TodoItem[] }) {
case 'completed':
return <CheckCircle2 className="w-4 h-4 text-emerald-400" />;
case 'in_progress':
return <Loader2 className="w-4 h-4 text-amber-400 animate-spin" />;
return <Spinner size="sm" />;
case 'pending':
return <Circle className="w-4 h-4 text-muted-foreground/70" />;
default:

View File

@@ -536,7 +536,15 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
if (modelStr.includes('grok')) {
return 'grok';
}
if (modelStr.includes('cursor') || modelStr === 'auto' || modelStr === 'composer-1') {
// Cursor models - canonical format includes 'cursor-' prefix
// Also support legacy IDs for backward compatibility
if (
modelStr.includes('cursor') ||
modelStr === 'auto' ||
modelStr === 'composer-1' ||
modelStr === 'cursor-auto' ||
modelStr === 'cursor-composer-1'
) {
return 'cursor';
}

View File

@@ -0,0 +1,32 @@
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
const sizeClasses: Record<SpinnerSize, string> = {
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-5 w-5',
lg: 'h-6 w-6',
xl: 'h-8 w-8',
};
interface SpinnerProps {
/** Size of the spinner */
size?: SpinnerSize;
/** Additional class names */
className?: string;
}
/**
* Themed spinner component using the primary brand color.
* Use this for all loading indicators throughout the app for consistency.
*/
export function Spinner({ size = 'md', className }: SpinnerProps) {
return (
<Loader2
className={cn(sizeClasses[size], 'animate-spin text-primary', className)}
aria-hidden="true"
/>
);
}

View File

@@ -5,7 +5,8 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('TaskProgressPanel');
import { Check, Loader2, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react';
import { Check, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
import { Badge } from '@/components/ui/badge';
@@ -260,7 +261,7 @@ export function TaskProgressPanel({
)}
>
{isCompleted && <Check className="h-3.5 w-3.5" />}
{isActive && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{isActive && <Spinner size="xs" />}
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
</div>

View File

@@ -1,9 +1,6 @@
import CodeMirror from '@uiw/react-codemirror';
import { xml } from '@codemirror/lang-xml';
import { EditorView } from '@codemirror/view';
import { Extension } from '@codemirror/state';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import { cn } from '@/lib/utils';
interface XmlSyntaxEditorProps {
@@ -14,52 +11,19 @@ interface XmlSyntaxEditorProps {
'data-testid'?: string;
}
// Syntax highlighting that uses CSS variables from the app's theme system
// This automatically adapts to any theme (dark, light, dracula, nord, etc.)
const syntaxColors = HighlightStyle.define([
// XML tags - use primary color
{ tag: t.tagName, color: 'var(--primary)' },
{ tag: t.angleBracket, color: 'var(--muted-foreground)' },
// Attributes
{ tag: t.attributeName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
{ tag: t.attributeValue, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
// Strings and content
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
{ tag: t.content, color: 'var(--foreground)' },
// Comments
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
// Special
{ tag: t.processingInstruction, color: 'var(--muted-foreground)' },
{ tag: t.documentMeta, color: 'var(--muted-foreground)' },
]);
// Editor theme using CSS variables
// Simple editor theme - inherits text color from parent
const editorTheme = EditorView.theme({
'&': {
height: '100%',
fontSize: '0.875rem',
fontFamily: 'ui-monospace, monospace',
backgroundColor: 'transparent',
color: 'var(--foreground)',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: 'ui-monospace, monospace',
},
'.cm-content': {
padding: '1rem',
minHeight: '100%',
caretColor: 'var(--primary)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--primary)',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
},
'.cm-activeLine': {
backgroundColor: 'transparent',
@@ -73,15 +37,8 @@ const editorTheme = EditorView.theme({
'.cm-gutters': {
display: 'none',
},
'.cm-placeholder': {
color: 'var(--muted-foreground)',
fontStyle: 'italic',
},
});
// Combine all extensions
const extensions: Extension[] = [xml(), syntaxHighlighting(syntaxColors), editorTheme];
export function XmlSyntaxEditor({
value,
onChange,
@@ -94,16 +51,16 @@ export function XmlSyntaxEditor({
<CodeMirror
value={value}
onChange={onChange}
extensions={extensions}
extensions={[xml(), editorTheme]}
theme="none"
placeholder={placeholder}
className="h-full [&_.cm-editor]:h-full"
className="h-full [&_.cm-editor]:h-full [&_.cm-content]:text-foreground"
basicSetup={{
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightSelectionMatches: true,
autocompletion: true,
highlightSelectionMatches: false,
autocompletion: false,
bracketMatching: true,
indentOnInput: true,
}}

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
import { useAppStore } from '@/store/app-store';
import { getTerminalTheme, DEFAULT_TERMINAL_FONT } from '@/config/terminal-themes';
import { getTerminalTheme, getTerminalFontFamily } from '@/config/terminal-themes';
// Types for dynamically imported xterm modules
type XTerminal = InstanceType<typeof import('@xterm/xterm').Terminal>;
@@ -20,7 +20,7 @@ export interface XtermLogViewerRef {
export interface XtermLogViewerProps {
/** Initial content to display */
initialContent?: string;
/** Font size in pixels (default: 13) */
/** Font size in pixels (uses terminal settings if not provided) */
fontSize?: number;
/** Whether to auto-scroll to bottom when new content is added (default: true) */
autoScroll?: boolean;
@@ -42,7 +42,7 @@ export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>
(
{
initialContent,
fontSize = 13,
fontSize,
autoScroll = true,
className,
minHeight = 300,
@@ -58,9 +58,14 @@ export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>
const autoScrollRef = useRef(autoScroll);
const pendingContentRef = useRef<string[]>([]);
// Get theme from store
// Get theme and font settings from store
const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme);
const effectiveTheme = getEffectiveTheme();
const terminalFontFamily = useAppStore((state) => state.terminalState.fontFamily);
const terminalFontSize = useAppStore((state) => state.terminalState.defaultFontSize);
// Use prop if provided, otherwise use store value, fallback to 13
const effectiveFontSize = fontSize ?? terminalFontSize ?? 13;
// Track system dark mode for "system" theme
const [systemIsDark, setSystemIsDark] = useState(() => {
@@ -102,12 +107,17 @@ export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>
const terminalTheme = getTerminalTheme(resolvedTheme);
// Get font settings from store at initialization time
const terminalState = useAppStore.getState().terminalState;
const fontFamily = getTerminalFontFamily(terminalState.fontFamily);
const initFontSize = fontSize ?? terminalState.defaultFontSize ?? 13;
const terminal = new Terminal({
cursorBlink: false,
cursorStyle: 'underline',
cursorInactiveStyle: 'none',
fontSize,
fontFamily: DEFAULT_TERMINAL_FONT,
fontSize: initFontSize,
fontFamily,
lineHeight: 1.2,
theme: terminalTheme,
disableStdin: true, // Read-only mode
@@ -181,10 +191,18 @@ export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>
// Update font size when it changes
useEffect(() => {
if (xtermRef.current && isReady) {
xtermRef.current.options.fontSize = fontSize;
xtermRef.current.options.fontSize = effectiveFontSize;
fitAddonRef.current?.fit();
}
}, [fontSize, isReady]);
}, [effectiveFontSize, isReady]);
// Update font family when it changes
useEffect(() => {
if (xtermRef.current && isReady) {
xtermRef.current.options.fontFamily = getTerminalFontFamily(terminalFontFamily);
fitAddonRef.current?.fit();
}
}, [terminalFontFamily, isReady]);
// Handle resize
useEffect(() => {

View File

@@ -3,6 +3,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
@@ -449,7 +450,7 @@ export function UsagePopover() {
</div>
) : !claudeUsage ? (
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<Spinner size="lg" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : (
@@ -568,7 +569,7 @@ export function UsagePopover() {
</div>
) : !codexUsage ? (
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<Spinner size="lg" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : codexUsage.rateLimits ? (

View File

@@ -11,12 +11,12 @@ import {
Terminal,
CheckCircle,
XCircle,
Loader2,
Play,
File,
Pencil,
Wrench,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
@@ -236,7 +236,7 @@ export function AgentToolsView() {
>
{isReadingFile ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Reading...
</>
) : (
@@ -315,7 +315,7 @@ export function AgentToolsView() {
>
{isWritingFile ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Writing...
</>
) : (
@@ -383,7 +383,7 @@ export function AgentToolsView() {
>
{isRunningCommand ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Running...
</>
) : (

View File

@@ -42,7 +42,7 @@ export function AgentView() {
return () => window.removeEventListener('resize', updateVisibility);
}, []);
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' });
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'claude-sonnet' });
// Input ref for auto-focus
const inputRef = useRef<HTMLTextAreaElement>(null);

View File

@@ -1,4 +1,5 @@
import { Bot } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
export function ThinkingIndicator() {
return (
@@ -8,20 +9,7 @@ export function ThinkingIndicator() {
</div>
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '0ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '150ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '300ms' }}
/>
</div>
<Spinner size="sm" />
<span className="text-sm text-muted-foreground">Thinking...</span>
</div>
</div>

View File

@@ -14,12 +14,12 @@ import {
RefreshCw,
BarChart3,
FileCode,
Loader2,
FileText,
CheckCircle,
AlertCircle,
ListChecks,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn, generateUUID } from '@/lib/utils';
const logger = createLogger('AnalysisView');
@@ -742,7 +742,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
<Button onClick={runAnalysis} disabled={isAnalyzing} data-testid="analyze-project-button">
{isAnalyzing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Analyzing...
</>
) : (
@@ -771,7 +771,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
</div>
) : isAnalyzing ? (
<div className="flex flex-col items-center justify-center h-full">
<Loader2 className="w-12 h-12 animate-spin text-primary mb-4" />
<Spinner size="xl" className="mb-4" />
<p className="text-muted-foreground">Scanning project files...</p>
</div>
) : projectAnalysis ? (
@@ -850,7 +850,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
>
{isGeneratingSpec ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Generating...
</>
) : (
@@ -903,7 +903,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
>
{isGeneratingFeatureList ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Generating...
</>
) : (

View File

@@ -34,7 +34,7 @@ import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
import { RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
@@ -856,68 +856,9 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation]
);
// Client-side auto mode: periodically check for backlog items and move them to in-progress
// Use a ref to track the latest auto mode state so async operations always check the current value
const autoModeRunningRef = useRef(autoMode.isRunning);
useEffect(() => {
autoModeRunningRef.current = autoMode.isRunning;
}, [autoMode.isRunning]);
// Use a ref to track the latest features to avoid effect re-runs when features change
const hookFeaturesRef = useRef(hookFeatures);
useEffect(() => {
hookFeaturesRef.current = hookFeatures;
}, [hookFeatures]);
// Use a ref to track running tasks to avoid effect re-runs that clear pendingFeaturesRef
const runningAutoTasksRef = useRef(runningAutoTasks);
useEffect(() => {
runningAutoTasksRef.current = runningAutoTasks;
}, [runningAutoTasks]);
// Keep latest start handler without retriggering the auto mode effect
const handleStartImplementationRef = useRef(handleStartImplementation);
useEffect(() => {
handleStartImplementationRef.current = handleStartImplementation;
}, [handleStartImplementation]);
// Track features that are pending (started but not yet confirmed running)
const pendingFeaturesRef = useRef<Set<string>>(new Set());
// Listen to auto mode events to remove features from pending when they start running
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
if (!currentProject) return;
// Only process events for the current project
const eventProjectPath = 'projectPath' in event ? event.projectPath : undefined;
if (eventProjectPath && eventProjectPath !== currentProject.path) {
return;
}
switch (event.type) {
case 'auto_mode_feature_start':
// Feature is now confirmed running - remove from pending
if (event.featureId) {
pendingFeaturesRef.current.delete(event.featureId);
}
break;
case 'auto_mode_feature_complete':
case 'auto_mode_error':
// Feature completed or errored - remove from pending if still there
if (event.featureId) {
pendingFeaturesRef.current.delete(event.featureId);
}
break;
}
});
return unsubscribe;
}, [currentProject]);
// NOTE: Auto mode polling loop has been moved to the backend.
// The frontend now just toggles the backend's auto loop via API calls.
// See use-auto-mode.ts for the start/stop logic that calls the backend.
// Listen for backlog plan events (for background generation)
useEffect(() => {
@@ -976,219 +917,6 @@ export function BoardView() {
};
}, [currentProject, pendingBacklogPlan]);
useEffect(() => {
logger.info(
'[AutoMode] Effect triggered - isRunning:',
autoMode.isRunning,
'hasProject:',
!!currentProject
);
if (!autoMode.isRunning || !currentProject) {
return;
}
logger.info('[AutoMode] Starting auto mode polling loop for project:', currentProject.path);
let isChecking = false;
let isActive = true; // Track if this effect is still active
const checkAndStartFeatures = async () => {
// Check if auto mode is still running and effect is still active
// Use ref to get the latest value, not the closure value
if (!isActive || !autoModeRunningRef.current || !currentProject) {
return;
}
// Prevent concurrent executions
if (isChecking) {
return;
}
isChecking = true;
try {
// Double-check auto mode is still running before proceeding
if (!isActive || !autoModeRunningRef.current || !currentProject) {
logger.debug(
'[AutoMode] Skipping check - isActive:',
isActive,
'autoModeRunning:',
autoModeRunningRef.current,
'hasProject:',
!!currentProject
);
return;
}
// Count currently running tasks + pending features
// Use ref to get the latest running tasks without causing effect re-runs
const currentRunning = runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
const availableSlots = maxConcurrency - currentRunning;
logger.debug(
'[AutoMode] Checking features - running:',
currentRunning,
'available slots:',
availableSlots
);
// No available slots, skip check
if (availableSlots <= 0) {
return;
}
// Filter backlog features by the currently selected worktree branch
// This logic mirrors use-board-column-features.ts for consistency.
// HOWEVER: auto mode should still run even if the user is viewing a non-primary worktree,
// so we fall back to "all backlog features" when none are visible in the current view.
// Use ref to get the latest features without causing effect re-runs
const currentFeatures = hookFeaturesRef.current;
const backlogFeaturesInView = currentFeatures.filter((f) => {
if (f.status !== 'backlog') return false;
const featureBranch = f.branchName;
// Features without branchName are considered unassigned (show only on primary worktree)
if (!featureBranch) {
// No branch assigned - show only when viewing primary worktree
const isViewingPrimary = currentWorktreePath === null;
return isViewingPrimary;
}
if (currentWorktreeBranch === null) {
// We're viewing main but branch hasn't been initialized yet
// Show features assigned to primary worktree's branch
return currentProject.path
? isPrimaryWorktreeBranch(currentProject.path, featureBranch)
: false;
}
// Match by branch name
return featureBranch === currentWorktreeBranch;
});
const backlogFeatures =
backlogFeaturesInView.length > 0
? backlogFeaturesInView
: currentFeatures.filter((f) => f.status === 'backlog');
logger.debug(
'[AutoMode] Features - total:',
currentFeatures.length,
'backlog in view:',
backlogFeaturesInView.length,
'backlog total:',
backlogFeatures.length
);
if (backlogFeatures.length === 0) {
logger.debug(
'[AutoMode] No backlog features found, statuses:',
currentFeatures.map((f) => f.status).join(', ')
);
return;
}
// Sort by priority (lower number = higher priority, priority 1 is highest)
const sortedBacklog = [...backlogFeatures].sort(
(a, b) => (a.priority || 999) - (b.priority || 999)
);
// Filter out features with blocking dependencies if dependency blocking is enabled
// NOTE: skipVerificationInAutoMode means "ignore unmet dependency verification" so we
// should NOT exclude blocked features in that mode.
const eligibleFeatures =
enableDependencyBlocking && !skipVerificationInAutoMode
? sortedBacklog.filter((f) => {
const blockingDeps = getBlockingDependencies(f, currentFeatures);
if (blockingDeps.length > 0) {
logger.debug('[AutoMode] Feature', f.id, 'blocked by deps:', blockingDeps);
}
return blockingDeps.length === 0;
})
: sortedBacklog;
logger.debug(
'[AutoMode] Eligible features after dep check:',
eligibleFeatures.length,
'dependency blocking enabled:',
enableDependencyBlocking
);
// Start features up to available slots
const featuresToStart = eligibleFeatures.slice(0, availableSlots);
const startImplementation = handleStartImplementationRef.current;
if (!startImplementation) {
return;
}
logger.info(
'[AutoMode] Starting',
featuresToStart.length,
'features:',
featuresToStart.map((f) => f.id).join(', ')
);
for (const feature of featuresToStart) {
// Check again before starting each feature
if (!isActive || !autoModeRunningRef.current || !currentProject) {
return;
}
// Simplified: No worktree creation on client - server derives workDir from feature.branchName
// If feature has no branchName, assign it to the primary branch so it can run consistently
// even when the user is viewing a non-primary worktree.
if (!feature.branchName) {
const primaryBranch =
(currentProject.path ? getPrimaryWorktreeBranch(currentProject.path) : null) ||
'main';
await persistFeatureUpdate(feature.id, {
branchName: primaryBranch,
});
}
// Final check before starting implementation
if (!isActive || !autoModeRunningRef.current || !currentProject) {
return;
}
// Start the implementation - server will derive workDir from feature.branchName
const started = await startImplementation(feature);
// If successfully started, track it as pending until we receive the start event
if (started) {
pendingFeaturesRef.current.add(feature.id);
}
}
} finally {
isChecking = false;
}
};
// Check immediately, then every 3 seconds
checkAndStartFeatures();
const interval = setInterval(checkAndStartFeatures, 3000);
return () => {
// Mark as inactive to prevent any pending async operations from continuing
isActive = false;
clearInterval(interval);
// Clear pending features when effect unmounts or dependencies change
pendingFeaturesRef.current.clear();
};
}, [
autoMode.isRunning,
currentProject,
// runningAutoTasks is accessed via runningAutoTasksRef to prevent effect re-runs
// that would clear pendingFeaturesRef and cause concurrency issues
maxConcurrency,
// hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs
currentWorktreeBranch,
currentWorktreePath,
getPrimaryWorktreeBranch,
isPrimaryWorktreeBranch,
enableDependencyBlocking,
skipVerificationInAutoMode,
persistFeatureUpdate,
]);
// Use keyboard shortcuts hook (after actions hook)
useBoardKeyboardShortcuts({
features: hookFeatures,
@@ -1384,7 +1112,7 @@ export function BoardView() {
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="board-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
<Spinner size="lg" />
</div>
);
}
@@ -1403,9 +1131,13 @@ export function BoardView() {
isAutoModeRunning={autoMode.isRunning}
onAutoModeToggle={(enabled) => {
if (enabled) {
autoMode.start();
autoMode.start().catch((error) => {
logger.error('[AutoMode] Failed to start:', error);
});
} else {
autoMode.stop();
autoMode.stop().catch((error) => {
logger.error('[AutoMode] Failed to stop:', error);
});
}
}}
onOpenPlanDialog={() => setShowPlanDialog(true)}

View File

@@ -1,6 +1,7 @@
import { useRef, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { Search, X, Loader2 } from 'lucide-react';
import { Search, X } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface BoardSearchBarProps {
searchQuery: string;
@@ -75,7 +76,7 @@ export function BoardSearchBar({
title="Creating App Specification"
data-testid="spec-creation-badge"
>
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
<Spinner size="xs" className="shrink-0" />
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
Creating spec
</span>

View File

@@ -11,16 +11,8 @@ import {
} from '@/lib/agent-context-parser';
import { cn } from '@/lib/utils';
import type { AutoModeEvent } from '@/types/electron';
import {
Brain,
ListTodo,
Sparkles,
Expand,
CheckCircle2,
Circle,
Loader2,
Wrench,
} from 'lucide-react';
import { Brain, ListTodo, Sparkles, Expand, CheckCircle2, Circle, Wrench } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { SummaryDialog } from './summary-dialog';
import { getProviderIconForModel } from '@/components/ui/provider-icon';
@@ -338,7 +330,7 @@ export function AgentInfoPanel({
{todo.status === 'completed' ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === 'in_progress' ? (
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
<Spinner size="xs" className="w-2.5 h-2.5 shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}

View File

@@ -13,7 +13,6 @@ import {
import {
GripVertical,
Edit,
Loader2,
Trash2,
FileText,
MoreVertical,
@@ -21,6 +20,7 @@ import {
ChevronUp,
GitFork,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { CountUpTimer } from '@/components/ui/count-up-timer';
import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
@@ -65,7 +65,7 @@ export function CardHeaderSection({
{isCurrentAutoTask && !isSelectionMode && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
<Loader2 className="w-3.5 h-3.5 text-[var(--status-in-progress)] animate-spin" />
<Spinner size="xs" />
{feature.startedAt && (
<CountUpTimer
startedAt={feature.startedAt}
@@ -324,7 +324,7 @@ export function CardHeaderSection({
<div className="flex-1 min-w-0 overflow-hidden">
{feature.titleGenerating ? (
<div className="flex items-center gap-1.5 mb-1">
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
<Spinner size="xs" />
<span className="text-xs text-muted-foreground italic">Generating title...</span>
</div>
) : feature.title ? (

View File

@@ -170,7 +170,7 @@ export function AddFeatureDialog({
const [priority, setPriority] = useState(2);
// Model selection state
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'opus' });
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'claude-opus' });
// Check if current model supports planning mode (Claude/Anthropic only)
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);

View File

@@ -6,7 +6,8 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Loader2, List, FileText, GitBranch, ClipboardList } from 'lucide-react';
import { List, FileText, GitBranch, ClipboardList } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { LogViewer } from '@/components/ui/log-viewer';
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
@@ -353,7 +354,7 @@ export function AgentOutputModal({
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-8">
<DialogTitle className="flex items-center gap-2">
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
<Spinner size="md" />
)}
Agent Output
</DialogTitle>
@@ -439,7 +440,7 @@ export function AgentOutputModal({
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
<Spinner size="lg" className="mr-2" />
Loading...
</div>
)}
@@ -457,7 +458,7 @@ export function AgentOutputModal({
>
{isLoading && !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
<Spinner size="lg" className="mr-2" />
Loading output...
</div>
) : !output ? (

View File

@@ -11,16 +11,8 @@ import {
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import {
Loader2,
Wand2,
Check,
Plus,
Pencil,
Trash2,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { Wand2, Check, Plus, Pencil, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -287,8 +279,7 @@ export function BacklogPlanDialog({
</div>
{isGeneratingPlan && (
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 rounded-lg p-3">
<Loader2 className="w-4 h-4 animate-spin" />A plan is currently being generated in
the background...
<Spinner size="sm" />A plan is currently being generated in the background...
</div>
)}
</div>
@@ -405,7 +396,7 @@ export function BacklogPlanDialog({
case 'applying':
return (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary mb-4" />
<Spinner size="xl" className="mb-4" />
<p className="text-muted-foreground">Applying changes...</p>
</div>
);
@@ -452,7 +443,7 @@ export function BacklogPlanDialog({
<Button onClick={handleGenerate} disabled={!prompt.trim() || isGeneratingPlan}>
{isGeneratingPlan ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Generating...
</>
) : (

View File

@@ -10,7 +10,8 @@ import {
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { GitCommit, Loader2, Sparkles } from 'lucide-react';
import { GitCommit, Sparkles } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
@@ -209,7 +210,7 @@ export function CommitWorktreeDialog({
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Committing...
</>
) : (

View File

@@ -13,7 +13,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { GitBranchPlus, Loader2 } from 'lucide-react';
import { GitBranchPlus } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface WorktreeInfo {
path: string;
@@ -133,7 +134,7 @@ export function CreateBranchDialog({
<Button onClick={handleCreate} disabled={!branchName.trim() || isCreating}>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Creating...
</>
) : (

View File

@@ -13,7 +13,8 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import { GitPullRequest, Loader2, ExternalLink } from 'lucide-react';
import { GitPullRequest, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -405,7 +406,7 @@ export function CreatePRDialog({
<Button onClick={handleCreate} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Creating...
</>
) : (

View File

@@ -10,7 +10,8 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
import { GitBranch, AlertCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -216,7 +217,7 @@ export function CreateWorktreeDialog({
<Button onClick={handleCreate} disabled={isLoading || !branchName.trim()}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Creating...
</>
) : (

View File

@@ -10,7 +10,8 @@ import {
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Loader2, Trash2, AlertTriangle, FileWarning } from 'lucide-react';
import { Trash2, AlertTriangle, FileWarning } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -147,7 +148,7 @@ export function DeleteWorktreeDialog({
<Button variant="destructive" onClick={handleDelete} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Deleting...
</>
) : (

View File

@@ -28,6 +28,7 @@ import { toast } from 'sonner';
import { cn, modelSupportsThinking } from '@/lib/utils';
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
import { migrateModelId } from '@automaker/types';
import {
TestingTabContent,
PrioritySelector,
@@ -107,9 +108,9 @@ export function EditFeatureDialog({
feature?.requirePlanApproval ?? false
);
// Model selection state
// Model selection state - migrate legacy model IDs to canonical format
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>(() => ({
model: (feature?.model as ModelAlias) || 'opus',
model: migrateModelId(feature?.model) || 'claude-opus',
thinkingLevel: feature?.thinkingLevel || 'none',
reasoningEffort: feature?.reasoningEffort || 'none',
}));
@@ -157,9 +158,9 @@ export function EditFeatureDialog({
setDescriptionChangeSource(null);
setPreEnhancementDescription(null);
setLocalHistory(feature.descriptionHistory ?? []);
// Reset model entry
// Reset model entry - migrate legacy model IDs
setModelEntry({
model: (feature.model as ModelAlias) || 'opus',
model: migrateModelId(feature.model) || 'claude-opus',
thinkingLevel: feature.thinkingLevel || 'none',
reasoningEffort: feature.reasoningEffort || 'none',
});

View File

@@ -126,7 +126,7 @@ export function MassEditDialog({
});
// Field values
const [model, setModel] = useState<ModelAlias>('sonnet');
const [model, setModel] = useState<ModelAlias>('claude-sonnet');
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
@@ -160,7 +160,7 @@ export function MassEditDialog({
skipTests: false,
branchName: false,
});
setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias);
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode);
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));

View File

@@ -10,7 +10,8 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Loader2, GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -217,7 +218,7 @@ export function MergeWorktreeDialog({
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Merging...
</>
) : (

View File

@@ -14,7 +14,8 @@ import { Textarea } from '@/components/ui/textarea';
import { Markdown } from '@/components/ui/markdown';
import { Label } from '@/components/ui/label';
import { Feature } from '@/store/app-store';
import { Check, RefreshCw, Edit2, Eye, Loader2 } from 'lucide-react';
import { Check, RefreshCw, Edit2, Eye } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface PlanApprovalDialogProps {
open: boolean;
@@ -171,7 +172,7 @@ export function PlanApprovalDialog({
</Button>
<Button variant="secondary" onClick={handleReject} disabled={isLoading}>
{isLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
@@ -190,7 +191,7 @@ export function PlanApprovalDialog({
className="bg-green-600 hover:bg-green-700 text-white"
>
{isLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
) : (
<Check className="w-4 h-4 mr-2" />
)}

View File

@@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { Terminal, Check, X, ChevronDown, ChevronUp } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { useAppStore, type InitScriptState } from '@/store/app-store';
import { AnsiOutput } from '@/components/ui/ansi-output';
@@ -65,7 +66,7 @@ function SingleIndicator({
{/* 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 === 'running' && <Spinner size="sm" />}
{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">

View File

@@ -1,6 +1,7 @@
import { useEffect, useCallback, useState, type ComponentType, type ReactNode } from 'react';
import { RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
@@ -90,9 +91,11 @@ function UsageItem({
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Refresh usage"
>
<RefreshCw
className={cn('w-3.5 h-3.5 text-muted-foreground', isLoading && 'animate-spin')}
/>
{isLoading ? (
<Spinner size="xs" />
) : (
<RefreshCw className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
</div>
<div className="pl-6 space-y-2">{children}</div>

View File

@@ -9,7 +9,7 @@ import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
export type ModelOption = {
id: string; // Claude models use ModelAlias, Cursor models use "cursor-{id}"
id: string; // All model IDs use canonical prefixed format (e.g., "claude-sonnet", "cursor-auto")
label: string;
description: string;
badge?: string;
@@ -17,23 +17,27 @@ export type ModelOption = {
hasThinking?: boolean;
};
/**
* Claude models with canonical prefixed IDs
* UI displays short labels but stores full canonical IDs
*/
export const CLAUDE_MODELS: ModelOption[] = [
{
id: 'haiku',
id: 'claude-haiku', // Canonical prefixed ID
label: 'Claude Haiku',
description: 'Fast and efficient for simple tasks.',
badge: 'Speed',
provider: 'claude',
},
{
id: 'sonnet',
id: 'claude-sonnet', // Canonical prefixed ID
label: 'Claude Sonnet',
description: 'Balanced performance with strong reasoning.',
badge: 'Balanced',
provider: 'claude',
},
{
id: 'opus',
id: 'claude-opus', // Canonical prefixed ID
label: 'Claude Opus',
description: 'Most capable model for complex work.',
badge: 'Premium',
@@ -43,11 +47,11 @@ export const CLAUDE_MODELS: ModelOption[] = [
/**
* Cursor models derived from CURSOR_MODEL_MAP
* ID is prefixed with "cursor-" for ProviderFactory routing (if not already prefixed)
* IDs already have 'cursor-' prefix in the canonical format
*/
export const CURSOR_MODELS: ModelOption[] = Object.entries(CURSOR_MODEL_MAP).map(
([id, config]) => ({
id: id.startsWith('cursor-') ? id : `cursor-${id}`,
id, // Already prefixed in canonical format
label: config.label,
description: config.description,
provider: 'cursor' as ModelProvider,

View File

@@ -11,7 +11,7 @@ import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@autom
import type { ModelProvider } from '@automaker/types';
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
import { useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface ModelSelectorProps {
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
@@ -70,22 +70,30 @@ export function ModelSelector({
// Filter Cursor models based on enabled models from global settings
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
// Compare model.id directly since both model.id and enabledCursorModels use full IDs with prefix
return enabledCursorModels.includes(model.id as any);
// enabledCursorModels stores CursorModelIds which may or may not have "cursor-" prefix
// (e.g., 'auto', 'sonnet-4.5' without prefix, but 'cursor-gpt-5.2' with prefix)
// CURSOR_MODELS always has the "cursor-" prefix added in model-constants.ts
// Check both the full ID (for GPT models) and the unprefixed version (for non-GPT models)
const unprefixedId = model.id.startsWith('cursor-') ? model.id.slice(7) : model.id;
return (
enabledCursorModels.includes(model.id as any) ||
enabledCursorModels.includes(unprefixedId as any)
);
});
const handleProviderChange = (provider: ModelProvider) => {
if (provider === 'cursor' && selectedProvider !== 'cursor') {
// Switch to Cursor's default model (from global settings)
onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`);
// cursorDefaultModel is now canonical (e.g., 'cursor-auto'), so use directly
onModelSelect(cursorDefaultModel);
} else if (provider === 'codex' && selectedProvider !== 'codex') {
// Switch to Codex's default model (use isDefault flag from dynamic models)
const defaultModel = codexModels.find((m) => m.isDefault);
const defaultModelId = defaultModel?.id || codexModels[0]?.id || 'codex-gpt-5.2-codex';
onModelSelect(defaultModelId);
} else if (provider === 'claude' && selectedProvider !== 'claude') {
// Switch to Claude's default model
onModelSelect('sonnet');
// Switch to Claude's default model (canonical format)
onModelSelect('claude-sonnet');
}
};
@@ -294,7 +302,7 @@ export function ModelSelector({
{/* Loading state */}
{codexModelsLoading && dynamicCodexModels.length === 0 && (
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
<RefreshCw className="w-4 h-4 animate-spin" />
<Spinner size="sm" />
Loading models...
</div>
)}

View File

@@ -6,12 +6,12 @@ import {
ClipboardList,
FileText,
ScrollText,
Loader2,
Check,
Eye,
RefreshCw,
Sparkles,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@@ -236,7 +236,7 @@ export function PlanningModeSelector({
<div className="flex items-center gap-2">
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<Spinner size="sm" />
<span className="text-sm text-muted-foreground">
Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}...
</span>

View File

@@ -8,7 +8,8 @@ import {
DropdownMenuTrigger,
DropdownMenuLabel,
} from '@/components/ui/dropdown-menu';
import { GitBranch, RefreshCw, GitBranchPlus, Check, Search } from 'lucide-react';
import { GitBranch, GitBranchPlus, Check, Search } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, BranchInfo } from '../types';
@@ -81,7 +82,7 @@ export function BranchSwitchDropdown({
<div className="max-h-[250px] overflow-y-auto">
{isLoadingBranches ? (
<DropdownMenuItem disabled className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
<Spinner size="xs" className="mr-2" />
Loading branches...
</DropdownMenuItem>
) : filteredBranches.length === 0 ? (

View File

@@ -2,7 +2,6 @@ import { useEffect, useRef, useCallback, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Loader2,
Terminal,
ArrowDown,
ExternalLink,
@@ -12,6 +11,7 @@ import {
Clock,
GitBranch,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer';
import { useDevServerLogs } from '../hooks/use-dev-server-logs';
@@ -183,7 +183,7 @@ export function DevServerLogsPanel({
onClick={() => fetchLogs()}
title="Refresh logs"
>
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</div>
</div>
@@ -234,7 +234,7 @@ export function DevServerLogsPanel({
>
{isLoading && !logs ? (
<div className="flex items-center justify-center h-full min-h-[300px] text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
<Spinner size="md" className="mr-2" />
<span className="text-sm">Loading logs...</span>
</div>
) : !logs && !isRunning ? (
@@ -245,7 +245,7 @@ export function DevServerLogsPanel({
</div>
) : !logs ? (
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
<div className="w-8 h-8 mb-3 rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground/60 animate-spin" />
<Spinner size="xl" className="mb-3" />
<p className="text-sm">Waiting for output...</p>
<p className="text-xs mt-1 opacity-60">
Logs will appear as the server generates output
@@ -256,7 +256,6 @@ export function DevServerLogsPanel({
ref={xtermRef}
className="h-full"
minHeight={280}
fontSize={13}
autoScroll={autoScrollEnabled}
onScrollAwayFromBottom={() => setAutoScrollEnabled(false)}
onScrollToBottom={() => setAutoScrollEnabled(true)}

View File

@@ -26,13 +26,22 @@ import {
RefreshCw,
Copy,
ScrollText,
Terminal,
SquarePlus,
SplitSquareHorizontal,
} 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 {
useAvailableTerminals,
useEffectiveDefaultTerminal,
} from '../hooks/use-available-terminals';
import { getEditorIcon } from '@/components/icons/editor-icons';
import { getTerminalIcon } from '@/components/icons/terminal-icons';
import { useAppStore } from '@/store/app-store';
interface WorktreeActionsDropdownProps {
worktree: WorktreeInfo;
@@ -51,6 +60,8 @@ interface WorktreeActionsDropdownProps {
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -81,6 +92,8 @@ export function WorktreeActionsDropdown({
onPull,
onPush,
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onCommit,
onCreatePR,
onAddressPRComments,
@@ -108,6 +121,20 @@ export function WorktreeActionsDropdown({
? getEditorIcon(effectiveDefaultEditor.command)
: null;
// Get available terminals for the "Open In Terminal" submenu
const { terminals, hasExternalTerminals } = useAvailableTerminals();
// Use shared hook for effective default terminal (null = integrated terminal)
const effectiveDefaultTerminal = useEffectiveDefaultTerminal(terminals);
// Get the user's preferred mode for opening terminals (new tab vs split)
const openTerminalMode = useAppStore((s) => s.terminalState.openTerminalMode);
// Get icon component for the effective terminal
const DefaultTerminalIcon = effectiveDefaultTerminal
? getTerminalIcon(effectiveDefaultTerminal.id)
: Terminal;
// Check if there's a PR associated with this worktree from stored metadata
const hasPR = !!worktree.pr;
@@ -303,6 +330,77 @@ export function WorktreeActionsDropdown({
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{/* Open in terminal - always show with integrated + external options */}
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - opens in default terminal (integrated or external) */}
<DropdownMenuItem
onClick={() => {
if (effectiveDefaultTerminal) {
// External terminal is the default
onOpenInExternalTerminal(worktree, effectiveDefaultTerminal.id);
} else {
// Integrated terminal is the default - use user's preferred mode
const mode = openTerminalMode === 'newTab' ? 'tab' : 'split';
onOpenInIntegratedTerminal(worktree, mode);
}
}}
className="text-xs flex-1 pr-0 rounded-r-none"
>
<DefaultTerminalIcon className="w-3.5 h-3.5 mr-2" />
Open in {effectiveDefaultTerminal?.name ?? 'Terminal'}
</DropdownMenuItem>
{/* Chevron trigger for submenu with all terminals */}
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
</div>
<DropdownMenuSubContent>
{/* Automaker Terminal - with submenu for new tab vs split */}
<DropdownMenuSub>
<DropdownMenuSubTrigger className="text-xs">
<Terminal className="w-3.5 h-3.5 mr-2" />
Terminal
{!effectiveDefaultTerminal && (
<span className="ml-auto mr-2 text-[10px] text-muted-foreground">(default)</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={() => onOpenInIntegratedTerminal(worktree, 'tab')}
className="text-xs"
>
<SquarePlus className="w-3.5 h-3.5 mr-2" />
New Tab
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onOpenInIntegratedTerminal(worktree, 'split')}
className="text-xs"
>
<SplitSquareHorizontal className="w-3.5 h-3.5 mr-2" />
Split
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* External terminals */}
{terminals.length > 0 && <DropdownMenuSeparator />}
{terminals.map((terminal) => {
const TerminalIcon = getTerminalIcon(terminal.id);
const isDefault = terminal.id === effectiveDefaultTerminal?.id;
return (
<DropdownMenuItem
key={terminal.id}
onClick={() => onOpenInExternalTerminal(worktree, terminal.id)}
className="text-xs"
>
<TerminalIcon className="w-3.5 h-3.5 mr-2" />
{terminal.name}
{isDefault && (
<span className="ml-auto text-[10px] text-muted-foreground">(default)</span>
)}
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
{!worktree.isMain && hasInitScript && (
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2" />

View File

@@ -7,7 +7,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { GitBranch, ChevronDown, Loader2, CircleDot, Check } from 'lucide-react';
import { GitBranch, ChevronDown, CircleDot, Check } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { WorktreeInfo } from '../types';
@@ -44,7 +45,7 @@ export function WorktreeMobileDropdown({
<GitBranch className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{displayBranch}</span>
{isActivating ? (
<Loader2 className="w-3 h-3 animate-spin shrink-0" />
<Spinner size="xs" className="shrink-0" />
) : (
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
)}
@@ -74,7 +75,7 @@ export function WorktreeMobileDropdown({
) : (
<div className="w-3.5 h-3.5 shrink-0" />
)}
{isRunning && <Loader2 className="w-3 h-3 animate-spin shrink-0" />}
{isRunning && <Spinner size="xs" className="shrink-0" />}
<span className={cn('font-mono text-xs truncate', isSelected && 'font-medium')}>
{worktree.branch}
</span>

View File

@@ -1,6 +1,7 @@
import type { JSX } from 'react';
import { Button } from '@/components/ui/button';
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
import { Globe, CircleDot, GitPullRequest } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
@@ -37,6 +38,8 @@ interface WorktreeTabProps {
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -81,6 +84,8 @@ export function WorktreeTab({
onPull,
onPush,
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onCommit,
onCreatePR,
onAddressPRComments,
@@ -197,8 +202,8 @@ export function WorktreeTab({
aria-label={worktree.branch}
data-testid={`worktree-branch-${worktree.branch}`}
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
{isRunning && <Spinner size="xs" />}
{isActivating && !isRunning && <Spinner size="xs" />}
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
@@ -264,8 +269,8 @@ export function WorktreeTab({
: 'Click to switch to this branch'
}
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
{isRunning && <Spinner size="xs" />}
{isActivating && !isRunning && <Spinner size="xs" />}
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
@@ -342,6 +347,8 @@ export function WorktreeTab({
onPull={onPull}
onPush={onPush}
onOpenInEditor={onOpenInEditor}
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}

View File

@@ -0,0 +1,99 @@
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 { TerminalInfo } from '@automaker/types';
const logger = createLogger('AvailableTerminals');
// Re-export TerminalInfo for convenience
export type { TerminalInfo };
export function useAvailableTerminals() {
const [terminals, setTerminals] = useState<TerminalInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const fetchAvailableTerminals = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.getAvailableTerminals) {
setIsLoading(false);
return;
}
const result = await api.worktree.getAvailableTerminals();
if (result.success && result.result?.terminals) {
setTerminals(result.result.terminals);
}
} catch (error) {
logger.error('Failed to fetch available terminals:', error);
} finally {
setIsLoading(false);
}
}, []);
/**
* Refresh terminals by clearing the server cache and re-detecting
* Use this when the user has installed/uninstalled terminals
*/
const refresh = useCallback(async () => {
setIsRefreshing(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.refreshTerminals) {
// Fallback to regular fetch if refresh not available
await fetchAvailableTerminals();
return;
}
const result = await api.worktree.refreshTerminals();
if (result.success && result.result?.terminals) {
setTerminals(result.result.terminals);
logger.info(`Terminal cache refreshed, found ${result.result.terminals.length} terminals`);
}
} catch (error) {
logger.error('Failed to refresh terminals:', error);
} finally {
setIsRefreshing(false);
}
}, [fetchAvailableTerminals]);
useEffect(() => {
fetchAvailableTerminals();
}, [fetchAvailableTerminals]);
return {
terminals,
isLoading,
isRefreshing,
refresh,
// Convenience property: has external terminals available
hasExternalTerminals: terminals.length > 0,
// The first terminal is the "default" one (highest priority)
defaultTerminal: terminals[0] ?? null,
};
}
/**
* Hook to get the effective default terminal based on user settings
* Returns null if user prefers integrated terminal (defaultTerminalId is null)
* Falls back to: user preference > first available external terminal
*/
export function useEffectiveDefaultTerminal(terminals: TerminalInfo[]): TerminalInfo | null {
const defaultTerminalId = useAppStore((s) => s.defaultTerminalId);
return useMemo(() => {
// If user hasn't set a preference (null/undefined), they prefer integrated terminal
if (defaultTerminalId == null) {
return null;
}
// If user has set a preference, find it in available terminals
if (defaultTerminalId) {
const found = terminals.find((t) => t.id === defaultTerminalId);
if (found) return found;
}
// If the saved preference doesn't exist anymore, fall back to first available
return terminals[0] ?? null;
}, [terminals, defaultTerminalId]);
}

View File

@@ -1,4 +1,5 @@
import { useState, useCallback } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -35,6 +36,7 @@ interface UseWorktreeActionsOptions {
}
export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktreeActionsOptions) {
const navigate = useNavigate();
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [isSwitching, setIsSwitching] = useState(false);
@@ -125,6 +127,19 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
[isPushing, fetchBranches, fetchWorktrees]
);
const handleOpenInIntegratedTerminal = useCallback(
(worktree: WorktreeInfo, mode?: 'tab' | 'split') => {
// Navigate to the terminal view with the worktree path and branch name
// The terminal view will handle creating the terminal with the specified cwd
// Include nonce to allow opening the same worktree multiple times
navigate({
to: '/terminal',
search: { cwd: worktree.path, branch: worktree.branch, mode, nonce: Date.now() },
});
},
[navigate]
);
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => {
try {
const api = getElectronAPI();
@@ -143,6 +158,27 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
}
}, []);
const handleOpenInExternalTerminal = useCallback(
async (worktree: WorktreeInfo, terminalId?: string) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.openInExternalTerminal) {
logger.warn('Open in external terminal API not available');
return;
}
const result = await api.worktree.openInExternalTerminal(worktree.path, terminalId);
if (result.success && result.result) {
toast.success(result.result.message);
} else if (result.error) {
toast.error(result.error);
}
} catch (error) {
logger.error('Open in external terminal failed:', error);
}
},
[]
);
return {
isPulling,
isPushing,
@@ -152,6 +188,8 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
handleSwitchBranch,
handlePull,
handlePush,
handleOpenInIntegratedTerminal,
handleOpenInEditor,
handleOpenInExternalTerminal,
};
}

View File

@@ -1,10 +1,6 @@
export interface WorktreePRInfo {
number: number;
url: string;
title: string;
state: string;
createdAt: string;
}
// Re-export shared types from @automaker/types
export type { PRState, WorktreePRInfo } from '@automaker/types';
import type { PRState, WorktreePRInfo } from '@automaker/types';
export interface WorktreeInfo {
path: string;
@@ -43,7 +39,8 @@ export interface PRInfo {
number: number;
title: string;
url: string;
state: string;
/** PR state: OPEN, MERGED, or CLOSED */
state: PRState;
author: string;
body: string;
comments: Array<{

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