Compare commits

...

45 Commits

Author SHA1 Message Date
Claude
d3a4e13c4e chore: update package-lock.json
https://claude.ai/code/session_018msdfAb9sirVp5b5ZGi4Eo
2026-01-23 02:34:46 +00:00
Claude
7941deffd7 feat: add unified provider usage tracker for all AI providers
Implements a comprehensive usage tracking system for Claude, Cursor, Codex,
Gemini, GitHub Copilot, OpenCode, MiniMax, and GLM providers. Based on
CodexBar reference implementation.

- Add unified provider usage types in @automaker/types
- Implement usage services for each provider with appropriate auth
- Create unified ProviderUsageTracker service with 60s caching
- Add API routes for fetching provider usage data
- Add React Query hooks with polling support
- Create ProviderUsageBar UI component for board header
- Replace single-provider UsagePopover with unified bar

https://claude.ai/code/session_018msdfAb9sirVp5b5ZGi4Eo
2026-01-23 02:33:12 +00:00
Stefan de Vogelaere
01859f3a9a feat(ui): unified sidebar with collapsible sections and enhanced UX (#659)
* feat(ui): add unified sidebar component

Add new unified-sidebar component for layout improvements.
- Export UnifiedSidebar from layout components
- Update root route to use new sidebar structure

* refactor(ui): consolidate unified-sidebar into sidebar folder

Merge the unified-sidebar implementation into the standard sidebar
folder structure. The unified sidebar becomes the canonical sidebar
with improved features including collapsible sections, scroll
indicators, and enhanced mobile support.

- Delete old sidebar.tsx
- Move unified-sidebar components to sidebar/components
- Rename UnifiedSidebar to Sidebar
- Update all imports in __root.tsx
- Remove redundant unified-sidebar folder

* fix(ui): address PR review comments and fix E2E tests for unified sidebar

- Add try/catch for getElectronAPI() in sidebar-footer with window.open fallback
- Use formatShortcut() for OS-aware hotkey display in sidebar-header
- Remove unnecessary optional chaining on project.icon
- Remove redundant ternary in sidebar-navigation className
- Update E2E tests to use new project-dropdown-trigger data-testid

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 02:06:10 +01:00
Stefan de Vogelaere
afb6e14811 feat: Allow drag-to-create dependencies between any non-completed features (#656)
* feat: Allow drag-to-create dependencies between any non-completed features

Previously, the card drag-to-create-dependency feature only worked between
backlog features. This expands the functionality to allow creating dependency
links between features in any status (except completed).

Changes:
- Make all non-completed cards droppable for dependency linking
- Update drag-drop hook to allow links between any status
- Add status badges to the dependency link dialog for better context

* refactor: use barrel export for StatusBadge import

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-23 01:42:51 +01:00
Stefan de Vogelaere
c65f931326 feat(ui): generate meaningful worktree branch names from feature titles (#655)
* feat(ui): generate meaningful worktree branch names from feature titles

Instead of generating random branch names like `feature/main-1737547200000-tt2v`,
this change creates human-readable branch names based on the feature title:
`feature/add-user-authentication-a3b2`

Changes:
- Generate branch name slug from feature title (lowercase, alphanumeric, hyphens)
- Use 4-character random suffix for uniqueness instead of timestamp
- If no title provided, generate one from description first (for auto worktree mode)
- Fall back to 'untitled' if both title and description are empty
- Fix: Apply substring limit before removing trailing hyphens to prevent
  malformed branch names when truncation occurs at a hyphen position

This makes it much easier to identify which worktree corresponds to which
feature when working with multiple features simultaneously.

Closes #604

* fix(ui): preserve existing branch name in auto mode when editing features

When editing a feature that already has a branch name assigned, preserve
it instead of generating a new one. This prevents orphaning existing
worktrees when users edit features in auto worktree mode.
2026-01-23 01:42:36 +01:00
Stefan de Vogelaere
f480386905 feat: add Gemini CLI provider integration (#647)
* feat: add Gemini CLI provider for AI model execution

- Add GeminiProvider class extending CliProvider for Gemini CLI integration
- Add Gemini models (Gemini 3 Pro/Flash Preview, 2.5 Pro/Flash/Flash-Lite)
- Add gemini-models.ts with model definitions and types
- Update ModelProvider type to include 'gemini'
- Add isGeminiModel() to provider-utils.ts for model detection
- Register Gemini provider in provider-factory with priority 4
- Add Gemini setup detection routes (status, auth, deauth)
- Add GeminiCliStatus to setup store for UI state management
- Add Gemini to PROVIDER_ICON_COMPONENTS for UI icon display
- Add GEMINI_MODELS to model-display for dropdown population
- Support thinking levels: off, low, medium, high

Based on https://github.com/google-gemini/gemini-cli

* chore: update package-lock.json

* feat(ui): add Gemini provider to settings and setup wizard

- Add GeminiCliStatus component for CLI detection display
- Add GeminiSettingsTab component for global settings
- Update provider-tabs.tsx to include Gemini as 5th tab
- Update providers-setup-step.tsx with Gemini provider detection
- Add useGeminiCliStatus hook for querying CLI status
- Add getGeminiStatus, authGemini, deauthGemini to HTTP API client
- Add gemini query key for React Query
- Fix GeminiModelId type to not double-prefix model IDs

* feat(ui): add Gemini to settings sidebar navigation

- Add 'gemini-provider' to SettingsViewId type
- Add GeminiIcon and gemini-provider to navigation config
- Add gemini-provider to NAV_ID_TO_PROVIDER mapping
- Add gemini-provider case in settings-view switch
- Export GeminiSettingsTab from providers index

This fixes the missing Gemini entry in the AI Providers sidebar menu.

* feat(ui): add Gemini model configuration in settings

- Create GeminiModelConfiguration component for model selection
- Add enabledGeminiModels and geminiDefaultModel state to app-store
- Add setEnabledGeminiModels, setGeminiDefaultModel, toggleGeminiModel actions
- Update GeminiSettingsTab to show model configuration when CLI is installed
- Import GeminiModelId and getAllGeminiModelIds from types

This adds the ability to configure which Gemini models are available
in the feature modal, similar to other providers like Codex and OpenCode.

* feat(ui): add Gemini models to all model dropdowns

- Add GEMINI_MODELS to model-constants.ts for UI dropdowns
- Add Gemini to ALL_MODELS array used throughout the app
- Add GeminiIcon to PROFILE_ICONS mapping
- Fix GEMINI_MODELS in model-display.ts to use correct model IDs
- Update getModelDisplayName to handle Gemini models correctly

Gemini models now appear in all model selection dropdowns including
Model Defaults, Feature Defaults, and feature card settings.

* fix(gemini): fix CLI integration and event handling

- Fix model ID prefix handling: strip gemini- prefix in agent-service,
  add it back in buildCliArgs for CLI invocation
- Fix event normalization to match actual Gemini CLI output format:
  - type: 'init' (not 'system')
  - type: 'message' with role (not 'assistant')
  - tool_name/tool_id/parameters/output field names
- Add --sandbox false and --approval-mode yolo for faster execution
- Remove thinking level selector from UI (Gemini CLI doesn't support it)
- Update auth status to show errors properly

* test: update provider-factory tests for Gemini provider

- Add GeminiProvider import and spy mock
- Update expected provider count from 4 to 5
- Add test for GeminiProvider inclusion
- Add gemini key to checkAllProviders test

* fix(gemini): address PR review feedback

- Fix npm package name from @anthropic-ai/gemini-cli to @google/gemini-cli
- Fix comments in gemini-provider.ts to match actual CLI output format
- Convert sync fs operations to async using fs/promises

* fix(settings): add Gemini and Codex settings to sync

Add enabledGeminiModels, geminiDefaultModel, enabledCodexModels, and
codexDefaultModel to SETTINGS_FIELDS_TO_SYNC for persistence across sessions.

* fix(gemini): address additional PR review feedback

- Use 'Speed' badge for non-thinking Gemini models (consistency)
- Fix installCommand mapping in gemini-settings-tab.tsx
- Add hasEnvApiKey to GeminiCliStatus interface for API parity
- Clarify GeminiThinkingLevel comment (CLI doesn't support --thinking-level)

* fix(settings): restore Codex and Gemini settings from server

Add sanitization and restoration logic for enabledCodexModels,
codexDefaultModel, enabledGeminiModels, and geminiDefaultModel
in refreshSettingsFromServer() to match the fields in SETTINGS_FIELDS_TO_SYNC.

* feat(gemini): normalize tool names and fix workspace restrictions

- Add tool name mapping to normalize Gemini CLI tool names to standard
  names (e.g., write_todos -> TodoWrite, read_file -> Read)
- Add normalizeGeminiToolInput to convert write_todos format to TodoWrite
  format (description -> content, handle cancelled status)
- Pass --include-directories with cwd to fix workspace restriction errors
  when Gemini CLI has a different cached workspace from previous sessions

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-23 01:42:17 +01:00
Stefan de Vogelaere
7773db559d fix(ui): improve review dialog rendering for tool calls and tables (#657)
* fix(ui): improve review dialog rendering for tool calls and tables

- Replace Markdown component with LogViewer in plan-approval-dialog to
  properly format tool calls with collapsible sections and JSON highlighting
- Add remark-gfm plugin to Markdown component for GitHub Flavored Markdown
  support including tables, task lists, and strikethrough
- Add table styling classes to Markdown component for proper table rendering
- Install remark-gfm and rehype-sanitize dependencies

Fixes mixed/broken rendering in review dialog where tool calls showed as
raw text and markdown tables showed as pipe-separated text.

* chore: fix git+ssh URL and prettier formatting

- Convert git+ssh:// to git+https:// in package-lock.json for @electron/node-gyp
- Apply prettier formatting to plan-approval-dialog.tsx

* fix(ui): create PlanContentViewer for better plan display

The previous LogViewer approach showed tool calls prominently but hid
the actual plan/specification markdown content. The new PlanContentViewer:

- Separates tool calls (exploration) from plan markdown
- Shows the plan/specification markdown prominently using Markdown component
- Collapses tool calls by default in an "Exploration" section
- Properly renders GFM tables in the plan content

This provides a better UX where users see the important plan content
first, with tool calls available but not distracting.

* fix(ui): add show more/less toggle for feature description

The feature description in the plan approval dialog header was
truncated at 150 characters with no way to see the full text.
Now users can click "show more" to expand and "show less" to collapse.

* fix(ui): increase description limit and add feature title to dialog

- Increase description character limit from 150 to 250 characters
- Add feature title to dialog header (e.g., "Review Plan - Feature Title")
  only if title exists and is <= 50 characters

* feat(ui): render tasks code blocks as proper checkbox lists

When markdown contains a ```tasks code block, it now renders as:
- Phase headers (## Phase 1: ...) as styled section headings
- Task items (- [ ] or - [x]) with proper checkbox icons
- Checked items show green checkmark and strikethrough text
- Unchecked items show empty square icon

This makes implementation task lists in plans much more readable
compared to rendering them as raw code blocks.

* fix(ui): improve plan content parsing robustness

Address CodeRabbit review feedback:

1. Relax heading detection regex to match emoji and non-word chars
   - Change \w to \S so headings like "##  Plan" are detected
   - Change \*\*[A-Z] to \*\*\S for bold section detection

2. Flush active tool call when heading is detected
   - Prevents plan content being dropped when heading follows tool call
     without a blank line separator

3. Support tool names with dots/hyphens
   - Change \w+ to [^\s]+ so names like "web.run" or "file-read" work

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-23 01:41:45 +01:00
Shirone
655f254538 Merge pull request #658 from AutoMaker-Org/feature/v0.14.0rc-1769075904343-i0uw
feat: abillity to configure "Start Dev Server" command in project settings
2026-01-22 20:56:34 +00:00
Shirone
b4be3c11e2 refactor: consolidate dev and test command configuration into a new CommandsSection
- Introduced a new `CommandsSection` component to manage both development and test commands, replacing the previous `DevServerSection` and `TestingSection`.
- Updated the `SettingsService` to handle special cases for `devCommand` and `testCommand`, allowing for null values to delete commands.
- Removed deprecated sections and streamlined the project settings view to enhance user experience and maintainability.

This refactor simplifies command management and improves the overall structure of the project settings interface.
2026-01-22 21:47:35 +01:00
Shirone
57ce198ae9 fix: normalize custom command handling and improve project settings loading
- Updated the `DevServerService` to normalize custom commands by trimming whitespace and treating empty strings as undefined.
- Refactored the `DevServerSection` component to utilize TanStack Query for fetching project settings, improving data handling and error management.
- Enhanced the save functionality to use mutation hooks for updating project settings, streamlining the save process and ensuring better state management.

These changes enhance the reliability and user experience when configuring development server commands.
2026-01-22 17:49:06 +01:00
alexanderalgemi
733ca15e15 Fix production docker build (#651)
* fix: Add missing fast-xml-parser dependency

* fix: Add mssing spec-parser package.json to dockerfile
2026-01-22 17:37:45 +01:00
Shirone
e110c058a2 feat: enhance dev server configuration and command handling
- Updated the `/start-dev` route to accept a custom development command from project settings, allowing for greater flexibility in starting dev servers.
- Implemented a new `parseCustomCommand` method in the `DevServerService` to handle custom command parsing, including support for quoted strings.
- Added a new `DevServerSection` component in the UI for configuring the dev server command, featuring quick presets and auto-detection options.
- Updated project settings interface to include a `devCommand` property for storing custom commands.

This update improves the user experience by allowing users to specify custom commands for their development servers, enhancing the overall development workflow.
2026-01-22 17:13:16 +01:00
webdevcody
0fdda11b09 refactor: normalize branch name handling and enhance auto mode settings merging
- Updated branch name normalization to align with UI conventions, treating "main" as null for consistency.
- Implemented deep merging of `autoModeByWorktree` settings to preserve existing entries during updates.
- Enhanced the BoardView component to persist max concurrency settings to the server, ensuring accurate capacity checks.
- Added error handling for feature rollback persistence in useBoardActions.

These changes improve the reliability and consistency of auto mode settings across the application.
2026-01-22 09:43:28 -05:00
Stefan de Vogelaere
0155da0be5 fix: resolve model aliases in backlog plan explicit override (#654)
When a user explicitly passes a model override (e.g., model: "sonnet"),
the code was only fetching credentials without resolving the model alias.
This caused API calls to fail because the Claude API expects full model
strings like "claude-sonnet-4-20250514", not aliases like "sonnet".

The other code branches (settings-based and fallback) correctly called
resolvePhaseModel(), but the explicit override branch was missing this.

This fix adds the resolvePhaseModel() call to ensure model aliases are
properly resolved before being sent to the API.
2026-01-22 12:58:55 +01:00
Shirone
41b127ebf3 Merge pull request #643 from AutoMaker-Org/feature/v0.14.0rc-1768981415660-tt2v
feat: add import / export features in json / yaml format
2026-01-21 23:06:10 +00:00
Shirone
e7e83a30d9 Merge pull request #650 from AutoMaker-Org/fix/ideation-view-non-claude-models
fix: ideation view not working with other providers
2026-01-21 22:49:11 +00:00
Shirone
40950b5fce refactor: remove suggestions routes and related logic
This commit removes the suggestions routes and associated files from the server, streamlining the codebase. The `suggestionsModel` has been replaced with `ideationModel` across various components, including UI and service layers, to better reflect the updated functionality. Additionally, adjustments were made to ensure that the ideation service correctly utilizes the new model configuration.

- Deleted suggestions routes and their handlers.
- Updated references from `suggestionsModel` to `ideationModel` in settings and UI components.
- Refactored related logic in the ideation service to align with the new model structure.
2026-01-21 23:42:53 +01:00
Shirone
3f05735be1 Merge pull request #649 from AutoMaker-Org/feat/detect-no-remote-branch
fix: detect no remote branch
2026-01-21 21:44:19 +00:00
Shirone
05f0ceceb6 fix: build failing 2026-01-21 22:39:20 +01:00
Shirone
28d50aa017 refactor: Consolidate validation and improve error logging 2026-01-21 22:28:22 +01:00
Shirone
103c6bc8a0 docs: improve comment clarity for resolvePhaseModel usage
Updated the comment to better explain why resolveModelString is not
needed after resolvePhaseModel - the latter already handles model
alias resolution internally.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:26:01 +01:00
Shirone
6c47068f71 refactor: remove redundant resolveModelString call in ideation service
Address PR #650 review feedback from gemini-code-assist. The call to
resolveModelString was redundant because resolvePhaseModel already
returns the fully resolved canonical model ID. When providerId is set,
it returns the provider-specific model ID unchanged; otherwise, it
already calls resolveModelString internally.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:23:10 +01:00
Shirone
a9616ff309 feat: add remote management functionality
- Introduced a new route for adding remotes to git worktrees.
- Enhanced the PushToRemoteDialog component to support adding new remotes, including form handling and error management.
- Updated the API client to include an endpoint for adding remotes.
- Modified the worktree state management to track the presence of remotes.
- Improved the list branches handler to check for configured remotes.

This update allows users to easily add remotes through the UI, enhancing the overall git workflow experience.
2026-01-21 22:11:16 +01:00
Shirone
4fa0923ff8 feat(ideation): enhance model resolution and provider integration
- Updated the ideation service to utilize phase settings for model resolution, improving flexibility in handling model aliases.
- Introduced `getPhaseModelWithOverrides` to fetch model and provider information, allowing for dynamic adjustments based on project settings.
- Enhanced logging to provide clearer insights into the model and provider being used during suggestion generation.

This update streamlines the process of generating suggestions by leveraging phase-specific configurations, ensuring better alignment with user-defined settings.
2026-01-21 22:08:51 +01:00
Shirone
c3cecc18f2 Merge pull request #646 from AutoMaker-Org/fix/excessive-api-polling
fix: excessive api pooling
2026-01-21 19:17:39 +00:00
Shirone
3fcda8abfc chore: update package-lock.json for version bump and dependency adjustments
- Bumped version from 0.12.0rc to 0.13.0 across the project.
- Updated package-lock.json to reflect changes in dependencies, including marking certain dependencies as `devOptional`.
- Adjusted import paths in the UI for better module organization.

This update ensures consistency in versioning and improves the structure of utility imports.
2026-01-21 20:14:39 +01:00
Shirone
a45ee59b7d Merge remote-tracking branch 'origin/v0.14.0rc' into feature/v0.14.0rc-1768981415660-tt2v
# Conflicts:
#	apps/ui/src/components/views/project-settings-view/config/navigation.ts
#	apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts
2026-01-21 17:46:22 +01:00
Shirone
662f854203 feat(ui): move export/import features from board header to project settings
Relocate the export and import features functionality from the board header
dropdown menu to a new "Data" section in project settings for better UX.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:43:33 +01:00
Shirone
f2860d9366 Merge pull request #645 from AutoMaker-Org/feat/test-runner
feat(tests): implement test runner functionality with API integration
2026-01-21 16:25:13 +00:00
Shirone
6eb7acb6d4 fix: Add path validation for optional params in test runner routes
Add path validation middleware for optional projectPath and worktreePath
parameters in test runner routes to maintain parity with other worktree
routes and ensure proper security validation when ALLOWED_ROOT_DIRECTORY
is configured.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:21:18 +01:00
Shirone
4ab927a5fb fix: Prevent command injection and stale state in test runner 2026-01-21 16:12:36 +01:00
Shirone
02de3df3df fix: replace magic numbers with named constants in polling logic
Address PR review feedback:
- Use WS_ACTIVITY_THRESHOLD constant instead of hardcoded 10000 in agent-info-panel.tsx
- Extract AGENT_OUTPUT_POLLING_INTERVAL constant for 5000ms value in use-features.ts

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:10:22 +01:00
Shirone
b73885e04a fix: adress pr comments 2026-01-21 16:00:40 +01:00
Shirone
afa93dde0d feat(tests): implement test runner functionality with API integration
- Added Test Runner Service to manage test execution processes for worktrees.
- Introduced endpoints for starting and stopping tests, and retrieving test logs.
- Created UI components for displaying test logs and managing test sessions.
- Integrated test runner events for real-time updates in the UI.
- Updated project settings to include configurable test commands.

This enhancement allows users to run tests directly from the UI, view logs in real-time, and manage test sessions effectively.
2026-01-21 15:45:33 +01:00
Shirone
aac59c2b3a feat(ui): enhance WebSocket event handling and polling logic
- Introduced a new `useEventRecency` hook to track the recency of WebSocket events, allowing for conditional polling based on event activity.
- Updated `AgentInfoPanel` to utilize the new hook, adjusting polling intervals based on WebSocket activity.
- Implemented debounced invalidation for auto mode events to optimize query updates during rapid event streams.
- Added utility functions for managing event recency checks in various query hooks, improving overall responsiveness and reducing unnecessary polling.
- Introduced debounce and throttle utilities for better control over function execution rates.

This enhancement improves the application's performance by reducing polling when real-time updates are available, ensuring a more efficient use of resources.
2026-01-21 14:57:26 +01:00
Stefan de Vogelaere
c3e7e57968 feat(ui): make React Query DevTools configurable (#642)
* feat(ui): make React Query DevTools configurable

- Add showQueryDevtools setting to app store with persistence
- Add toggle in Global Settings > Developer section
- Move DevTools button from bottom-left to bottom-right (less intrusive)
- Support VITE_HIDE_QUERY_DEVTOOLS env variable to disable
- DevTools only available in development mode

Users can now:
1. Toggle DevTools on/off via Settings > Developer
2. Set VITE_HIDE_QUERY_DEVTOOLS=true to hide permanently
3. DevTools are now positioned at bottom-right to avoid overlapping UI controls

* chore: update package-lock.json

* fix(ui): hide React Query DevTools toggle in production mode

* refactor(ui): remove VITE_HIDE_QUERY_DEVTOOLS env variable

The persisted toggle in Settings > Developer is sufficient for controlling
DevTools visibility. No need for an additional env variable override.

* fix(ui): persist showQueryDevtools setting across page refreshes

- Add showQueryDevtools to GlobalSettings type
- Add showQueryDevtools to hydrateStoreFromSettings function
- Add default value in DEFAULT_GLOBAL_SETTINGS

* fix: restore package-lock.json from base branch

Removes git+ssh:// URL that was accidentally introduced

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-21 13:20:36 +01:00
Shirone
7bb97953a7 feat: Refactor feature export service with type guards and parallel conflict checking 2026-01-21 13:11:18 +01:00
Shirone
2214c2700b feat(ui): add export and import features functionality
- Introduced new routes for exporting and importing features, enhancing project management capabilities.
- Added UI components for export and import dialogs, allowing users to easily manage feature data.
- Updated HTTP API client to support export and import operations with appropriate options and responses.
- Enhanced board view with controls for triggering export and import actions, improving user experience.
- Defined new types for feature export and import, ensuring type safety and clarity in data handling.
2026-01-21 13:00:34 +01:00
Shirone
7bee54717c Merge pull request #637 from AutoMaker-Org/feature/v0.13.0rc-1768936017583-e6ni
feat: implement pipeline step exclusion functionality
2026-01-21 11:59:08 +00:00
Stefan de Vogelaere
5ab53afd7f feat: add per-project default model override for new features (#640)
* feat: add per-project default model override for new features

- Add defaultFeatureModel to ProjectSettings type for project-level override
- Add defaultFeatureModel to Project interface for UI state
- Display Default Feature Model in Model Defaults section alongside phase models
- Include Default Feature Model in global Bulk Replace dialog
- Add Default Feature Model override section to Project Settings
- Add setProjectDefaultFeatureModel store action for project-level overrides
- Update clearAllProjectPhaseModelOverrides to also clear defaultFeatureModel
- Update add-feature-dialog to use project override when available
- Include Default Feature Model in Project Bulk Replace dialog

This allows projects with different complexity levels to use different
default models (e.g., Haiku for simple tasks, Opus for complex projects).

* fix: add server-side __CLEAR__ handler for defaultFeatureModel

- Add handler in settings-service.ts to properly delete defaultFeatureModel
  when '__CLEAR__' marker is sent from the UI
- Fix bulk-replace-dialog.tsx to correctly return claude-opus when resetting
  default feature model to Anthropic Direct (was incorrectly using
  enhancementModel's settings which default to sonnet)

These fixes ensure:
1. Clearing project default model override properly removes the setting
   instead of storing literal '__CLEAR__' string
2. Global bulk replace correctly resets default feature model to opus

* fix: include defaultFeatureModel in Reset to Defaults action

- Updated resetPhaseModels to also reset defaultFeatureModel to claude-opus
- Fixed initial state to use canonical 'claude-opus' instead of 'opus'

* refactor: use DEFAULT_GLOBAL_SETTINGS constant for defaultFeatureModel

Address PR review feedback:
- Replace hardcoded { model: 'claude-opus' } with DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel
- Fix Prettier formatting for long destructuring lines
- Import DEFAULT_GLOBAL_SETTINGS from @automaker/types where needed

This improves maintainability by centralizing the default value.
2026-01-21 12:45:14 +01:00
Stefan de Vogelaere
3ebd67f35f fix: hide Cursor models in selector when provider is disabled (#639)
* fix: hide Cursor models in selector when provider is disabled

The Cursor Models section was appearing in model dropdown selectors even
when the Cursor provider was toggled OFF in Settings → AI Providers.

This fix adds a !isCursorDisabled check to the rendering condition,
matching the pattern already used by Codex and OpenCode providers.
This ensures consistency across all provider types.

Fixes the issue where:
- Codex/OpenCode correctly hide models when disabled
- Cursor incorrectly showed models even when disabled

* style: fix Prettier formatting
2026-01-21 11:40:26 +01:00
Stefan de Vogelaere
641bbde877 refactor: replace crypto.randomUUID with generateUUID utility (#638)
* refactor: replace crypto.randomUUID with generateUUID in spec editor

Use the centralized generateUUID utility from @/lib/utils instead of
direct crypto.randomUUID calls in spec editor components. This provides
better fallback handling for non-secure contexts (e.g., Docker via HTTP).

Files updated:
- array-field-editor.tsx
- features-section.tsx
- roadmap-section.tsx

* refactor: simplify generateUUID to always use crypto.getRandomValues

Remove conditional checks and fallbacks - crypto.getRandomValues() works
in all modern browsers including non-secure HTTP contexts (Docker).
This simplifies the code while maintaining the same security guarantees.

* refactor: add defensive check for crypto availability

Add check for crypto.getRandomValues() availability before use.
Throws a meaningful error if the crypto API is not available,
rather than failing with an unclear runtime error.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-21 10:32:12 +01:00
Shirone
7c80249bbf Merge remote-tracking branch 'origin/main' into feature/v0.13.0rc-1768936017583-e6ni
# Conflicts:
#	apps/ui/src/components/views/board-view.tsx
2026-01-21 08:47:16 +01:00
Shirone
a73a57b9a4 feat: implement pipeline step exclusion functionality
- Added support for excluding specific pipeline steps in feature management, allowing users to skip certain steps during execution.
- Introduced a new `PipelineExclusionControls` component for managing exclusions in the UI.
- Updated relevant dialogs and components to handle excluded pipeline steps, including `AddFeatureDialog`, `EditFeatureDialog`, and `MassEditDialog`.
- Enhanced the `getNextStatus` method in `PipelineService` to account for excluded steps when determining the next status in the pipeline flow.
- Updated tests to cover scenarios involving excluded pipeline steps.
2026-01-21 08:34:55 +01:00
webdevcody
db71dc9aa5 fix(workflows): update artifact upload paths in release workflow
- Modified paths for macOS, Windows, and Linux artifacts to use explicit file patterns instead of wildcard syntax.
- Ensured all relevant file types are included for each platform, improving artifact management during releases.
2026-01-20 22:48:00 -05:00
160 changed files with 17090 additions and 1773 deletions

View File

@@ -62,7 +62,9 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: macos-builds
path: apps/ui/release/*.{dmg,zip}
path: |
apps/ui/release/*.dmg
apps/ui/release/*.zip
retention-days: 30
- name: Upload Windows artifacts
@@ -78,7 +80,10 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: linux-builds
path: apps/ui/release/*.{AppImage,deb,rpm}
path: |
apps/ui/release/*.AppImage
apps/ui/release/*.deb
apps/ui/release/*.rpm
retention-days: 30
upload:
@@ -109,8 +114,14 @@ jobs:
uses: softprops/action-gh-release@v2
with:
files: |
artifacts/macos-builds/*.{dmg,zip,blockmap}
artifacts/windows-builds/*.{exe,blockmap}
artifacts/linux-builds/*.{AppImage,deb,rpm,blockmap}
artifacts/macos-builds/*.dmg
artifacts/macos-builds/*.zip
artifacts/macos-builds/*.blockmap
artifacts/windows-builds/*.exe
artifacts/windows-builds/*.blockmap
artifacts/linux-builds/*.AppImage
artifacts/linux-builds/*.deb
artifacts/linux-builds/*.rpm
artifacts/linux-builds/*.blockmap
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -28,6 +28,7 @@ COPY libs/platform/package*.json ./libs/platform/
COPY libs/model-resolver/package*.json ./libs/model-resolver/
COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
COPY libs/git-utils/package*.json ./libs/git-utils/
COPY libs/spec-parser/package*.json ./libs/spec-parser/
# Copy scripts (needed by npm workspace)
COPY scripts ./scripts

View File

@@ -40,7 +40,8 @@
"express": "5.2.1",
"morgan": "1.10.1",
"node-pty": "1.1.0-beta41",
"ws": "8.18.3"
"ws": "8.18.3",
"yaml": "2.7.0"
},
"devDependencies": {
"@types/cookie": "0.6.0",

View File

@@ -43,7 +43,6 @@ import { createEnhancePromptRoutes } from './routes/enhance-prompt/index.js';
import { createWorktreeRoutes } from './routes/worktree/index.js';
import { createGitRoutes } from './routes/git/index.js';
import { createSetupRoutes } from './routes/setup/index.js';
import { createSuggestionsRoutes } from './routes/suggestions/index.js';
import { createModelsRoutes } from './routes/models/index.js';
import { createRunningAgentsRoutes } from './routes/running-agents/index.js';
import { createWorkspaceRoutes } from './routes/workspace/index.js';
@@ -83,6 +82,9 @@ import { createNotificationsRoutes } from './routes/notifications/index.js';
import { getNotificationService } from './services/notification-service.js';
import { createEventHistoryRoutes } from './routes/event-history/index.js';
import { getEventHistoryService } from './services/event-history-service.js';
import { getTestRunnerService } from './services/test-runner-service.js';
import { createProviderUsageRoutes } from './routes/provider-usage/index.js';
import { ProviderUsageTracker } from './services/provider-usage-tracker.js';
// Load environment variables
dotenv.config();
@@ -236,6 +238,7 @@ const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServ
const codexUsageService = new CodexUsageService(codexAppServerService);
const mcpTestService = new MCPTestService(settingsService);
const ideationService = new IdeationService(events, settingsService, featureLoader);
const providerUsageTracker = new ProviderUsageTracker(codexUsageService);
// Initialize DevServerService with event emitter for real-time log streaming
const devServerService = getDevServerService();
@@ -248,6 +251,10 @@ notificationService.setEventEmitter(events);
// Initialize Event History Service
const eventHistoryService = getEventHistoryService();
// Initialize Test Runner Service with event emitter for real-time test output streaming
const testRunnerService = getTestRunnerService();
testRunnerService.setEventEmitter(events);
// Initialize Event Hook Service for custom event triggers (with history storage)
eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader);
@@ -326,7 +333,6 @@ app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
app.use('/api/git', createGitRoutes());
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
app.use('/api/models', createModelsRoutes());
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService));
@@ -344,6 +350,7 @@ app.use('/api/pipeline', createPipelineRoutes(pipelineService));
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
app.use('/api/notifications', createNotificationsRoutes(notificationService));
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
app.use('/api/provider-usage', createProviderUsageRoutes(providerUsageTracker));
// Create HTTP server
const server = createServer(app);

View File

@@ -337,10 +337,11 @@ export class CursorProvider extends CliProvider {
'--stream-partial-output' // Real-time streaming
);
// Only add --force if NOT in read-only mode
// Without --force, Cursor CLI suggests changes but doesn't apply them
// With --force, Cursor CLI can actually edit files
if (!options.readOnly) {
// In read-only mode, use --mode ask for Q&A style (no tools)
// Otherwise, add --force to allow file edits
if (options.readOnly) {
cliArgs.push('--mode', 'ask');
} else {
cliArgs.push('--force');
}
@@ -672,10 +673,13 @@ export class CursorProvider extends CliProvider {
);
}
// Extract prompt text to pass via stdin (avoids shell escaping issues)
const promptText = this.extractPromptText(options);
// Embed system prompt into user prompt (Cursor CLI doesn't support separate system messages)
const effectiveOptions = this.embedSystemPromptIntoPrompt(options);
const cliArgs = this.buildCliArgs(options);
// Extract prompt text to pass via stdin (avoids shell escaping issues)
const promptText = this.extractPromptText(effectiveOptions);
const cliArgs = this.buildCliArgs(effectiveOptions);
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
// Pass prompt via stdin to avoid shell interpretation of special characters

View File

@@ -0,0 +1,815 @@
/**
* Gemini Provider - Executes queries using the Gemini CLI
*
* Extends CliProvider with Gemini-specific:
* - Event normalization for Gemini's JSONL streaming format
* - Google account and API key authentication support
* - Thinking level configuration
*
* Based on https://github.com/google-gemini/gemini-cli
*/
import { execSync } from 'child_process';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js';
import type {
ProviderConfig,
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ModelDefinition,
ContentBlock,
} from './types.js';
import { validateBareModelId } from '@automaker/types';
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
import { createLogger, isAbortError } from '@automaker/utils';
import { spawnJSONLProcess } from '@automaker/platform';
// Create logger for this module
const logger = createLogger('GeminiProvider');
// =============================================================================
// Gemini Stream Event Types
// =============================================================================
/**
* Base event structure from Gemini CLI --output-format stream-json
*
* Actual CLI output format:
* {"type":"init","timestamp":"...","session_id":"...","model":"..."}
* {"type":"message","timestamp":"...","role":"user","content":"..."}
* {"type":"message","timestamp":"...","role":"assistant","content":"...","delta":true}
* {"type":"tool_use","timestamp":"...","tool_name":"...","tool_id":"...","parameters":{...}}
* {"type":"tool_result","timestamp":"...","tool_id":"...","status":"success","output":"..."}
* {"type":"result","timestamp":"...","status":"success","stats":{...}}
*/
interface GeminiStreamEvent {
type: 'init' | 'message' | 'tool_use' | 'tool_result' | 'result' | 'error';
timestamp?: string;
session_id?: string;
}
interface GeminiInitEvent extends GeminiStreamEvent {
type: 'init';
session_id: string;
model: string;
}
interface GeminiMessageEvent extends GeminiStreamEvent {
type: 'message';
role: 'user' | 'assistant';
content: string;
delta?: boolean;
session_id?: string;
}
interface GeminiToolUseEvent extends GeminiStreamEvent {
type: 'tool_use';
tool_id: string;
tool_name: string;
parameters: Record<string, unknown>;
session_id?: string;
}
interface GeminiToolResultEvent extends GeminiStreamEvent {
type: 'tool_result';
tool_id: string;
status: 'success' | 'error';
output: string;
session_id?: string;
}
interface GeminiResultEvent extends GeminiStreamEvent {
type: 'result';
status: 'success' | 'error';
stats?: {
total_tokens?: number;
input_tokens?: number;
output_tokens?: number;
cached?: number;
input?: number;
duration_ms?: number;
tool_calls?: number;
};
error?: string;
session_id?: string;
}
// =============================================================================
// Error Codes
// =============================================================================
export enum GeminiErrorCode {
NOT_INSTALLED = 'GEMINI_NOT_INSTALLED',
NOT_AUTHENTICATED = 'GEMINI_NOT_AUTHENTICATED',
RATE_LIMITED = 'GEMINI_RATE_LIMITED',
MODEL_UNAVAILABLE = 'GEMINI_MODEL_UNAVAILABLE',
NETWORK_ERROR = 'GEMINI_NETWORK_ERROR',
PROCESS_CRASHED = 'GEMINI_PROCESS_CRASHED',
TIMEOUT = 'GEMINI_TIMEOUT',
UNKNOWN = 'GEMINI_UNKNOWN_ERROR',
}
export interface GeminiError extends Error {
code: GeminiErrorCode;
recoverable: boolean;
suggestion?: string;
}
// =============================================================================
// Tool Name Normalization
// =============================================================================
/**
* Gemini CLI tool name to standard tool name mapping
* This allows the UI to properly categorize and display Gemini tool calls
*/
const GEMINI_TOOL_NAME_MAP: Record<string, string> = {
write_todos: 'TodoWrite',
read_file: 'Read',
read_many_files: 'Read',
replace: 'Edit',
write_file: 'Write',
run_shell_command: 'Bash',
search_file_content: 'Grep',
glob: 'Glob',
list_directory: 'Ls',
web_fetch: 'WebFetch',
google_web_search: 'WebSearch',
};
/**
* Normalize Gemini tool names to standard tool names
*/
function normalizeGeminiToolName(geminiToolName: string): string {
return GEMINI_TOOL_NAME_MAP[geminiToolName] || geminiToolName;
}
/**
* Normalize Gemini tool input parameters to standard format
*
* Gemini `write_todos` format:
* {"todos": [{"description": "Task text", "status": "pending|in_progress|completed|cancelled"}]}
*
* Claude `TodoWrite` format:
* {"todos": [{"content": "Task text", "status": "pending|in_progress|completed", "activeForm": "..."}]}
*/
function normalizeGeminiToolInput(
toolName: string,
input: Record<string, unknown>
): Record<string, unknown> {
// Normalize write_todos: map 'description' to 'content', handle 'cancelled' status
if (toolName === 'write_todos' && Array.isArray(input.todos)) {
return {
todos: input.todos.map((todo: { description?: string; status?: string }) => ({
content: todo.description || '',
// Map 'cancelled' to 'completed' since Claude doesn't have cancelled status
status: todo.status === 'cancelled' ? 'completed' : todo.status,
// Use description as activeForm since Gemini doesn't have it
activeForm: todo.description || '',
})),
};
}
return input;
}
/**
* GeminiProvider - Integrates Gemini CLI as an AI provider
*
* Features:
* - Google account OAuth login support
* - API key authentication (GEMINI_API_KEY)
* - Vertex AI support
* - Thinking level configuration
* - Streaming JSON output
*/
export class GeminiProvider extends CliProvider {
constructor(config: ProviderConfig = {}) {
super(config);
// Trigger CLI detection on construction
this.ensureCliDetected();
}
// ==========================================================================
// CliProvider Abstract Method Implementations
// ==========================================================================
getName(): string {
return 'gemini';
}
getCliName(): string {
return 'gemini';
}
getSpawnConfig(): CliSpawnConfig {
return {
windowsStrategy: 'npx', // Gemini CLI can be run via npx
npxPackage: '@google/gemini-cli', // Official Google Gemini CLI package
commonPaths: {
linux: [
path.join(os.homedir(), '.local/bin/gemini'),
'/usr/local/bin/gemini',
path.join(os.homedir(), '.npm-global/bin/gemini'),
],
darwin: [
path.join(os.homedir(), '.local/bin/gemini'),
'/usr/local/bin/gemini',
'/opt/homebrew/bin/gemini',
path.join(os.homedir(), '.npm-global/bin/gemini'),
],
win32: [
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini.cmd'),
path.join(os.homedir(), '.npm-global', 'gemini.cmd'),
],
},
};
}
/**
* Extract prompt text from ExecuteOptions
*/
private extractPromptText(options: ExecuteOptions): string {
if (typeof options.prompt === 'string') {
return options.prompt;
} else if (Array.isArray(options.prompt)) {
return options.prompt
.filter((p) => p.type === 'text' && p.text)
.map((p) => p.text)
.join('\n');
} else {
throw new Error('Invalid prompt format');
}
}
buildCliArgs(options: ExecuteOptions): string[] {
// Model comes in stripped of provider prefix (e.g., '2.5-flash' from 'gemini-2.5-flash')
// We need to add 'gemini-' back since it's part of the actual CLI model name
const bareModel = options.model || '2.5-flash';
const cliArgs: string[] = [];
// Streaming JSON output format for real-time updates
cliArgs.push('--output-format', 'stream-json');
// Model selection - Gemini CLI expects full model names like "gemini-2.5-flash"
// Unlike Cursor CLI where 'cursor-' is just a routing prefix, for Gemini CLI
// the 'gemini-' is part of the actual model name Google expects
if (bareModel && bareModel !== 'auto') {
// Add gemini- prefix if not already present (handles edge cases)
const cliModel = bareModel.startsWith('gemini-') ? bareModel : `gemini-${bareModel}`;
cliArgs.push('--model', cliModel);
}
// Disable sandbox mode for faster execution (sandbox adds overhead)
cliArgs.push('--sandbox', 'false');
// YOLO mode for automatic approval (required for non-interactive use)
// Use explicit approval-mode for clearer semantics
cliArgs.push('--approval-mode', 'yolo');
// Explicitly include the working directory in allowed workspace directories
// This ensures Gemini CLI allows file operations in the project directory,
// even if it has a different workspace cached from a previous session
if (options.cwd) {
cliArgs.push('--include-directories', options.cwd);
}
// Note: Gemini CLI doesn't have a --thinking-level flag.
// Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro).
// The model handles thinking internally based on the task complexity.
// The prompt will be passed as the last positional argument
// We'll append it in executeQuery after extracting the text
return cliArgs;
}
/**
* Convert Gemini event to AutoMaker ProviderMessage format
*/
normalizeEvent(event: unknown): ProviderMessage | null {
const geminiEvent = event as GeminiStreamEvent;
switch (geminiEvent.type) {
case 'init': {
// Init event - capture session but don't yield a message
const initEvent = geminiEvent as GeminiInitEvent;
logger.debug(
`Gemini init event: session=${initEvent.session_id}, model=${initEvent.model}`
);
return null;
}
case 'message': {
const messageEvent = geminiEvent as GeminiMessageEvent;
// Skip user messages - already handled by caller
if (messageEvent.role === 'user') {
return null;
}
// Handle assistant messages
if (messageEvent.role === 'assistant') {
return {
type: 'assistant',
session_id: messageEvent.session_id,
message: {
role: 'assistant',
content: [{ type: 'text', text: messageEvent.content }],
},
};
}
return null;
}
case 'tool_use': {
const toolEvent = geminiEvent as GeminiToolUseEvent;
const normalizedName = normalizeGeminiToolName(toolEvent.tool_name);
const normalizedInput = normalizeGeminiToolInput(
toolEvent.tool_name,
toolEvent.parameters as Record<string, unknown>
);
return {
type: 'assistant',
session_id: toolEvent.session_id,
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
name: normalizedName,
tool_use_id: toolEvent.tool_id,
input: normalizedInput,
},
],
},
};
}
case 'tool_result': {
const toolResultEvent = geminiEvent as GeminiToolResultEvent;
// If tool result is an error, prefix with error indicator
const content =
toolResultEvent.status === 'error'
? `[ERROR] ${toolResultEvent.output}`
: toolResultEvent.output;
return {
type: 'assistant',
session_id: toolResultEvent.session_id,
message: {
role: 'assistant',
content: [
{
type: 'tool_result',
tool_use_id: toolResultEvent.tool_id,
content,
},
],
},
};
}
case 'result': {
const resultEvent = geminiEvent as GeminiResultEvent;
if (resultEvent.status === 'error') {
return {
type: 'error',
session_id: resultEvent.session_id,
error: resultEvent.error || 'Unknown error',
};
}
// Success result - include stats for logging
logger.debug(
`Gemini result: status=${resultEvent.status}, tokens=${resultEvent.stats?.total_tokens}`
);
return {
type: 'result',
subtype: 'success',
session_id: resultEvent.session_id,
};
}
case 'error': {
const errorEvent = geminiEvent as GeminiResultEvent;
return {
type: 'error',
session_id: errorEvent.session_id,
error: errorEvent.error || 'Unknown error',
};
}
default:
logger.debug(`Unknown Gemini event type: ${geminiEvent.type}`);
return null;
}
}
// ==========================================================================
// CliProvider Overrides
// ==========================================================================
/**
* Override error mapping for Gemini-specific error codes
*/
protected mapError(stderr: string, exitCode: number | null): CliErrorInfo {
const lower = stderr.toLowerCase();
if (
lower.includes('not authenticated') ||
lower.includes('please log in') ||
lower.includes('unauthorized') ||
lower.includes('login required') ||
lower.includes('error authenticating') ||
lower.includes('loadcodeassist') ||
(lower.includes('econnrefused') && lower.includes('8888'))
) {
return {
code: GeminiErrorCode.NOT_AUTHENTICATED,
message: 'Gemini CLI is not authenticated',
recoverable: true,
suggestion:
'Run "gemini" interactively to log in, or set GEMINI_API_KEY environment variable',
};
}
if (
lower.includes('rate limit') ||
lower.includes('too many requests') ||
lower.includes('429') ||
lower.includes('quota exceeded')
) {
return {
code: GeminiErrorCode.RATE_LIMITED,
message: 'Gemini API rate limit exceeded',
recoverable: true,
suggestion: 'Wait a few minutes and try again. Free tier: 60 req/min, 1000 req/day',
};
}
if (
lower.includes('model not available') ||
lower.includes('invalid model') ||
lower.includes('unknown model') ||
lower.includes('modelnotfounderror') ||
lower.includes('model not found') ||
(lower.includes('not found') && lower.includes('404'))
) {
return {
code: GeminiErrorCode.MODEL_UNAVAILABLE,
message: 'Requested model is not available',
recoverable: true,
suggestion: 'Try using "gemini-2.5-flash" or select a different model',
};
}
if (
lower.includes('network') ||
lower.includes('connection') ||
lower.includes('econnrefused') ||
lower.includes('timeout')
) {
return {
code: GeminiErrorCode.NETWORK_ERROR,
message: 'Network connection error',
recoverable: true,
suggestion: 'Check your internet connection and try again',
};
}
if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
return {
code: GeminiErrorCode.PROCESS_CRASHED,
message: 'Gemini CLI process was terminated',
recoverable: true,
suggestion: 'The process may have run out of memory. Try a simpler task.',
};
}
return {
code: GeminiErrorCode.UNKNOWN,
message: stderr || `Gemini CLI exited with code ${exitCode}`,
recoverable: false,
};
}
/**
* Override install instructions for Gemini-specific guidance
*/
protected getInstallInstructions(): string {
return 'Install with: npm install -g @google/gemini-cli (or visit https://github.com/google-gemini/gemini-cli)';
}
/**
* Execute a prompt using Gemini CLI with streaming
*/
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
this.ensureCliDetected();
// Validate that model doesn't have a provider prefix
validateBareModelId(options.model, 'GeminiProvider');
if (!this.cliPath) {
throw this.createError(
GeminiErrorCode.NOT_INSTALLED,
'Gemini CLI is not installed',
true,
this.getInstallInstructions()
);
}
// Extract prompt text to pass as positional argument
const promptText = this.extractPromptText(options);
// Build CLI args and append the prompt as the last positional argument
const cliArgs = this.buildCliArgs(options);
cliArgs.push(promptText); // Gemini CLI uses positional args for the prompt
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
let sessionId: string | undefined;
logger.debug(`GeminiProvider.executeQuery called with model: "${options.model}"`);
try {
for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
const event = rawEvent as GeminiStreamEvent;
// Capture session ID from init event
if (event.type === 'init') {
const initEvent = event as GeminiInitEvent;
sessionId = initEvent.session_id;
logger.debug(`Session started: ${sessionId}, model: ${initEvent.model}`);
}
// Normalize and yield the event
const normalized = this.normalizeEvent(event);
if (normalized) {
if (!normalized.session_id && sessionId) {
normalized.session_id = sessionId;
}
yield normalized;
}
}
} catch (error) {
if (isAbortError(error)) {
logger.debug('Query aborted');
return;
}
// Map CLI errors to GeminiError
if (error instanceof Error && 'stderr' in error) {
const errorInfo = this.mapError(
(error as { stderr?: string }).stderr || error.message,
(error as { exitCode?: number | null }).exitCode ?? null
);
throw this.createError(
errorInfo.code as GeminiErrorCode,
errorInfo.message,
errorInfo.recoverable,
errorInfo.suggestion
);
}
throw error;
}
}
// ==========================================================================
// Gemini-Specific Methods
// ==========================================================================
/**
* Create a GeminiError with details
*/
private createError(
code: GeminiErrorCode,
message: string,
recoverable: boolean = false,
suggestion?: string
): GeminiError {
const error = new Error(message) as GeminiError;
error.code = code;
error.recoverable = recoverable;
error.suggestion = suggestion;
error.name = 'GeminiError';
return error;
}
/**
* Get Gemini CLI version
*/
async getVersion(): Promise<string | null> {
this.ensureCliDetected();
if (!this.cliPath) return null;
try {
const result = execSync(`"${this.cliPath}" --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
}).trim();
return result;
} catch {
return null;
}
}
/**
* Check authentication status
*
* Uses a fast credential check approach:
* 1. Check for GEMINI_API_KEY environment variable
* 2. Check for Google Cloud credentials
* 3. Check for Gemini settings file with stored credentials
* 4. Quick CLI auth test with --help (fast, doesn't make API calls)
*/
async checkAuth(): Promise<GeminiAuthStatus> {
this.ensureCliDetected();
if (!this.cliPath) {
logger.debug('checkAuth: CLI not found');
return { authenticated: false, method: 'none' };
}
logger.debug('checkAuth: Starting credential check');
// Determine the likely auth method based on environment
const hasApiKey = !!process.env.GEMINI_API_KEY;
const hasEnvApiKey = hasApiKey;
const hasVertexAi = !!(
process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.GOOGLE_CLOUD_PROJECT
);
logger.debug(`checkAuth: hasApiKey=${hasApiKey}, hasVertexAi=${hasVertexAi}`);
// Check for Gemini credentials file (~/.gemini/settings.json)
const geminiConfigDir = path.join(os.homedir(), '.gemini');
const settingsPath = path.join(geminiConfigDir, 'settings.json');
let hasCredentialsFile = false;
let authType: string | null = null;
try {
await fs.access(settingsPath);
logger.debug(`checkAuth: Found settings file at ${settingsPath}`);
try {
const content = await fs.readFile(settingsPath, 'utf8');
const settings = JSON.parse(content);
// Auth config is at security.auth.selectedType (e.g., "oauth-personal", "oauth-adc", "api-key")
const selectedType = settings?.security?.auth?.selectedType;
if (selectedType) {
hasCredentialsFile = true;
authType = selectedType;
logger.debug(`checkAuth: Settings file has auth config, selectedType=${selectedType}`);
} else {
logger.debug(`checkAuth: Settings file found but no auth type configured`);
}
} catch (e) {
logger.debug(`checkAuth: Failed to parse settings file: ${e}`);
}
} catch {
logger.debug('checkAuth: No settings file found');
}
// If we have an API key, we're authenticated
if (hasApiKey) {
logger.debug('checkAuth: Using API key authentication');
return {
authenticated: true,
method: 'api_key',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
// If we have Vertex AI credentials, we're authenticated
if (hasVertexAi) {
logger.debug('checkAuth: Using Vertex AI authentication');
return {
authenticated: true,
method: 'vertex_ai',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
// Check if settings file indicates configured authentication
if (hasCredentialsFile && authType) {
// OAuth types: "oauth-personal", "oauth-adc"
// API key type: "api-key"
// Code assist: "code-assist" (requires IDE integration)
if (authType.startsWith('oauth')) {
logger.debug(`checkAuth: OAuth authentication configured (${authType})`);
return {
authenticated: true,
method: 'google_login',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
if (authType === 'api-key') {
logger.debug('checkAuth: API key authentication configured in settings');
return {
authenticated: true,
method: 'api_key',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
if (authType === 'code-assist' || authType === 'codeassist') {
logger.debug('checkAuth: Code Assist auth configured but requires local server');
return {
authenticated: false,
method: 'google_login',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
error:
'Code Assist authentication requires IDE integration. Please use "gemini" CLI to log in with a different method, or set GEMINI_API_KEY.',
};
}
// Unknown auth type but something is configured
logger.debug(`checkAuth: Unknown auth type configured: ${authType}`);
return {
authenticated: true,
method: 'google_login',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
// No credentials found
logger.debug('checkAuth: No valid credentials found');
return {
authenticated: false,
method: 'none',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
error:
'No authentication configured. Run "gemini" interactively to log in, or set GEMINI_API_KEY.',
};
}
/**
* Detect installation status (required by BaseProvider)
*/
async detectInstallation(): Promise<InstallationStatus> {
const installed = await this.isInstalled();
const version = installed ? await this.getVersion() : undefined;
const auth = await this.checkAuth();
return {
installed,
version: version || undefined,
path: this.cliPath || undefined,
method: 'cli',
hasApiKey: !!process.env.GEMINI_API_KEY,
authenticated: auth.authenticated,
};
}
/**
* Get the detected CLI path (public accessor for status endpoints)
*/
getCliPath(): string | null {
this.ensureCliDetected();
return this.cliPath;
}
/**
* Get available Gemini models
*/
getAvailableModels(): ModelDefinition[] {
return Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => ({
id, // Full model ID with gemini- prefix (e.g., 'gemini-2.5-flash')
name: config.label,
modelString: id, // Same as id - CLI uses the full model name
provider: 'gemini',
description: config.description,
supportsTools: true,
supportsVision: config.supportsVision,
contextWindow: config.contextWindow,
}));
}
/**
* Check if a feature is supported
*/
supportsFeature(feature: string): boolean {
const supported = ['tools', 'text', 'streaming', 'vision', 'thinking'];
return supported.includes(feature);
}
}

View File

@@ -16,6 +16,16 @@ export type {
ProviderMessage,
InstallationStatus,
ModelDefinition,
AgentDefinition,
ReasoningEffort,
SystemPromptPreset,
ConversationMessage,
ContentBlock,
ValidationResult,
McpServerConfig,
McpStdioServerConfig,
McpSSEServerConfig,
McpHttpServerConfig,
} from './types.js';
// Claude provider

View File

@@ -7,7 +7,13 @@
import { BaseProvider } from './base-provider.js';
import type { InstallationStatus, ModelDefinition } from './types.js';
import { isCursorModel, isCodexModel, isOpencodeModel, type ModelProvider } from '@automaker/types';
import {
isCursorModel,
isCodexModel,
isOpencodeModel,
isGeminiModel,
type ModelProvider,
} from '@automaker/types';
import * as fs from 'fs';
import * as path from 'path';
@@ -16,6 +22,7 @@ const DISCONNECTED_MARKERS: Record<string, string> = {
codex: '.codex-disconnected',
cursor: '.cursor-disconnected',
opencode: '.opencode-disconnected',
gemini: '.gemini-disconnected',
};
/**
@@ -239,8 +246,8 @@ export class ProviderFactory {
model.modelString === modelId ||
model.id.endsWith(`-${modelId}`) ||
model.modelString.endsWith(`-${modelId}`) ||
model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') ||
model.modelString === modelId.replace(/-(claude|cursor|codex)$/, '')
model.modelString === modelId.replace(/^(claude|cursor|codex|gemini)-/, '') ||
model.modelString === modelId.replace(/-(claude|cursor|codex|gemini)$/, '')
) {
return model.supportsVision ?? true;
}
@@ -267,6 +274,7 @@ import { ClaudeProvider } from './claude-provider.js';
import { CursorProvider } from './cursor-provider.js';
import { CodexProvider } from './codex-provider.js';
import { OpencodeProvider } from './opencode-provider.js';
import { GeminiProvider } from './gemini-provider.js';
// Register Claude provider
registerProvider('claude', {
@@ -301,3 +309,11 @@ registerProvider('opencode', {
canHandleModel: (model: string) => isOpencodeModel(model),
priority: 3, // Between codex (5) and claude (0)
});
// Register Gemini provider
registerProvider('gemini', {
factory: () => new GeminiProvider(),
aliases: ['google'],
canHandleModel: (model: string) => isGeminiModel(model),
priority: 4, // Between opencode (3) and codex (5)
});

View File

@@ -19,4 +19,7 @@ export type {
InstallationStatus,
ValidationResult,
ModelDefinition,
AgentDefinition,
ReasoningEffort,
SystemPromptPreset,
} from '@automaker/types';

View File

@@ -128,7 +128,10 @@ export async function generateBacklogPlan(
let credentials: import('@automaker/types').Credentials | undefined;
if (effectiveModel) {
// Use explicit override - just get credentials
// Use explicit override - resolve model alias and get credentials
const resolved = resolvePhaseModel({ model: effectiveModel });
effectiveModel = resolved.model;
thinkingLevel = resolved.thinkingLevel;
credentials = await settingsService?.getCredentials();
} else if (settingsService) {
// Use settings-based model with provider info

View File

@@ -16,6 +16,8 @@ import { createBulkDeleteHandler } from './routes/bulk-delete.js';
import { createDeleteHandler } from './routes/delete.js';
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
import { createGenerateTitleHandler } from './routes/generate-title.js';
import { createExportHandler } from './routes/export.js';
import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
export function createFeaturesRoutes(
featureLoader: FeatureLoader,
@@ -46,6 +48,13 @@ export function createFeaturesRoutes(
router.post('/agent-output', createAgentOutputHandler(featureLoader));
router.post('/raw-output', createRawOutputHandler(featureLoader));
router.post('/generate-title', createGenerateTitleHandler(settingsService));
router.post('/export', validatePathParams('projectPath'), createExportHandler(featureLoader));
router.post('/import', validatePathParams('projectPath'), createImportHandler(featureLoader));
router.post(
'/check-conflicts',
validatePathParams('projectPath'),
createConflictCheckHandler(featureLoader)
);
return router;
}

View File

@@ -0,0 +1,96 @@
/**
* POST /export endpoint - Export features to JSON or YAML format
*/
import type { Request, Response } from 'express';
import type { FeatureLoader } from '../../../services/feature-loader.js';
import {
getFeatureExportService,
type ExportFormat,
type BulkExportOptions,
} from '../../../services/feature-export-service.js';
import { getErrorMessage, logError } from '../common.js';
interface ExportRequest {
projectPath: string;
/** Feature IDs to export. If empty/undefined, exports all features */
featureIds?: string[];
/** Export format: 'json' or 'yaml' */
format?: ExportFormat;
/** Whether to include description history */
includeHistory?: boolean;
/** Whether to include plan spec */
includePlanSpec?: boolean;
/** Filter by category */
category?: string;
/** Filter by status */
status?: string;
/** Pretty print output */
prettyPrint?: boolean;
/** Optional metadata to include */
metadata?: {
projectName?: string;
projectPath?: string;
branch?: string;
[key: string]: unknown;
};
}
export function createExportHandler(featureLoader: FeatureLoader) {
const exportService = getFeatureExportService();
return async (req: Request, res: Response): Promise<void> => {
try {
const {
projectPath,
featureIds,
format = 'json',
includeHistory = true,
includePlanSpec = true,
category,
status,
prettyPrint = true,
metadata,
} = req.body as ExportRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
// Validate format
if (format !== 'json' && format !== 'yaml') {
res.status(400).json({
success: false,
error: 'format must be "json" or "yaml"',
});
return;
}
const options: BulkExportOptions = {
format,
includeHistory,
includePlanSpec,
category,
status,
featureIds,
prettyPrint,
metadata,
};
const exportData = await exportService.exportFeatures(projectPath, options);
// Return the export data as a string in the response
res.json({
success: true,
data: exportData,
format,
contentType: format === 'json' ? 'application/json' : 'application/x-yaml',
filename: `features-export.${format === 'json' ? 'json' : 'yaml'}`,
});
} catch (error) {
logError(error, 'Export features failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,210 @@
/**
* POST /import endpoint - Import features from JSON or YAML format
*/
import type { Request, Response } from 'express';
import type { FeatureLoader } from '../../../services/feature-loader.js';
import type { FeatureImportResult, Feature, FeatureExport } from '@automaker/types';
import { getFeatureExportService } from '../../../services/feature-export-service.js';
import { getErrorMessage, logError } from '../common.js';
interface ImportRequest {
projectPath: string;
/** Raw JSON or YAML string containing feature data */
data: string;
/** Whether to overwrite existing features with same ID */
overwrite?: boolean;
/** Whether to preserve branch info from imported features */
preserveBranchInfo?: boolean;
/** Optional category to assign to all imported features */
targetCategory?: string;
}
interface ConflictCheckRequest {
projectPath: string;
/** Raw JSON or YAML string containing feature data */
data: string;
}
interface ConflictInfo {
featureId: string;
title?: string;
existingTitle?: string;
hasConflict: boolean;
}
export function createImportHandler(featureLoader: FeatureLoader) {
const exportService = getFeatureExportService();
return async (req: Request, res: Response): Promise<void> => {
try {
const {
projectPath,
data,
overwrite = false,
preserveBranchInfo = false,
targetCategory,
} = req.body as ImportRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!data) {
res.status(400).json({ success: false, error: 'data is required' });
return;
}
// Detect format and parse the data
const format = exportService.detectFormat(data);
if (!format) {
res.status(400).json({
success: false,
error: 'Invalid data format. Expected valid JSON or YAML.',
});
return;
}
const parsed = exportService.parseImportData(data);
if (!parsed) {
res.status(400).json({
success: false,
error: 'Failed to parse import data. Ensure it is valid JSON or YAML.',
});
return;
}
// Determine if this is a single feature or bulk import
const isBulkImport =
'features' in parsed && Array.isArray((parsed as { features: unknown }).features);
let results: FeatureImportResult[];
if (isBulkImport) {
// Bulk import
results = await exportService.importFeatures(projectPath, data, {
overwrite,
preserveBranchInfo,
targetCategory,
});
} else {
// Single feature import - we know it's not a bulk export at this point
// It must be either a Feature or FeatureExport
const singleData = parsed as Feature | FeatureExport;
const result = await exportService.importFeature(projectPath, {
data: singleData,
overwrite,
preserveBranchInfo,
targetCategory,
});
results = [result];
}
const successCount = results.filter((r) => r.success).length;
const failureCount = results.filter((r) => !r.success).length;
const allSuccessful = failureCount === 0;
res.json({
success: allSuccessful,
importedCount: successCount,
failedCount: failureCount,
results,
});
} catch (error) {
logError(error, 'Import features failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Create handler for checking conflicts before import
*/
export function createConflictCheckHandler(featureLoader: FeatureLoader) {
const exportService = getFeatureExportService();
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, data } = req.body as ConflictCheckRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!data) {
res.status(400).json({ success: false, error: 'data is required' });
return;
}
// Parse the import data
const format = exportService.detectFormat(data);
if (!format) {
res.status(400).json({
success: false,
error: 'Invalid data format. Expected valid JSON or YAML.',
});
return;
}
const parsed = exportService.parseImportData(data);
if (!parsed) {
res.status(400).json({
success: false,
error: 'Failed to parse import data.',
});
return;
}
// Extract features from the data using type guards
let featuresToCheck: Array<{ id: string; title?: string }> = [];
if (exportService.isBulkExport(parsed)) {
// Bulk export format
featuresToCheck = parsed.features.map((f) => ({
id: f.feature.id,
title: f.feature.title,
}));
} else if (exportService.isFeatureExport(parsed)) {
// Single FeatureExport format
featuresToCheck = [
{
id: parsed.feature.id,
title: parsed.feature.title,
},
];
} else if (exportService.isRawFeature(parsed)) {
// Raw Feature format
featuresToCheck = [{ id: parsed.id, title: parsed.title }];
}
// Check each feature for conflicts in parallel
const conflicts: ConflictInfo[] = await Promise.all(
featuresToCheck.map(async (feature) => {
const existing = await featureLoader.get(projectPath, feature.id);
return {
featureId: feature.id,
title: feature.title,
existingTitle: existing?.title,
hasConflict: !!existing,
};
})
);
const hasConflicts = conflicts.some((c) => c.hasConflict);
res.json({
success: true,
hasConflicts,
conflicts,
totalFeatures: featuresToCheck.length,
conflictCount: conflicts.filter((c) => c.hasConflict).length,
});
} catch (error) {
logError(error, 'Conflict check failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,143 @@
/**
* Provider Usage Routes
*
* API endpoints for fetching usage data from all AI providers.
*
* Endpoints:
* - GET /api/provider-usage - Get usage for all enabled providers
* - GET /api/provider-usage/:providerId - Get usage for a specific provider
* - GET /api/provider-usage/availability - Check availability of all providers
*/
import { Router, Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import type { UsageProviderId } from '@automaker/types';
import { ProviderUsageTracker } from '../../services/provider-usage-tracker.js';
const logger = createLogger('ProviderUsageRoutes');
// Valid provider IDs
const VALID_PROVIDER_IDS: UsageProviderId[] = [
'claude',
'codex',
'cursor',
'gemini',
'copilot',
'opencode',
'minimax',
'glm',
];
export function createProviderUsageRoutes(tracker: ProviderUsageTracker): Router {
const router = Router();
/**
* GET /api/provider-usage
* Fetch usage for all enabled providers
*/
router.get('/', async (req: Request, res: Response) => {
try {
const forceRefresh = req.query.refresh === 'true';
const usage = await tracker.fetchAllUsage(forceRefresh);
res.json({
success: true,
data: usage,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error fetching all provider usage:', error);
res.status(500).json({
success: false,
error: message,
});
}
});
/**
* GET /api/provider-usage/availability
* Check which providers are available
*/
router.get('/availability', async (_req: Request, res: Response) => {
try {
const availability = await tracker.checkAvailability();
res.json({
success: true,
data: availability,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error checking provider availability:', error);
res.status(500).json({
success: false,
error: message,
});
}
});
/**
* GET /api/provider-usage/:providerId
* Fetch usage for a specific provider
*/
router.get('/:providerId', async (req: Request, res: Response) => {
try {
const providerId = req.params.providerId as UsageProviderId;
// Validate provider ID
if (!VALID_PROVIDER_IDS.includes(providerId)) {
res.status(400).json({
success: false,
error: `Invalid provider ID: ${providerId}. Valid providers: ${VALID_PROVIDER_IDS.join(', ')}`,
});
return;
}
// Check if provider is enabled
if (!tracker.isProviderEnabled(providerId)) {
res.status(200).json({
success: true,
data: {
providerId,
providerName: providerId,
available: false,
lastUpdated: new Date().toISOString(),
error: 'Provider is disabled',
},
});
return;
}
const forceRefresh = req.query.refresh === 'true';
const usage = await tracker.fetchProviderUsage(providerId, forceRefresh);
if (!usage) {
res.status(200).json({
success: true,
data: {
providerId,
providerName: providerId,
available: false,
lastUpdated: new Date().toISOString(),
error: 'Failed to fetch usage data',
},
});
return;
}
res.json({
success: true,
data: usage,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error(`Error fetching usage for ${req.params.providerId}:`, error);
// Return 200 with error in data to avoid triggering logout
res.status(200).json({
success: false,
error: message,
});
}
});
return router;
}

View File

@@ -24,6 +24,9 @@ import { createDeauthCursorHandler } from './routes/deauth-cursor.js';
import { createAuthOpencodeHandler } from './routes/auth-opencode.js';
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
import { createGeminiStatusHandler } from './routes/gemini-status.js';
import { createAuthGeminiHandler } from './routes/auth-gemini.js';
import { createDeauthGeminiHandler } from './routes/deauth-gemini.js';
import {
createGetOpencodeModelsHandler,
createRefreshOpencodeModelsHandler,
@@ -72,6 +75,11 @@ export function createSetupRoutes(): Router {
router.post('/auth-opencode', createAuthOpencodeHandler());
router.post('/deauth-opencode', createDeauthOpencodeHandler());
// Gemini CLI routes
router.get('/gemini-status', createGeminiStatusHandler());
router.post('/auth-gemini', createAuthGeminiHandler());
router.post('/deauth-gemini', createDeauthGeminiHandler());
// OpenCode Dynamic Model Discovery routes
router.get('/opencode/models', createGetOpencodeModelsHandler());
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());

View File

@@ -0,0 +1,42 @@
/**
* POST /auth-gemini endpoint - Connect Gemini CLI to the app
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs/promises';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
/**
* Creates handler for POST /api/setup/auth-gemini
* Removes the disconnection marker to allow Gemini CLI to be used
*/
export function createAuthGeminiHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const projectRoot = process.cwd();
const automakerDir = path.join(projectRoot, '.automaker');
const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE);
// Remove the disconnection marker if it exists
try {
await fs.unlink(markerPath);
} catch {
// File doesn't exist, nothing to remove
}
res.json({
success: true,
message: 'Gemini CLI connected to app',
});
} catch (error) {
logError(error, 'Auth Gemini failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,42 @@
/**
* POST /deauth-gemini endpoint - Disconnect Gemini CLI from the app
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs/promises';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
/**
* Creates handler for POST /api/setup/deauth-gemini
* Creates a marker file to disconnect Gemini CLI from the app
*/
export function createDeauthGeminiHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const projectRoot = process.cwd();
const automakerDir = path.join(projectRoot, '.automaker');
// Ensure .automaker directory exists
await fs.mkdir(automakerDir, { recursive: true });
const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE);
// Create the disconnection marker
await fs.writeFile(markerPath, 'Gemini CLI disconnected from app');
res.json({
success: true,
message: 'Gemini CLI disconnected from app',
});
} catch (error) {
logError(error, 'Deauth Gemini failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,79 @@
/**
* GET /gemini-status endpoint - Get Gemini CLI installation and auth status
*/
import type { Request, Response } from 'express';
import { GeminiProvider } from '../../../providers/gemini-provider.js';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs/promises';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
async function isGeminiDisconnectedFromApp(): Promise<boolean> {
try {
const projectRoot = process.cwd();
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
await fs.access(markerPath);
return true;
} catch {
return false;
}
}
/**
* Creates handler for GET /api/setup/gemini-status
* Returns Gemini CLI installation and authentication status
*/
export function createGeminiStatusHandler() {
const installCommand = 'npm install -g @google/gemini-cli';
const loginCommand = 'gemini';
return async (_req: Request, res: Response): Promise<void> => {
try {
// Check if user has manually disconnected from the app
if (await isGeminiDisconnectedFromApp()) {
res.json({
success: true,
installed: true,
version: null,
path: null,
auth: {
authenticated: false,
method: 'none',
hasApiKey: false,
},
installCommand,
loginCommand,
});
return;
}
const provider = new GeminiProvider();
const status = await provider.detectInstallation();
const auth = await provider.checkAuth();
res.json({
success: true,
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: auth.authenticated,
method: auth.method,
hasApiKey: auth.hasApiKey || false,
hasEnvApiKey: auth.hasEnvApiKey || false,
error: auth.error,
},
installCommand,
loginCommand,
});
} catch (error) {
logError(error, 'Get Gemini status failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,34 +0,0 @@
/**
* Common utilities and state for suggestions routes
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
const logger = createLogger('Suggestions');
// Shared state for tracking generation status - private
let isRunning = false;
let currentAbortController: AbortController | null = null;
/**
* Get the current running state
*/
export function getSuggestionsStatus(): {
isRunning: boolean;
currentAbortController: AbortController | null;
} {
return { isRunning, currentAbortController };
}
/**
* Set the running state and abort controller
*/
export function setRunningState(running: boolean, controller: AbortController | null = null): void {
isRunning = running;
currentAbortController = controller;
}
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger);

View File

@@ -1,335 +0,0 @@
/**
* Business logic for generating suggestions
*
* Model is configurable via phaseModels.suggestionsModel in settings
* (AI Suggestions in the UI). Supports both Claude and Cursor models.
*/
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { streamingQuery } from '../../providers/simple-query-service.js';
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,
getPhaseModelWithOverrides,
getProviderByModelId,
} from '../../lib/settings-helpers.js';
const logger = createLogger('Suggestions');
/**
* Extract implemented features from app_spec.txt XML content
*
* Note: This uses regex-based parsing which is sufficient for our controlled
* XML structure. If more complex XML parsing is needed in the future, consider
* using a library like 'fast-xml-parser' or 'xml2js'.
*/
function extractImplementedFeatures(specContent: string): string[] {
const features: string[] = [];
// Match <implemented_features>...</implemented_features> section
const implementedMatch = specContent.match(
/<implemented_features>([\s\S]*?)<\/implemented_features>/
);
if (implementedMatch) {
const implementedSection = implementedMatch[1];
// Extract feature names from <name>...</name> tags using matchAll
const nameRegex = /<name>(.*?)<\/name>/g;
const matches = implementedSection.matchAll(nameRegex);
for (const match of matches) {
features.push(match[1].trim());
}
}
return features;
}
/**
* Load existing context (app spec and backlog features) to avoid duplicates
*/
async function loadExistingContext(projectPath: string): Promise<string> {
let context = '';
// 1. Read app_spec.txt for implemented features
try {
const appSpecPath = getAppSpecPath(projectPath);
const specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
if (specContent && specContent.trim().length > 0) {
const implementedFeatures = extractImplementedFeatures(specContent);
if (implementedFeatures.length > 0) {
context += '\n\n=== ALREADY IMPLEMENTED FEATURES ===\n';
context += 'These features are already implemented in the codebase:\n';
context += implementedFeatures.map((feature) => `- ${feature}`).join('\n') + '\n';
}
}
} catch (error) {
// app_spec.txt doesn't exist or can't be read - that's okay
logger.debug('No app_spec.txt found or error reading it:', error);
}
// 2. Load existing features from backlog
try {
const featureLoader = new FeatureLoader();
const features = await featureLoader.getAll(projectPath);
if (features.length > 0) {
context += '\n\n=== EXISTING FEATURES IN BACKLOG ===\n';
context += 'These features are already planned or in progress:\n';
context +=
features
.map((feature) => {
const status = feature.status || 'pending';
const title = feature.title || feature.description?.substring(0, 50) || 'Untitled';
return `- ${title} (${status})`;
})
.join('\n') + '\n';
}
} catch (error) {
// Features directory doesn't exist or can't be read - that's okay
logger.debug('No features found or error loading them:', error);
}
return context;
}
/**
* JSON Schema for suggestions output
*/
const suggestionsSchema = {
type: 'object',
properties: {
suggestions: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
category: { type: 'string' },
description: { type: 'string' },
priority: {
type: 'number',
minimum: 1,
maximum: 3,
},
reasoning: { type: 'string' },
},
required: ['category', 'description', 'priority', 'reasoning'],
},
},
},
required: ['suggestions'],
additionalProperties: false,
};
export async function generateSuggestions(
projectPath: string,
suggestionType: string,
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService,
modelOverride?: string,
thinkingLevelOverride?: ThinkingLevel
): Promise<void> {
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[Suggestions]');
// Map suggestion types to their prompts
const typePrompts: Record<string, string> = {
features: prompts.suggestions.featuresPrompt,
refactoring: prompts.suggestions.refactoringPrompt,
security: prompts.suggestions.securityPrompt,
performance: prompts.suggestions.performancePrompt,
};
// Load existing context to avoid duplicates
const existingContext = await loadExistingContext(projectPath);
const prompt = `${typePrompts[suggestionType] || typePrompts.features}
${existingContext}
${existingContext ? '\nIMPORTANT: Do NOT suggest features that are already implemented or already in the backlog above. Focus on NEW ideas that complement what already exists.\n' : ''}
${prompts.suggestions.baseTemplate}`;
// Don't send initial message - let the agent output speak for itself
// The first agent message will be captured as an info entry
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[Suggestions]'
);
// Get model from phase settings with provider info (AI Suggestions = suggestionsModel)
// Use override if provided, otherwise fall back to settings
let model: string;
let thinkingLevel: ThinkingLevel | undefined;
let provider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
let credentials: import('@automaker/types').Credentials | undefined;
if (modelOverride) {
// Use explicit override - resolve the model string
const resolved = resolvePhaseModel({
model: modelOverride,
thinkingLevel: thinkingLevelOverride,
});
model = resolved.model;
thinkingLevel = resolved.thinkingLevel;
// Try to find a provider for this model (e.g., GLM, MiniMax models)
if (settingsService) {
const providerResult = await getProviderByModelId(
modelOverride,
settingsService,
'[Suggestions]'
);
provider = providerResult.provider;
// Use resolved model from provider if available (maps to Claude model)
if (providerResult.resolvedModel) {
model = providerResult.resolvedModel;
}
credentials = providerResult.credentials ?? (await settingsService.getCredentials());
}
// If no settingsService, credentials remains undefined (initialized above)
} else if (settingsService) {
// Use settings-based model with provider info
const phaseResult = await getPhaseModelWithOverrides(
'suggestionsModel',
settingsService,
projectPath,
'[Suggestions]'
);
const resolved = resolvePhaseModel(phaseResult.phaseModel);
model = resolved.model;
thinkingLevel = resolved.thinkingLevel;
provider = phaseResult.provider;
credentials = phaseResult.credentials;
} else {
// Fallback to defaults
const resolved = resolvePhaseModel(DEFAULT_PHASE_MODELS.suggestionsModel);
model = resolved.model;
thinkingLevel = resolved.thinkingLevel;
}
logger.info(
'[Suggestions] Using model:',
model,
provider ? `via provider: ${provider.name}` : 'direct API'
);
let responseText = '';
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
const useStructuredOutput = !isCursorModel(model);
// Build the final prompt - for Cursor, include JSON schema instructions
let finalPrompt = prompt;
if (!useStructuredOutput) {
finalPrompt = `${prompt}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. Return the JSON in your response only.
2. After analyzing the project, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
3. The JSON must match this exact schema:
${JSON.stringify(suggestionsSchema, null, 2)}
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
}
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt: finalPrompt,
model,
cwd: projectPath,
maxTurns: 250,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
thinkingLevel,
readOnly: true, // Suggestions only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',
schema: suggestionsSchema,
}
: undefined,
onText: (text) => {
responseText += text;
events.emit('suggestions:event', {
type: 'suggestions_progress',
content: text,
});
},
onToolUse: (tool, input) => {
events.emit('suggestions:event', {
type: 'suggestions_tool',
tool,
input,
});
},
});
// Use structured output if available, otherwise fall back to parsing text
try {
let structuredOutput: { suggestions: Array<Record<string, unknown>> } | null = null;
if (result.structured_output) {
structuredOutput = result.structured_output as {
suggestions: Array<Record<string, unknown>>;
};
logger.debug('Received structured output:', structuredOutput);
} else if (responseText) {
// Fallback: try to parse from text using shared extraction utility
logger.warn('No structured output received, attempting to parse from text');
structuredOutput = extractJsonWithArray<{ suggestions: Array<Record<string, unknown>> }>(
responseText,
'suggestions',
{ logger }
);
}
if (structuredOutput && structuredOutput.suggestions) {
// Use structured output directly
events.emit('suggestions:event', {
type: 'suggestions_complete',
suggestions: structuredOutput.suggestions.map((s: Record<string, unknown>, i: number) => ({
...s,
id: s.id || `suggestion-${Date.now()}-${i}`,
})),
});
} else {
throw new Error('No valid JSON found in response');
}
} catch (error) {
// Log the parsing error for debugging
logger.error('Failed to parse suggestions JSON from AI response:', error);
// Return generic suggestions if parsing fails
events.emit('suggestions:event', {
type: 'suggestions_complete',
suggestions: [
{
id: `suggestion-${Date.now()}-0`,
category: 'Analysis',
description: 'Review the AI analysis output for insights',
priority: 1,
reasoning: 'The AI provided analysis but suggestions need manual review',
},
],
});
}
}

View File

@@ -1,28 +0,0 @@
/**
* Suggestions routes - HTTP API for AI-powered feature suggestions
*/
import { Router } from 'express';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createGenerateHandler } from './routes/generate.js';
import { createStopHandler } from './routes/stop.js';
import { createStatusHandler } from './routes/status.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createSuggestionsRoutes(
events: EventEmitter,
settingsService?: SettingsService
): Router {
const router = Router();
router.post(
'/generate',
validatePathParams('projectPath'),
createGenerateHandler(events, settingsService)
);
router.post('/stop', createStopHandler());
router.get('/status', createStatusHandler());
return router;
}

View File

@@ -1,75 +0,0 @@
/**
* POST /generate endpoint - Generate suggestions
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { createLogger } from '@automaker/utils';
import type { ThinkingLevel } from '@automaker/types';
import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js';
import { generateSuggestions } from '../generate-suggestions.js';
import type { SettingsService } from '../../../services/settings-service.js';
const logger = createLogger('Suggestions');
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const {
projectPath,
suggestionType = 'features',
model,
thinkingLevel,
} = req.body as {
projectPath: string;
suggestionType?: string;
model?: string;
thinkingLevel?: ThinkingLevel;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
const { isRunning } = getSuggestionsStatus();
if (isRunning) {
res.json({
success: false,
error: 'Suggestions generation is already running',
});
return;
}
setRunningState(true);
const abortController = new AbortController();
setRunningState(true, abortController);
// Start generation in background
generateSuggestions(
projectPath,
suggestionType,
events,
abortController,
settingsService,
model,
thinkingLevel
)
.catch((error) => {
logError(error, 'Generate suggestions failed (background)');
events.emit('suggestions:event', {
type: 'suggestions_error',
error: getErrorMessage(error),
});
})
.finally(() => {
setRunningState(false, null);
});
res.json({ success: true });
} catch (error) {
logError(error, 'Generate suggestions failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,18 +0,0 @@
/**
* GET /status endpoint - Get status
*/
import type { Request, Response } from 'express';
import { getSuggestionsStatus, getErrorMessage, logError } from '../common.js';
export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const { isRunning } = getSuggestionsStatus();
res.json({ success: true, isRunning });
} catch (error) {
logError(error, 'Get status failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,22 +0,0 @@
/**
* POST /stop endpoint - Stop suggestions generation
*/
import type { Request, Response } from 'express';
import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js';
export function createStopHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const { currentAbortController } = getSuggestionsStatus();
if (currentAbortController) {
currentAbortController.abort();
}
setRunningState(false, null);
res.json({ success: true });
} catch (error) {
logError(error, 'Stop suggestions failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -42,6 +42,9 @@ import { createStartDevHandler } from './routes/start-dev.js';
import { createStopDevHandler } from './routes/stop-dev.js';
import { createListDevServersHandler } from './routes/list-dev-servers.js';
import { createGetDevServerLogsHandler } from './routes/dev-server-logs.js';
import { createStartTestsHandler } from './routes/start-tests.js';
import { createStopTestsHandler } from './routes/stop-tests.js';
import { createGetTestLogsHandler } from './routes/test-logs.js';
import {
createGetInitScriptHandler,
createPutInitScriptHandler,
@@ -50,6 +53,7 @@ import {
} from './routes/init-script.js';
import { createDiscardChangesHandler } from './routes/discard-changes.js';
import { createListRemotesHandler } from './routes/list-remotes.js';
import { createAddRemoteHandler } from './routes/add-remote.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes(
@@ -130,7 +134,7 @@ export function createWorktreeRoutes(
router.post(
'/start-dev',
validatePathParams('projectPath', 'worktreePath'),
createStartDevHandler()
createStartDevHandler(settingsService)
);
router.post('/stop-dev', createStopDevHandler());
router.post('/list-dev-servers', createListDevServersHandler());
@@ -140,6 +144,15 @@ export function createWorktreeRoutes(
createGetDevServerLogsHandler()
);
// Test runner routes
router.post(
'/start-tests',
validatePathParams('worktreePath', 'projectPath?'),
createStartTestsHandler(settingsService)
);
router.post('/stop-tests', createStopTestsHandler());
router.get('/test-logs', validatePathParams('worktreePath?'), createGetTestLogsHandler());
// Init script routes
router.get('/init-script', createGetInitScriptHandler());
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
@@ -166,5 +179,13 @@ export function createWorktreeRoutes(
createListRemotesHandler()
);
// Add remote route
router.post(
'/add-remote',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createAddRemoteHandler()
);
return router;
}

View File

@@ -0,0 +1,166 @@
/**
* POST /add-remote endpoint - Add a new remote to a git repository
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logWorktreeError } from '../common.js';
const execFileAsync = promisify(execFile);
/** Maximum allowed length for remote names */
const MAX_REMOTE_NAME_LENGTH = 250;
/** Maximum allowed length for remote URLs */
const MAX_REMOTE_URL_LENGTH = 2048;
/** Timeout for git fetch operations (30 seconds) */
const FETCH_TIMEOUT_MS = 30000;
/**
* Validate remote name - must be alphanumeric with dashes/underscores
* Git remote names have similar restrictions to branch names
*/
function isValidRemoteName(name: string): boolean {
// Remote names should be alphanumeric, may contain dashes, underscores, periods
// Cannot start with a dash or period, cannot be empty
if (!name || name.length === 0 || name.length > MAX_REMOTE_NAME_LENGTH) {
return false;
}
return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
}
/**
* Validate remote URL - basic validation for git remote URLs
* Supports HTTPS, SSH, and git:// protocols
*/
function isValidRemoteUrl(url: string): boolean {
if (!url || url.length === 0 || url.length > MAX_REMOTE_URL_LENGTH) {
return false;
}
// Support common git URL formats:
// - https://github.com/user/repo.git
// - git@github.com:user/repo.git
// - git://github.com/user/repo.git
// - ssh://git@github.com/user/repo.git
const httpsPattern = /^https?:\/\/.+/;
const sshPattern = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+:.+/;
const gitProtocolPattern = /^git:\/\/.+/;
const sshProtocolPattern = /^ssh:\/\/.+/;
return (
httpsPattern.test(url) ||
sshPattern.test(url) ||
gitProtocolPattern.test(url) ||
sshProtocolPattern.test(url)
);
}
export function createAddRemoteHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, remoteName, remoteUrl } = req.body as {
worktreePath: string;
remoteName: string;
remoteUrl: string;
};
// Validate required fields
const requiredFields = { worktreePath, remoteName, remoteUrl };
for (const [key, value] of Object.entries(requiredFields)) {
if (!value) {
res.status(400).json({ success: false, error: `${key} required` });
return;
}
}
// Validate remote name
if (!isValidRemoteName(remoteName)) {
res.status(400).json({
success: false,
error:
'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.',
});
return;
}
// Validate remote URL
if (!isValidRemoteUrl(remoteUrl)) {
res.status(400).json({
success: false,
error: 'Invalid remote URL. Must be a valid git URL (HTTPS, SSH, or git:// protocol).',
});
return;
}
// Check if remote already exists
try {
const { stdout: existingRemotes } = await execFileAsync('git', ['remote'], {
cwd: worktreePath,
});
const remoteNames = existingRemotes
.trim()
.split('\n')
.filter((r) => r.trim());
if (remoteNames.includes(remoteName)) {
res.status(400).json({
success: false,
error: `Remote '${remoteName}' already exists`,
code: 'REMOTE_EXISTS',
});
return;
}
} catch (error) {
// If git remote fails, continue with adding the remote. Log for debugging.
logWorktreeError(
error,
'Checking for existing remotes failed, proceeding to add.',
worktreePath
);
}
// Add the remote using execFile with array arguments to prevent command injection
await execFileAsync('git', ['remote', 'add', remoteName, remoteUrl], {
cwd: worktreePath,
});
// Optionally fetch from the new remote to get its branches
let fetchSucceeded = false;
try {
await execFileAsync('git', ['fetch', remoteName, '--quiet'], {
cwd: worktreePath,
timeout: FETCH_TIMEOUT_MS,
});
fetchSucceeded = true;
} catch (fetchError) {
// Fetch failed (maybe offline or invalid URL), but remote was added successfully
logWorktreeError(
fetchError,
`Fetch from new remote '${remoteName}' failed (remote added successfully)`,
worktreePath
);
fetchSucceeded = false;
}
res.json({
success: true,
result: {
remoteName,
remoteUrl,
fetched: fetchSucceeded,
message: fetchSucceeded
? `Successfully added remote '${remoteName}' and fetched its branches`
: `Successfully added remote '${remoteName}' (fetch failed - you may need to fetch manually)`,
},
});
} catch (error) {
const worktreePath = req.body?.worktreePath;
logWorktreeError(error, 'Add remote failed', worktreePath);
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -110,6 +110,18 @@ export function createListBranchesHandler() {
}
}
// Check if any remotes are configured for this repository
let hasAnyRemotes = false;
try {
const { stdout: remotesOutput } = await execAsync('git remote', {
cwd: worktreePath,
});
hasAnyRemotes = remotesOutput.trim().length > 0;
} catch {
// If git remote fails, assume no remotes
hasAnyRemotes = false;
}
// Get ahead/behind count for current branch and check if remote branch exists
let aheadCount = 0;
let behindCount = 0;
@@ -154,6 +166,7 @@ export function createListBranchesHandler() {
aheadCount,
behindCount,
hasRemoteBranch,
hasAnyRemotes,
},
});
} catch (error) {

View File

@@ -1,16 +1,22 @@
/**
* POST /start-dev endpoint - Start a dev server for a worktree
*
* Spins up a development server (npm run dev) in the worktree directory
* on a unique port, allowing preview of the worktree's changes without
* affecting the main dev server.
* Spins up a development server in the worktree directory on a unique port,
* allowing preview of the worktree's changes without affecting the main dev server.
*
* If a custom devCommand is configured in project settings, it will be used.
* Otherwise, auto-detection based on package manager (npm/yarn/pnpm/bun run dev) is used.
*/
import type { Request, Response } from 'express';
import type { SettingsService } from '../../../services/settings-service.js';
import { getDevServerService } from '../../../services/dev-server-service.js';
import { getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils';
export function createStartDevHandler() {
const logger = createLogger('start-dev');
export function createStartDevHandler(settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, worktreePath } = req.body as {
@@ -34,8 +40,25 @@ export function createStartDevHandler() {
return;
}
// Get custom dev command from project settings (if configured)
let customCommand: string | undefined;
if (settingsService) {
const projectSettings = await settingsService.getProjectSettings(projectPath);
const devCommand = projectSettings?.devCommand?.trim();
if (devCommand) {
customCommand = devCommand;
logger.debug(`Using custom dev command from project settings: ${customCommand}`);
} else {
logger.debug('No custom dev command configured, using auto-detection');
}
}
const devServerService = getDevServerService();
const result = await devServerService.startDevServer(projectPath, worktreePath);
const result = await devServerService.startDevServer(
projectPath,
worktreePath,
customCommand
);
if (result.success && result.result) {
res.json({

View File

@@ -0,0 +1,92 @@
/**
* POST /start-tests endpoint - Start tests for a worktree
*
* Runs the test command configured in project settings.
* If no testCommand is configured, returns an error.
*/
import type { Request, Response } from 'express';
import type { SettingsService } from '../../../services/settings-service.js';
import { getTestRunnerService } from '../../../services/test-runner-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createStartTestsHandler(settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const body = req.body;
// Validate request body
if (!body || typeof body !== 'object') {
res.status(400).json({
success: false,
error: 'Request body must be an object',
});
return;
}
const worktreePath = typeof body.worktreePath === 'string' ? body.worktreePath : undefined;
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : undefined;
const testFile = typeof body.testFile === 'string' ? body.testFile : undefined;
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath is required and must be a string',
});
return;
}
// Get project settings to find the test command
// Use projectPath if provided, otherwise use worktreePath
const settingsPath = projectPath || worktreePath;
if (!settingsService) {
res.status(500).json({
success: false,
error: 'Settings service not available',
});
return;
}
const projectSettings = await settingsService.getProjectSettings(settingsPath);
const testCommand = projectSettings?.testCommand;
if (!testCommand) {
res.status(400).json({
success: false,
error:
'No test command configured. Please configure a test command in Project Settings > Testing Configuration.',
});
return;
}
const testRunnerService = getTestRunnerService();
const result = await testRunnerService.startTests(worktreePath, {
command: testCommand,
testFile,
});
if (result.success && result.result) {
res.json({
success: true,
result: {
sessionId: result.result.sessionId,
worktreePath: result.result.worktreePath,
command: result.result.command,
status: result.result.status,
testFile: result.result.testFile,
message: result.result.message,
},
});
} else {
res.status(400).json({
success: false,
error: result.error || 'Failed to start tests',
});
}
} catch (error) {
logError(error, 'Start tests failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,58 @@
/**
* POST /stop-tests endpoint - Stop a running test session
*
* Stops the test runner process for a specific session,
* cancelling any ongoing tests and freeing up resources.
*/
import type { Request, Response } from 'express';
import { getTestRunnerService } from '../../../services/test-runner-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createStopTestsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const body = req.body;
// Validate request body
if (!body || typeof body !== 'object') {
res.status(400).json({
success: false,
error: 'Request body must be an object',
});
return;
}
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined;
if (!sessionId) {
res.status(400).json({
success: false,
error: 'sessionId is required and must be a string',
});
return;
}
const testRunnerService = getTestRunnerService();
const result = await testRunnerService.stopTests(sessionId);
if (result.success && result.result) {
res.json({
success: true,
result: {
sessionId: result.result.sessionId,
message: result.result.message,
},
});
} else {
res.status(400).json({
success: false,
error: result.error || 'Failed to stop tests',
});
}
} catch (error) {
logError(error, 'Stop tests failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,160 @@
/**
* GET /test-logs endpoint - Get buffered logs for a test runner session
*
* Returns the scrollback buffer containing historical log output for a test run.
* Used by clients to populate the log panel on initial connection
* before subscribing to real-time updates via WebSocket.
*
* Query parameters:
* - worktreePath: Path to the worktree (optional if sessionId provided)
* - sessionId: Specific test session ID (optional, uses active session if not provided)
*/
import type { Request, Response } from 'express';
import { getTestRunnerService } from '../../../services/test-runner-service.js';
import { getErrorMessage, logError } from '../common.js';
interface SessionInfo {
sessionId: string;
worktreePath?: string;
command?: string;
testFile?: string;
exitCode?: number | null;
}
interface OutputResult {
sessionId: string;
status: string;
output: string;
startedAt: string;
finishedAt?: string | null;
}
function buildLogsResponse(session: SessionInfo, output: OutputResult) {
return {
success: true,
result: {
sessionId: session.sessionId,
worktreePath: session.worktreePath,
command: session.command,
status: output.status,
testFile: session.testFile,
logs: output.output,
startedAt: output.startedAt,
finishedAt: output.finishedAt,
exitCode: session.exitCode ?? null,
},
};
}
export function createGetTestLogsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, sessionId } = req.query as {
worktreePath?: string;
sessionId?: string;
};
const testRunnerService = getTestRunnerService();
// If sessionId is provided, get logs for that specific session
if (sessionId) {
const result = testRunnerService.getSessionOutput(sessionId);
if (result.success && result.result) {
const session = testRunnerService.getSession(sessionId);
res.json(
buildLogsResponse(
{
sessionId: result.result.sessionId,
worktreePath: session?.worktreePath,
command: session?.command,
testFile: session?.testFile,
exitCode: session?.exitCode,
},
result.result
)
);
} else {
res.status(404).json({
success: false,
error: result.error || 'Failed to get test logs',
});
}
return;
}
// If worktreePath is provided, get logs for the active session
if (worktreePath) {
const activeSession = testRunnerService.getActiveSession(worktreePath);
if (activeSession) {
const result = testRunnerService.getSessionOutput(activeSession.id);
if (result.success && result.result) {
res.json(
buildLogsResponse(
{
sessionId: activeSession.id,
worktreePath: activeSession.worktreePath,
command: activeSession.command,
testFile: activeSession.testFile,
exitCode: activeSession.exitCode,
},
result.result
)
);
} else {
res.status(404).json({
success: false,
error: result.error || 'Failed to get test logs',
});
}
} else {
// No active session - check for most recent session for this worktree
const sessions = testRunnerService.listSessions(worktreePath);
if (sessions.result.sessions.length > 0) {
// Get the most recent session (list is not sorted, so find it)
const mostRecent = sessions.result.sessions.reduce((latest, current) => {
const latestTime = new Date(latest.startedAt).getTime();
const currentTime = new Date(current.startedAt).getTime();
return currentTime > latestTime ? current : latest;
});
const result = testRunnerService.getSessionOutput(mostRecent.sessionId);
if (result.success && result.result) {
res.json(
buildLogsResponse(
{
sessionId: mostRecent.sessionId,
worktreePath: mostRecent.worktreePath,
command: mostRecent.command,
testFile: mostRecent.testFile,
exitCode: mostRecent.exitCode,
},
result.result
)
);
return;
}
}
res.status(404).json({
success: false,
error: 'No test sessions found for this worktree',
});
}
return;
}
// Neither sessionId nor worktreePath provided
res.status(400).json({
success: false,
error: 'Either worktreePath or sessionId query parameter is required',
});
} catch (error) {
logError(error, 'Get test logs failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -534,7 +534,11 @@ export class AutoModeService {
const autoModeByWorktree = settings.autoModeByWorktree;
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
const key = `${projectId}::${branchName ?? '__main__'}`;
// Normalize branch name to match UI convention:
// - null or "main" -> "__main__" (UI treats "main" as the main worktree)
// This ensures consistency with how the UI stores worktree settings
const normalizedBranch = branchName === 'main' ? null : branchName;
const key = `${projectId}::${normalizedBranch ?? '__main__'}`;
const entry = autoModeByWorktree[key];
if (entry && typeof entry.maxConcurrency === 'number') {
return entry.maxConcurrency;
@@ -1039,7 +1043,9 @@ export class AutoModeService {
}> {
// Load feature to get branchName
const feature = await this.loadFeature(projectPath, featureId);
const branchName = feature?.branchName ?? null;
const rawBranchName = feature?.branchName ?? null;
// Normalize "main" to null to match UI convention for main worktree
const branchName = rawBranchName === 'main' ? null : rawBranchName;
// Get per-worktree limit
const maxAgents = await this.resolveMaxConcurrency(projectPath, branchName);
@@ -1281,7 +1287,11 @@ export class AutoModeService {
// Check for pipeline steps and execute them
const pipelineConfig = await pipelineService.getPipelineConfig(projectPath);
const sortedSteps = [...(pipelineConfig?.steps || [])].sort((a, b) => a.order - b.order);
// Filter out excluded pipeline steps and sort by order
const excludedStepIds = new Set(feature.excludedPipelineSteps || []);
const sortedSteps = [...(pipelineConfig?.steps || [])]
.sort((a, b) => a.order - b.order)
.filter((step) => !excludedStepIds.has(step.id));
if (sortedSteps.length > 0) {
// Execute pipeline steps sequentially
@@ -1743,15 +1753,76 @@ Complete the pipeline step instructions above. Review the previous work and appl
): Promise<void> {
const featureId = feature.id;
const sortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order);
// Sort all steps first
const allSortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order);
// Validate step index
if (startFromStepIndex < 0 || startFromStepIndex >= sortedSteps.length) {
// Get the current step we're resuming from (using the index from unfiltered list)
if (startFromStepIndex < 0 || startFromStepIndex >= allSortedSteps.length) {
throw new Error(`Invalid step index: ${startFromStepIndex}`);
}
const currentStep = allSortedSteps[startFromStepIndex];
// Get steps to execute (from startFromStepIndex onwards)
const stepsToExecute = sortedSteps.slice(startFromStepIndex);
// Filter out excluded pipeline steps
const excludedStepIds = new Set(feature.excludedPipelineSteps || []);
// Check if the current step is excluded
// If so, use getNextStatus to find the appropriate next step
if (excludedStepIds.has(currentStep.id)) {
console.log(
`[AutoMode] Current step ${currentStep.id} is excluded for feature ${featureId}, finding next valid step`
);
const nextStatus = pipelineService.getNextStatus(
`pipeline_${currentStep.id}`,
pipelineConfig,
feature.skipTests ?? false,
feature.excludedPipelineSteps
);
// If next status is not a pipeline step, feature is done
if (!pipelineService.isPipelineStatus(nextStatus)) {
await this.updateFeatureStatus(projectPath, featureId, nextStatus);
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true,
message: 'Pipeline completed (remaining steps excluded)',
projectPath,
});
return;
}
// Find the next step and update the start index
const nextStepId = pipelineService.getStepIdFromStatus(nextStatus);
const nextStepIndex = allSortedSteps.findIndex((s) => s.id === nextStepId);
if (nextStepIndex === -1) {
throw new Error(`Next step ${nextStepId} not found in pipeline config`);
}
startFromStepIndex = nextStepIndex;
}
// Get steps to execute (from startFromStepIndex onwards, excluding excluded steps)
const stepsToExecute = allSortedSteps
.slice(startFromStepIndex)
.filter((step) => !excludedStepIds.has(step.id));
// If no steps left to execute, complete the feature
if (stepsToExecute.length === 0) {
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true,
message: 'Pipeline completed (all remaining steps excluded)',
projectPath,
});
return;
}
// Use the filtered steps for counting
const sortedSteps = allSortedSteps.filter((step) => !excludedStepIds.has(step.id));
console.log(
`[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}`

View File

@@ -0,0 +1,288 @@
/**
* GitHub Copilot Usage Service
*
* Fetches usage data from GitHub's Copilot API using GitHub OAuth.
* Based on CodexBar reference implementation.
*
* Authentication methods:
* 1. GitHub CLI token (~/.config/gh/hosts.yml)
* 2. GitHub OAuth device flow (stored in config)
*
* API Endpoints:
* - GET https://api.github.com/copilot_internal/user - Quota and plan info
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execSync } from 'child_process';
import { createLogger } from '@automaker/utils';
import type { CopilotProviderUsage, UsageWindow } from '@automaker/types';
const logger = createLogger('CopilotUsage');
// GitHub API endpoint for Copilot
const COPILOT_USER_ENDPOINT = 'https://api.github.com/copilot_internal/user';
interface CopilotQuotaSnapshot {
percentageUsed?: number;
percentageRemaining?: number;
limit?: number;
used?: number;
}
interface CopilotUserResponse {
copilotPlan?: string;
copilot_plan?: string;
quotaSnapshots?: {
premiumInteractions?: CopilotQuotaSnapshot;
chat?: CopilotQuotaSnapshot;
};
plan?: string;
}
export class CopilotUsageService {
private cachedToken: string | null = null;
/**
* Check if GitHub Copilot credentials are available
*/
async isAvailable(): Promise<boolean> {
const token = await this.getGitHubToken();
return !!token;
}
/**
* Get GitHub token from various sources
*/
private async getGitHubToken(): Promise<string | null> {
if (this.cachedToken) {
return this.cachedToken;
}
// 1. Check environment variable
if (process.env.GITHUB_TOKEN) {
this.cachedToken = process.env.GITHUB_TOKEN;
return this.cachedToken;
}
// 2. Check GH_TOKEN (GitHub CLI uses this)
if (process.env.GH_TOKEN) {
this.cachedToken = process.env.GH_TOKEN;
return this.cachedToken;
}
// 3. Try to get token from GitHub CLI
try {
const token = execSync('gh auth token', {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
if (token) {
this.cachedToken = token;
return this.cachedToken;
}
} catch {
logger.debug('Failed to get token from gh CLI');
}
// 4. Check GitHub CLI hosts.yml file
const ghHostsPath = path.join(os.homedir(), '.config', 'gh', 'hosts.yml');
if (fs.existsSync(ghHostsPath)) {
try {
const content = fs.readFileSync(ghHostsPath, 'utf8');
// Simple YAML parsing for oauth_token
const match = content.match(/oauth_token:\s*(.+)/);
if (match) {
this.cachedToken = match[1].trim();
return this.cachedToken;
}
} catch (error) {
logger.debug('Failed to read gh hosts.yml:', error);
}
}
// 5. Check CodexBar config (for users who also use CodexBar)
const codexbarConfigPath = path.join(os.homedir(), '.codexbar', 'config.json');
if (fs.existsSync(codexbarConfigPath)) {
try {
const content = fs.readFileSync(codexbarConfigPath, 'utf8');
const config = JSON.parse(content);
if (config.github?.oauth_token) {
this.cachedToken = config.github.oauth_token;
return this.cachedToken;
}
} catch (error) {
logger.debug('Failed to read CodexBar config:', error);
}
}
return null;
}
/**
* Make an authenticated request to GitHub Copilot API
*/
private async makeRequest<T>(url: string): Promise<T | null> {
const token = await this.getGitHubToken();
if (!token) {
return null;
}
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: `token ${token}`,
Accept: 'application/json',
'User-Agent': 'automaker/1.0',
// Copilot-specific headers (from CodexBar reference)
'Editor-Version': 'vscode/1.96.2',
'Editor-Plugin-Version': 'copilot-chat/0.26.7',
'X-Github-Api-Version': '2025-04-01',
},
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
// Clear cached token on auth failure
this.cachedToken = null;
logger.warn('GitHub Copilot API authentication failed');
return null;
}
if (response.status === 404) {
// User may not have Copilot access
logger.info('GitHub Copilot not available for this user');
return null;
}
logger.error(`GitHub Copilot API error: ${response.status} ${response.statusText}`);
return null;
}
return (await response.json()) as T;
} catch (error) {
logger.error('Failed to fetch from GitHub Copilot API:', error);
return null;
}
}
/**
* Fetch usage data from GitHub Copilot
*/
async fetchUsageData(): Promise<CopilotProviderUsage> {
logger.info('[fetchUsageData] Starting GitHub Copilot usage fetch...');
const baseUsage: CopilotProviderUsage = {
providerId: 'copilot',
providerName: 'GitHub Copilot',
available: false,
lastUpdated: new Date().toISOString(),
};
// Check if token is available
const hasToken = await this.getGitHubToken();
if (!hasToken) {
baseUsage.error = 'GitHub authentication not available';
return baseUsage;
}
// Fetch Copilot user data
const userResponse = await this.makeRequest<CopilotUserResponse>(COPILOT_USER_ENDPOINT);
if (!userResponse) {
baseUsage.error = 'Failed to fetch GitHub Copilot usage data';
return baseUsage;
}
baseUsage.available = true;
// Parse quota snapshots
const quotas = userResponse.quotaSnapshots;
if (quotas) {
// Premium interactions quota
if (quotas.premiumInteractions) {
const premium = quotas.premiumInteractions;
const usedPercent =
premium.percentageUsed !== undefined
? premium.percentageUsed
: premium.percentageRemaining !== undefined
? 100 - premium.percentageRemaining
: 0;
const premiumWindow: UsageWindow = {
name: 'Premium Interactions',
usedPercent,
resetsAt: '', // GitHub doesn't provide reset time
resetText: 'Resets monthly',
limit: premium.limit,
used: premium.used,
};
baseUsage.primary = premiumWindow;
baseUsage.premiumInteractions = premiumWindow;
}
// Chat quota
if (quotas.chat) {
const chat = quotas.chat;
const usedPercent =
chat.percentageUsed !== undefined
? chat.percentageUsed
: chat.percentageRemaining !== undefined
? 100 - chat.percentageRemaining
: 0;
const chatWindow: UsageWindow = {
name: 'Chat',
usedPercent,
resetsAt: '',
resetText: 'Resets monthly',
limit: chat.limit,
used: chat.used,
};
baseUsage.secondary = chatWindow;
baseUsage.chatQuota = chatWindow;
}
}
// Parse plan type
const planType = userResponse.copilotPlan || userResponse.copilot_plan || userResponse.plan;
if (planType) {
baseUsage.copilotPlan = planType;
baseUsage.plan = {
type: planType,
displayName: this.formatPlanName(planType),
isPaid: planType.toLowerCase() !== 'free',
};
}
logger.info(
`[fetchUsageData] ✓ GitHub Copilot usage: Premium=${baseUsage.premiumInteractions?.usedPercent || 0}%, ` +
`Chat=${baseUsage.chatQuota?.usedPercent || 0}%, Plan=${planType || 'unknown'}`
);
return baseUsage;
}
/**
* Format plan name for display
*/
private formatPlanName(plan: string): string {
const planMap: Record<string, string> = {
free: 'Free',
individual: 'Individual',
business: 'Business',
enterprise: 'Enterprise',
};
return planMap[plan.toLowerCase()] || plan;
}
/**
* Clear cached token
*/
clearCache(): void {
this.cachedToken = null;
}
}

View File

@@ -0,0 +1,331 @@
/**
* Cursor Usage Service
*
* Fetches usage data from Cursor's API using session cookies or access token.
* Based on CodexBar reference implementation.
*
* Authentication methods (in priority order):
* 1. Cached session cookie from browser import
* 2. Access token from credentials file
*
* API Endpoints:
* - GET https://cursor.com/api/usage-summary - Plan usage, on-demand, billing dates
* - GET https://cursor.com/api/auth/me - User email and name
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createLogger } from '@automaker/utils';
import type { CursorProviderUsage, UsageWindow } from '@automaker/types';
const logger = createLogger('CursorUsage');
// Cursor API endpoints
const CURSOR_API_BASE = 'https://cursor.com/api';
const USAGE_SUMMARY_ENDPOINT = `${CURSOR_API_BASE}/usage-summary`;
const AUTH_ME_ENDPOINT = `${CURSOR_API_BASE}/auth/me`;
// Session cookie names used by Cursor
const SESSION_COOKIE_NAMES = [
'WorkosCursorSessionToken',
'__Secure-next-auth.session-token',
'next-auth.session-token',
];
interface CursorUsageSummary {
planUsage?: {
percent: number;
resetAt?: string;
};
onDemandUsage?: {
percent: number;
costUsd?: number;
};
billingCycleEnd?: string;
plan?: string;
}
interface CursorAuthMe {
email?: string;
name?: string;
plan?: string;
}
export class CursorUsageService {
private cachedSessionCookie: string | null = null;
private cachedAccessToken: string | null = null;
/**
* Check if Cursor credentials are available
*/
async isAvailable(): Promise<boolean> {
return await this.hasValidCredentials();
}
/**
* Check if we have valid Cursor credentials
*/
private async hasValidCredentials(): Promise<boolean> {
const token = await this.getAccessToken();
return !!token;
}
/**
* Get access token from credentials file
*/
private async getAccessToken(): Promise<string | null> {
if (this.cachedAccessToken) {
return this.cachedAccessToken;
}
// Check environment variable first
if (process.env.CURSOR_ACCESS_TOKEN) {
this.cachedAccessToken = process.env.CURSOR_ACCESS_TOKEN;
return this.cachedAccessToken;
}
// Check credentials files
const credentialPaths = [
path.join(os.homedir(), '.cursor', 'credentials.json'),
path.join(os.homedir(), '.config', 'cursor', 'credentials.json'),
];
for (const credPath of credentialPaths) {
try {
if (fs.existsSync(credPath)) {
const content = fs.readFileSync(credPath, 'utf8');
const creds = JSON.parse(content);
if (creds.accessToken) {
this.cachedAccessToken = creds.accessToken;
return this.cachedAccessToken;
}
if (creds.token) {
this.cachedAccessToken = creds.token;
return this.cachedAccessToken;
}
}
} catch (error) {
logger.debug(`Failed to read credentials from ${credPath}:`, error);
}
}
return null;
}
/**
* Get session cookie for API calls
* Returns a cookie string like "WorkosCursorSessionToken=xxx"
*/
private async getSessionCookie(): Promise<string | null> {
if (this.cachedSessionCookie) {
return this.cachedSessionCookie;
}
// Check for cookie in environment
if (process.env.CURSOR_SESSION_COOKIE) {
this.cachedSessionCookie = process.env.CURSOR_SESSION_COOKIE;
return this.cachedSessionCookie;
}
// Check for saved session file
const sessionPath = path.join(os.homedir(), '.cursor', 'session.json');
try {
if (fs.existsSync(sessionPath)) {
const content = fs.readFileSync(sessionPath, 'utf8');
const session = JSON.parse(content);
for (const cookieName of SESSION_COOKIE_NAMES) {
if (session[cookieName]) {
this.cachedSessionCookie = `${cookieName}=${session[cookieName]}`;
return this.cachedSessionCookie;
}
}
}
} catch (error) {
logger.debug('Failed to read session file:', error);
}
return null;
}
/**
* Make an authenticated request to Cursor API
*/
private async makeRequest<T>(url: string): Promise<T | null> {
const headers: Record<string, string> = {
Accept: 'application/json',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
};
// Try access token first
const accessToken = await this.getAccessToken();
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
// Try session cookie as fallback
const sessionCookie = await this.getSessionCookie();
if (sessionCookie) {
headers['Cookie'] = sessionCookie;
}
if (!accessToken && !sessionCookie) {
logger.warn('No Cursor credentials available for API request');
return null;
}
try {
const response = await fetch(url, {
method: 'GET',
headers,
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
// Clear cached credentials on auth failure
this.cachedAccessToken = null;
this.cachedSessionCookie = null;
logger.warn('Cursor API authentication failed');
return null;
}
logger.error(`Cursor API error: ${response.status} ${response.statusText}`);
return null;
}
return (await response.json()) as T;
} catch (error) {
logger.error('Failed to fetch from Cursor API:', error);
return null;
}
}
/**
* Fetch usage data from Cursor
*/
async fetchUsageData(): Promise<CursorProviderUsage> {
logger.info('[fetchUsageData] Starting Cursor usage fetch...');
const baseUsage: CursorProviderUsage = {
providerId: 'cursor',
providerName: 'Cursor',
available: false,
lastUpdated: new Date().toISOString(),
};
// Check if credentials are available
const hasCredentials = await this.hasValidCredentials();
if (!hasCredentials) {
baseUsage.error = 'Cursor credentials not available';
return baseUsage;
}
// Fetch usage summary
const usageSummary = await this.makeRequest<CursorUsageSummary>(USAGE_SUMMARY_ENDPOINT);
if (!usageSummary) {
baseUsage.error = 'Failed to fetch Cursor usage data';
return baseUsage;
}
baseUsage.available = true;
// Parse plan usage
if (usageSummary.planUsage) {
const planWindow: UsageWindow = {
name: 'Plan Usage',
usedPercent: usageSummary.planUsage.percent || 0,
resetsAt: usageSummary.planUsage.resetAt || '',
resetText: usageSummary.planUsage.resetAt
? this.formatResetTime(usageSummary.planUsage.resetAt)
: '',
};
baseUsage.primary = planWindow;
baseUsage.planUsage = planWindow;
}
// Parse on-demand usage
if (usageSummary.onDemandUsage) {
const onDemandWindow: UsageWindow = {
name: 'On-Demand Usage',
usedPercent: usageSummary.onDemandUsage.percent || 0,
resetsAt: usageSummary.billingCycleEnd || '',
resetText: usageSummary.billingCycleEnd
? this.formatResetTime(usageSummary.billingCycleEnd)
: '',
};
baseUsage.secondary = onDemandWindow;
baseUsage.onDemandUsage = onDemandWindow;
if (usageSummary.onDemandUsage.costUsd !== undefined) {
baseUsage.onDemandCostUsd = usageSummary.onDemandUsage.costUsd;
}
}
// Parse billing cycle end
if (usageSummary.billingCycleEnd) {
baseUsage.billingCycleEnd = usageSummary.billingCycleEnd;
}
// Parse plan type
if (usageSummary.plan) {
baseUsage.plan = {
type: usageSummary.plan,
displayName: this.formatPlanName(usageSummary.plan),
isPaid: usageSummary.plan.toLowerCase() !== 'free',
};
}
logger.info(
`[fetchUsageData] ✓ Cursor usage: Plan=${baseUsage.planUsage?.usedPercent || 0}%, ` +
`OnDemand=${baseUsage.onDemandUsage?.usedPercent || 0}%`
);
return baseUsage;
}
/**
* Format reset time as human-readable string
*/
private formatResetTime(resetAt: string): string {
try {
const date = new Date(resetAt);
const now = new Date();
const diff = date.getTime() - now.getTime();
if (diff < 0) return 'Expired';
const hours = Math.floor(diff / 3600000);
const days = Math.floor(hours / 24);
if (days > 0) {
return `Resets in ${days}d`;
}
if (hours > 0) {
return `Resets in ${hours}h`;
}
return 'Resets soon';
} catch {
return '';
}
}
/**
* Format plan name for display
*/
private formatPlanName(plan: string): string {
const planMap: Record<string, string> = {
free: 'Free',
pro: 'Pro',
business: 'Business',
enterprise: 'Enterprise',
};
return planMap[plan.toLowerCase()] || plan;
}
/**
* Clear cached credentials (useful for logout)
*/
clearCache(): void {
this.cachedAccessToken = null;
this.cachedSessionCookie = null;
}
}

View File

@@ -273,12 +273,56 @@ class DevServerService {
}
}
/**
* Parse a custom command string into cmd and args
* Handles quoted strings with spaces (e.g., "my command" arg1 arg2)
*/
private parseCustomCommand(command: string): { cmd: string; args: string[] } {
const tokens: string[] = [];
let current = '';
let inQuote = false;
let quoteChar = '';
for (let i = 0; i < command.length; i++) {
const char = command[i];
if (inQuote) {
if (char === quoteChar) {
inQuote = false;
} else {
current += char;
}
} else if (char === '"' || char === "'") {
inQuote = true;
quoteChar = char;
} else if (char === ' ') {
if (current) {
tokens.push(current);
current = '';
}
} else {
current += char;
}
}
if (current) {
tokens.push(current);
}
const [cmd, ...args] = tokens;
return { cmd: cmd || '', args };
}
/**
* Start a dev server for a worktree
* @param projectPath - The project root path
* @param worktreePath - The worktree directory path
* @param customCommand - Optional custom command to run instead of auto-detected dev command
*/
async startDevServer(
projectPath: string,
worktreePath: string
worktreePath: string,
customCommand?: string
): Promise<{
success: boolean;
result?: {
@@ -311,22 +355,41 @@ class DevServerService {
};
}
// Check for package.json
const packageJsonPath = path.join(worktreePath, 'package.json');
if (!(await this.fileExists(packageJsonPath))) {
return {
success: false,
error: `No package.json found in: ${worktreePath}`,
};
}
// Determine the dev command to use
let devCommand: { cmd: string; args: string[] };
// Get dev command
const devCommand = await this.getDevCommand(worktreePath);
if (!devCommand) {
return {
success: false,
error: `Could not determine dev command for: ${worktreePath}`,
};
// Normalize custom command: trim whitespace and treat empty strings as undefined
const normalizedCustomCommand = customCommand?.trim();
if (normalizedCustomCommand) {
// Use the provided custom command
devCommand = this.parseCustomCommand(normalizedCustomCommand);
if (!devCommand.cmd) {
return {
success: false,
error: 'Invalid custom command: command cannot be empty',
};
}
logger.debug(`Using custom command: ${normalizedCustomCommand}`);
} else {
// Check for package.json when auto-detecting
const packageJsonPath = path.join(worktreePath, 'package.json');
if (!(await this.fileExists(packageJsonPath))) {
return {
success: false,
error: `No package.json found in: ${worktreePath}`,
};
}
// Get dev command from package manager detection
const detectedCommand = await this.getDevCommand(worktreePath);
if (!detectedCommand) {
return {
success: false,
error: `Could not determine dev command for: ${worktreePath}`,
};
}
devCommand = detectedCommand;
}
// Find available port

View File

@@ -0,0 +1,540 @@
/**
* Feature Export Service - Handles exporting and importing features in JSON/YAML formats
*
* Provides functionality to:
* - Export single features to JSON or YAML format
* - Export multiple features (bulk export)
* - Import features from JSON or YAML data
* - Validate import data for compatibility
*/
import { createLogger } from '@automaker/utils';
import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
import type { Feature, FeatureExport, FeatureImport, FeatureImportResult } from '@automaker/types';
import { FeatureLoader } from './feature-loader.js';
const logger = createLogger('FeatureExportService');
/** Current export format version */
export const FEATURE_EXPORT_VERSION = '1.0.0';
/** Supported export formats */
export type ExportFormat = 'json' | 'yaml';
/** Options for exporting features */
export interface ExportOptions {
/** Format to export in (default: 'json') */
format?: ExportFormat;
/** Whether to include description history (default: true) */
includeHistory?: boolean;
/** Whether to include plan spec (default: true) */
includePlanSpec?: boolean;
/** Optional metadata to include */
metadata?: {
projectName?: string;
projectPath?: string;
branch?: string;
[key: string]: unknown;
};
/** Who/what is performing the export */
exportedBy?: string;
/** Pretty print output (default: true) */
prettyPrint?: boolean;
}
/** Options for bulk export */
export interface BulkExportOptions extends ExportOptions {
/** Filter by category */
category?: string;
/** Filter by status */
status?: string;
/** Feature IDs to include (if not specified, exports all) */
featureIds?: string[];
}
/** Result of a bulk export */
export interface BulkExportResult {
/** Export format version */
version: string;
/** ISO date string when the export was created */
exportedAt: string;
/** Number of features exported */
count: number;
/** The exported features */
features: FeatureExport[];
/** Export metadata */
metadata?: {
projectName?: string;
projectPath?: string;
branch?: string;
[key: string]: unknown;
};
}
/**
* FeatureExportService - Manages feature export and import operations
*/
export class FeatureExportService {
private featureLoader: FeatureLoader;
constructor(featureLoader?: FeatureLoader) {
this.featureLoader = featureLoader || new FeatureLoader();
}
/**
* Export a single feature to the specified format
*
* @param projectPath - Path to the project
* @param featureId - ID of the feature to export
* @param options - Export options
* @returns Promise resolving to the exported feature string
*/
async exportFeature(
projectPath: string,
featureId: string,
options: ExportOptions = {}
): Promise<string> {
const feature = await this.featureLoader.get(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
return this.exportFeatureData(feature, options);
}
/**
* Export feature data to the specified format (without fetching from disk)
*
* @param feature - The feature to export
* @param options - Export options
* @returns The exported feature string
*/
exportFeatureData(feature: Feature, options: ExportOptions = {}): string {
const {
format = 'json',
includeHistory = true,
includePlanSpec = true,
metadata,
exportedBy,
prettyPrint = true,
} = options;
// Prepare feature data, optionally excluding some fields
const featureData = this.prepareFeatureForExport(feature, {
includeHistory,
includePlanSpec,
});
const exportData: FeatureExport = {
version: FEATURE_EXPORT_VERSION,
feature: featureData,
exportedAt: new Date().toISOString(),
...(exportedBy ? { exportedBy } : {}),
...(metadata ? { metadata } : {}),
};
return this.serialize(exportData, format, prettyPrint);
}
/**
* Export multiple features to the specified format
*
* @param projectPath - Path to the project
* @param options - Bulk export options
* @returns Promise resolving to the exported features string
*/
async exportFeatures(projectPath: string, options: BulkExportOptions = {}): Promise<string> {
const {
format = 'json',
category,
status,
featureIds,
includeHistory = true,
includePlanSpec = true,
metadata,
prettyPrint = true,
} = options;
// Get all features
let features = await this.featureLoader.getAll(projectPath);
// Apply filters
if (featureIds && featureIds.length > 0) {
const idSet = new Set(featureIds);
features = features.filter((f) => idSet.has(f.id));
}
if (category) {
features = features.filter((f) => f.category === category);
}
if (status) {
features = features.filter((f) => f.status === status);
}
// Generate timestamp once for consistent export time across all features
const exportedAt = new Date().toISOString();
// Prepare feature exports
const featureExports: FeatureExport[] = features.map((feature) => ({
version: FEATURE_EXPORT_VERSION,
feature: this.prepareFeatureForExport(feature, { includeHistory, includePlanSpec }),
exportedAt,
}));
const bulkExport: BulkExportResult = {
version: FEATURE_EXPORT_VERSION,
exportedAt,
count: featureExports.length,
features: featureExports,
...(metadata ? { metadata } : {}),
};
logger.info(`Exported ${featureExports.length} features from ${projectPath}`);
return this.serialize(bulkExport, format, prettyPrint);
}
/**
* Import a feature from JSON or YAML data
*
* @param projectPath - Path to the project
* @param importData - Import configuration
* @returns Promise resolving to the import result
*/
async importFeature(
projectPath: string,
importData: FeatureImport
): Promise<FeatureImportResult> {
const warnings: string[] = [];
const errors: string[] = [];
try {
// Extract feature from data (handle both raw Feature and wrapped FeatureExport)
const feature = this.extractFeatureFromImport(importData.data);
if (!feature) {
return {
success: false,
importedAt: new Date().toISOString(),
errors: ['Invalid import data: could not extract feature'],
};
}
// Validate required fields
const validationErrors = this.validateFeature(feature);
if (validationErrors.length > 0) {
return {
success: false,
importedAt: new Date().toISOString(),
errors: validationErrors,
};
}
// Determine the feature ID to use
const featureId = importData.newId || feature.id || this.featureLoader.generateFeatureId();
// Check for existing feature
const existingFeature = await this.featureLoader.get(projectPath, featureId);
if (existingFeature && !importData.overwrite) {
return {
success: false,
importedAt: new Date().toISOString(),
errors: [`Feature with ID ${featureId} already exists. Set overwrite: true to replace.`],
};
}
// Prepare feature for import
const featureToImport: Feature = {
...feature,
id: featureId,
// Optionally override category
...(importData.targetCategory ? { category: importData.targetCategory } : {}),
// Clear branch info if not preserving
...(importData.preserveBranchInfo ? {} : { branchName: undefined }),
};
// Clear runtime-specific fields that shouldn't be imported
delete featureToImport.titleGenerating;
delete featureToImport.error;
// Handle image paths - they won't be valid after import
if (featureToImport.imagePaths && featureToImport.imagePaths.length > 0) {
warnings.push(
`Feature had ${featureToImport.imagePaths.length} image path(s) that were cleared during import. Images must be re-attached.`
);
featureToImport.imagePaths = [];
}
// Handle text file paths - they won't be valid after import
if (featureToImport.textFilePaths && featureToImport.textFilePaths.length > 0) {
warnings.push(
`Feature had ${featureToImport.textFilePaths.length} text file path(s) that were cleared during import. Files must be re-attached.`
);
featureToImport.textFilePaths = [];
}
// Create or update the feature
if (existingFeature) {
await this.featureLoader.update(projectPath, featureId, featureToImport);
logger.info(`Updated feature ${featureId} via import`);
} else {
await this.featureLoader.create(projectPath, featureToImport);
logger.info(`Created feature ${featureId} via import`);
}
return {
success: true,
featureId,
importedAt: new Date().toISOString(),
warnings: warnings.length > 0 ? warnings : undefined,
wasOverwritten: !!existingFeature,
};
} catch (error) {
logger.error('Failed to import feature:', error);
return {
success: false,
importedAt: new Date().toISOString(),
errors: [`Import failed: ${error instanceof Error ? error.message : String(error)}`],
};
}
}
/**
* Import multiple features from JSON or YAML data
*
* @param projectPath - Path to the project
* @param data - Raw JSON or YAML string, or parsed data
* @param options - Import options applied to all features
* @returns Promise resolving to array of import results
*/
async importFeatures(
projectPath: string,
data: string | BulkExportResult,
options: Omit<FeatureImport, 'data'> = {}
): Promise<FeatureImportResult[]> {
let bulkData: BulkExportResult;
// Parse if string
if (typeof data === 'string') {
const parsed = this.parseImportData(data);
if (!parsed || !this.isBulkExport(parsed)) {
return [
{
success: false,
importedAt: new Date().toISOString(),
errors: ['Invalid bulk import data: expected BulkExportResult format'],
},
];
}
bulkData = parsed as BulkExportResult;
} else {
bulkData = data;
}
// Import each feature
const results: FeatureImportResult[] = [];
for (const featureExport of bulkData.features) {
const result = await this.importFeature(projectPath, {
data: featureExport,
...options,
});
results.push(result);
}
const successCount = results.filter((r) => r.success).length;
logger.info(`Bulk import complete: ${successCount}/${results.length} features imported`);
return results;
}
/**
* Parse import data from JSON or YAML string
*
* @param data - Raw JSON or YAML string
* @returns Parsed data or null if parsing fails
*/
parseImportData(data: string): Feature | FeatureExport | BulkExportResult | null {
const trimmed = data.trim();
// Try JSON first
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
return JSON.parse(trimmed);
} catch {
// Fall through to YAML
}
}
// Try YAML
try {
return yamlParse(trimmed);
} catch (error) {
logger.error('Failed to parse import data:', error);
return null;
}
}
/**
* Detect the format of import data
*
* @param data - Raw string data
* @returns Detected format or null if unknown
*/
detectFormat(data: string): ExportFormat | null {
const trimmed = data.trim();
// JSON detection
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
JSON.parse(trimmed);
return 'json';
} catch {
// Not valid JSON
}
}
// YAML detection (if it parses and wasn't JSON)
try {
yamlParse(trimmed);
return 'yaml';
} catch {
// Not valid YAML either
}
return null;
}
/**
* Prepare a feature for export by optionally removing fields
*/
private prepareFeatureForExport(
feature: Feature,
options: { includeHistory?: boolean; includePlanSpec?: boolean }
): Feature {
const { includeHistory = true, includePlanSpec = true } = options;
// Clone to avoid modifying original
const exported: Feature = { ...feature };
// Remove transient fields that shouldn't be exported
delete exported.titleGenerating;
delete exported.error;
// Optionally exclude history
if (!includeHistory) {
delete exported.descriptionHistory;
}
// Optionally exclude plan spec
if (!includePlanSpec) {
delete exported.planSpec;
}
return exported;
}
/**
* Extract a Feature from import data (handles both raw and wrapped formats)
*/
private extractFeatureFromImport(data: Feature | FeatureExport): Feature | null {
if (!data || typeof data !== 'object') {
return null;
}
// Check if it's a FeatureExport wrapper
if ('version' in data && 'feature' in data && 'exportedAt' in data) {
const exportData = data as FeatureExport;
return exportData.feature;
}
// Assume it's a raw Feature
return data as Feature;
}
/**
* Check if parsed data is a bulk export
*/
isBulkExport(data: unknown): data is BulkExportResult {
if (!data || typeof data !== 'object') {
return false;
}
const obj = data as Record<string, unknown>;
return 'version' in obj && 'features' in obj && Array.isArray(obj.features);
}
/**
* Check if parsed data is a single FeatureExport
*/
isFeatureExport(data: unknown): data is FeatureExport {
if (!data || typeof data !== 'object') {
return false;
}
const obj = data as Record<string, unknown>;
return (
'version' in obj &&
'feature' in obj &&
'exportedAt' in obj &&
typeof obj.feature === 'object' &&
obj.feature !== null &&
'id' in (obj.feature as Record<string, unknown>)
);
}
/**
* Check if parsed data is a raw Feature
*/
isRawFeature(data: unknown): data is Feature {
if (!data || typeof data !== 'object') {
return false;
}
const obj = data as Record<string, unknown>;
// A raw feature has 'id' but not the 'version' + 'feature' wrapper of FeatureExport
return 'id' in obj && !('feature' in obj && 'version' in obj);
}
/**
* Validate a feature has required fields
*/
private validateFeature(feature: Feature): string[] {
const errors: string[] = [];
if (!feature.description && !feature.title) {
errors.push('Feature must have at least a title or description');
}
if (!feature.category) {
errors.push('Feature must have a category');
}
return errors;
}
/**
* Serialize export data to string (handles both single feature and bulk exports)
*/
private serialize<T extends FeatureExport | BulkExportResult>(
data: T,
format: ExportFormat,
prettyPrint: boolean
): string {
if (format === 'yaml') {
return yamlStringify(data, {
indent: 2,
lineWidth: 120,
});
}
return prettyPrint ? JSON.stringify(data, null, 2) : JSON.stringify(data);
}
}
// Singleton instance
let featureExportServiceInstance: FeatureExportService | null = null;
/**
* Get the singleton feature export service instance
*/
export function getFeatureExportService(): FeatureExportService {
if (!featureExportServiceInstance) {
featureExportServiceInstance = new FeatureExportService();
}
return featureExportServiceInstance;
}

View File

@@ -0,0 +1,362 @@
/**
* Gemini Usage Service
*
* Fetches usage data from Google's Gemini/Cloud Code API using OAuth credentials.
* Based on CodexBar reference implementation.
*
* Authentication methods:
* 1. OAuth credentials from ~/.gemini/oauth_creds.json
* 2. API key (limited - only supports API calls, not quota info)
*
* API Endpoints:
* - POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota - Quota info
* - POST https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist - Tier detection
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createLogger } from '@automaker/utils';
import type { GeminiProviderUsage, UsageWindow } from '@automaker/types';
const logger = createLogger('GeminiUsage');
// Gemini API endpoints
const QUOTA_ENDPOINT = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota';
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist';
const TOKEN_REFRESH_ENDPOINT = 'https://oauth2.googleapis.com/token';
// Gemini CLI client credentials (from Gemini CLI installation)
// These are embedded in the Gemini CLI and are public
const GEMINI_CLIENT_ID =
'764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com';
const GEMINI_CLIENT_SECRET = 'd-FL95Q19q7MQmFpd7hHD0Ty';
interface GeminiOAuthCreds {
access_token: string;
refresh_token: string;
id_token?: string;
expiry_date: number;
}
interface GeminiQuotaResponse {
quotas?: Array<{
remainingFraction: number;
resetTime: string;
modelId?: string;
}>;
}
interface GeminiCodeAssistResponse {
tier?: string;
claims?: {
hd?: string;
};
}
export class GeminiUsageService {
private cachedCreds: GeminiOAuthCreds | null = null;
private settingsPath = path.join(os.homedir(), '.gemini', 'settings.json');
private credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
/**
* Check if Gemini credentials are available
*/
async isAvailable(): Promise<boolean> {
const creds = await this.getOAuthCreds();
return !!creds;
}
/**
* Get authentication type from settings
*/
private getAuthType(): string | null {
try {
if (fs.existsSync(this.settingsPath)) {
const content = fs.readFileSync(this.settingsPath, 'utf8');
const settings = JSON.parse(content);
return settings.auth_type || settings.authType || null;
}
} catch (error) {
logger.debug('Failed to read Gemini settings:', error);
}
return null;
}
/**
* Get OAuth credentials from file
*/
private async getOAuthCreds(): Promise<GeminiOAuthCreds | null> {
// Check auth type - only oauth-personal supports quota API
const authType = this.getAuthType();
if (authType && authType !== 'oauth-personal') {
logger.debug(`Gemini auth type is ${authType}, not oauth-personal - quota API not available`);
return null;
}
// Check cached credentials
if (this.cachedCreds) {
// Check if expired
if (this.cachedCreds.expiry_date > Date.now()) {
return this.cachedCreds;
}
// Try to refresh
const refreshed = await this.refreshToken(this.cachedCreds.refresh_token);
if (refreshed) {
this.cachedCreds = refreshed;
return this.cachedCreds;
}
}
// Load from file
try {
if (fs.existsSync(this.credsPath)) {
const content = fs.readFileSync(this.credsPath, 'utf8');
const creds = JSON.parse(content) as GeminiOAuthCreds;
// Check if expired
if (creds.expiry_date && creds.expiry_date <= Date.now()) {
// Try to refresh
if (creds.refresh_token) {
const refreshed = await this.refreshToken(creds.refresh_token);
if (refreshed) {
this.cachedCreds = refreshed;
// Save refreshed credentials
this.saveCreds(refreshed);
return this.cachedCreds;
}
}
logger.warn('Gemini OAuth token expired and refresh failed');
return null;
}
this.cachedCreds = creds;
return this.cachedCreds;
}
} catch (error) {
logger.debug('Failed to read Gemini OAuth credentials:', error);
}
return null;
}
/**
* Refresh OAuth token
*/
private async refreshToken(refreshToken: string): Promise<GeminiOAuthCreds | null> {
try {
const response = await fetch(TOKEN_REFRESH_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: GEMINI_CLIENT_ID,
client_secret: GEMINI_CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: 'refresh_token',
}),
});
if (!response.ok) {
logger.error(`Token refresh failed: ${response.status}`);
return null;
}
const data = (await response.json()) as {
access_token: string;
expires_in: number;
id_token?: string;
};
return {
access_token: data.access_token,
refresh_token: refreshToken,
id_token: data.id_token,
expiry_date: Date.now() + data.expires_in * 1000,
};
} catch (error) {
logger.error('Failed to refresh Gemini token:', error);
return null;
}
}
/**
* Save credentials to file
*/
private saveCreds(creds: GeminiOAuthCreds): void {
try {
const dir = path.dirname(this.credsPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(this.credsPath, JSON.stringify(creds, null, 2));
} catch (error) {
logger.warn('Failed to save Gemini credentials:', error);
}
}
/**
* Make an authenticated request to Gemini API
*/
private async makeRequest<T>(url: string, body?: unknown): Promise<T | null> {
const creds = await this.getOAuthCreds();
if (!creds) {
return null;
}
try {
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${creds.access_token}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
// Clear cached credentials on auth failure
this.cachedCreds = null;
logger.warn('Gemini API authentication failed');
return null;
}
logger.error(`Gemini API error: ${response.status} ${response.statusText}`);
return null;
}
return (await response.json()) as T;
} catch (error) {
logger.error('Failed to fetch from Gemini API:', error);
return null;
}
}
/**
* Fetch usage data from Gemini
*/
async fetchUsageData(): Promise<GeminiProviderUsage> {
logger.info('[fetchUsageData] Starting Gemini usage fetch...');
const baseUsage: GeminiProviderUsage = {
providerId: 'gemini',
providerName: 'Gemini',
available: false,
lastUpdated: new Date().toISOString(),
};
// Check if credentials are available
const creds = await this.getOAuthCreds();
if (!creds) {
baseUsage.error = 'Gemini OAuth credentials not available';
return baseUsage;
}
// Fetch quota information
const quotaResponse = await this.makeRequest<GeminiQuotaResponse>(QUOTA_ENDPOINT, {
projectId: '-', // Use default project
});
if (quotaResponse?.quotas && quotaResponse.quotas.length > 0) {
baseUsage.available = true;
const primaryQuota = quotaResponse.quotas[0];
// Convert remaining fraction to used percent
const usedPercent = Math.round((1 - (primaryQuota.remainingFraction || 0)) * 100);
const quotaWindow: UsageWindow = {
name: 'Quota',
usedPercent,
resetsAt: primaryQuota.resetTime || '',
resetText: primaryQuota.resetTime ? this.formatResetTime(primaryQuota.resetTime) : '',
};
baseUsage.primary = quotaWindow;
baseUsage.remainingFraction = primaryQuota.remainingFraction;
baseUsage.modelId = primaryQuota.modelId;
}
// Fetch tier information
const codeAssistResponse = await this.makeRequest<GeminiCodeAssistResponse>(
CODE_ASSIST_ENDPOINT,
{
metadata: {
ide: 'automaker',
},
}
);
if (codeAssistResponse?.tier) {
baseUsage.tierType = codeAssistResponse.tier;
// Determine plan info from tier
const tierMap: Record<string, { type: string; displayName: string; isPaid: boolean }> = {
'standard-tier': { type: 'paid', displayName: 'Paid', isPaid: true },
'free-tier': {
type: codeAssistResponse.claims?.hd ? 'workspace' : 'free',
displayName: codeAssistResponse.claims?.hd ? 'Workspace' : 'Free',
isPaid: false,
},
'legacy-tier': { type: 'legacy', displayName: 'Legacy', isPaid: false },
};
const tierInfo = tierMap[codeAssistResponse.tier] || {
type: codeAssistResponse.tier,
displayName: codeAssistResponse.tier,
isPaid: false,
};
baseUsage.plan = tierInfo;
}
if (baseUsage.available) {
logger.info(
`[fetchUsageData] ✓ Gemini usage: ${baseUsage.primary?.usedPercent || 0}% used, ` +
`tier=${baseUsage.tierType || 'unknown'}`
);
} else {
baseUsage.error = 'Failed to fetch Gemini quota data';
}
return baseUsage;
}
/**
* Format reset time as human-readable string
*/
private formatResetTime(resetAt: string): string {
try {
const date = new Date(resetAt);
const now = new Date();
const diff = date.getTime() - now.getTime();
if (diff < 0) return 'Expired';
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return `Resets in ${days}d ${hours % 24}h`;
}
if (hours > 0) {
return `Resets in ${hours}h ${minutes % 60}m`;
}
if (minutes > 0) {
return `Resets in ${minutes}m`;
}
return 'Resets soon';
} catch {
return '';
}
}
/**
* Clear cached credentials
*/
clearCache(): void {
this.cachedCreds = null;
}
}

View File

@@ -0,0 +1,140 @@
/**
* GLM (z.AI) Usage Service
*
* Fetches usage data from z.AI's API.
* GLM is a Claude-compatible provider offered by z.AI.
*
* Authentication:
* - API Token from provider config or GLM_API_KEY environment variable
*
* Note: z.AI's API may not expose a dedicated usage endpoint.
* This service checks for API availability and reports basic status.
*/
import { createLogger } from '@automaker/utils';
import type { GLMProviderUsage, ClaudeCompatibleProvider } from '@automaker/types';
const logger = createLogger('GLMUsage');
// GLM API base (z.AI)
const GLM_API_BASE = 'https://api.z.ai';
export class GLMUsageService {
private providerConfig: ClaudeCompatibleProvider | null = null;
private cachedApiKey: string | null = null;
/**
* Set the provider config (called from settings)
*/
setProviderConfig(config: ClaudeCompatibleProvider | null): void {
this.providerConfig = config;
this.cachedApiKey = null;
}
/**
* Check if GLM is available
*/
async isAvailable(): Promise<boolean> {
const apiKey = this.getApiKey();
return !!apiKey;
}
/**
* Get API key from various sources
*/
private getApiKey(): string | null {
if (this.cachedApiKey) {
return this.cachedApiKey;
}
// 1. Check environment variable
if (process.env.GLM_API_KEY) {
this.cachedApiKey = process.env.GLM_API_KEY;
return this.cachedApiKey;
}
// 2. Check provider config
if (this.providerConfig?.apiKey) {
this.cachedApiKey = this.providerConfig.apiKey;
return this.cachedApiKey;
}
return null;
}
/**
* Fetch usage data from GLM
*
* Note: z.AI may not have a public usage API.
* This returns basic availability status.
*/
async fetchUsageData(): Promise<GLMProviderUsage> {
logger.info('[fetchUsageData] Starting GLM usage fetch...');
const baseUsage: GLMProviderUsage = {
providerId: 'glm',
providerName: 'z.AI GLM',
available: false,
lastUpdated: new Date().toISOString(),
};
const apiKey = this.getApiKey();
if (!apiKey) {
baseUsage.error = 'GLM API key not available';
return baseUsage;
}
// GLM/z.AI is available if we have an API key
// z.AI doesn't appear to have a public usage endpoint
baseUsage.available = true;
// Check if API key is valid by making a simple request
try {
const baseUrl = this.providerConfig?.baseUrl || GLM_API_BASE;
const response = await fetch(`${baseUrl}/api/anthropic/v1/messages`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'GLM-4.7',
max_tokens: 1,
messages: [{ role: 'user', content: 'hi' }],
}),
});
// We just want to check if auth works, not actually make a request
// A 400 with invalid request is fine - it means auth worked
if (response.status === 401 || response.status === 403) {
baseUsage.available = false;
baseUsage.error = 'GLM API authentication failed';
}
} catch (error) {
// Network error or other issue - still mark as available since we have the key
logger.debug('GLM API check failed (may be fine):', error);
}
// Note: z.AI doesn't appear to expose usage metrics via API
// Users should check their z.AI dashboard for detailed usage
if (baseUsage.available) {
baseUsage.plan = {
type: 'api',
displayName: 'API Access',
isPaid: true,
};
}
logger.info(`[fetchUsageData] GLM available: ${baseUsage.available}`);
return baseUsage;
}
/**
* Clear cached credentials
*/
clearCache(): void {
this.cachedApiKey = null;
}
}

View File

@@ -39,9 +39,13 @@ import { ProviderFactory } from '../providers/provider-factory.js';
import type { SettingsService } from './settings-service.js';
import type { FeatureLoader } from './feature-loader.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { resolveModelString } from '@automaker/model-resolver';
import { resolveModelString, resolvePhaseModel } from '@automaker/model-resolver';
import { stripProviderPrefix } from '@automaker/types';
import { getPromptCustomization, getProviderByModelId } from '../lib/settings-helpers.js';
import {
getPromptCustomization,
getProviderByModelId,
getPhaseModelWithOverrides,
} from '../lib/settings-helpers.js';
const logger = createLogger('IdeationService');
@@ -684,8 +688,24 @@ export class IdeationService {
existingWorkContext
);
// Resolve model alias to canonical identifier (with prefix)
const modelId = resolveModelString('sonnet');
// Get model from phase settings with provider info (ideationModel)
const phaseResult = await getPhaseModelWithOverrides(
'ideationModel',
this.settingsService,
projectPath,
'[IdeationService]'
);
const resolved = resolvePhaseModel(phaseResult.phaseModel);
// resolvePhaseModel already resolves model aliases internally - no need to call resolveModelString again
const modelId = resolved.model;
const claudeCompatibleProvider = phaseResult.provider;
const credentials = phaseResult.credentials;
logger.info(
'generateSuggestions using model:',
modelId,
claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API'
);
// Create SDK options
const sdkOptions = createChatOptions({
@@ -700,9 +720,6 @@ export class IdeationService {
// Strip provider prefix - providers need bare model IDs
const bareModel = stripProviderPrefix(modelId);
// Get credentials for API calls (uses hardcoded model, no phase setting)
const credentials = await this.settingsService?.getCredentials();
const executeOptions: ExecuteOptions = {
prompt: prompt.prompt,
model: bareModel,
@@ -713,6 +730,8 @@ export class IdeationService {
// Disable all tools - we just want text generation, not codebase analysis
allowedTools: [],
abortController: new AbortController(),
readOnly: true, // Suggestions only need to return JSON, never write files
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
};

View File

@@ -0,0 +1,260 @@
/**
* MiniMax Usage Service
*
* Fetches usage data from MiniMax's coding plan API.
* Based on CodexBar reference implementation.
*
* Authentication methods:
* 1. API Token (MINIMAX_API_KEY environment variable or provider config)
* 2. Cookie-based authentication (from platform login)
*
* API Endpoints:
* - GET https://api.minimax.io/v1/coding_plan/remains - Token-based usage
* - GET https://platform.minimax.io/v1/api/openplatform/coding_plan/remains - Fallback
*
* For China mainland: platform.minimaxi.com
*/
import { createLogger } from '@automaker/utils';
import type { MiniMaxProviderUsage, UsageWindow, ClaudeCompatibleProvider } from '@automaker/types';
const logger = createLogger('MiniMaxUsage');
// MiniMax API endpoints
const MINIMAX_API_BASE = 'https://api.minimax.io';
const MINIMAX_PLATFORM_BASE = 'https://platform.minimax.io';
const MINIMAX_CHINA_BASE = 'https://platform.minimaxi.com';
const CODING_PLAN_ENDPOINT = '/v1/coding_plan/remains';
const PLATFORM_CODING_PLAN_ENDPOINT = '/v1/api/openplatform/coding_plan/remains';
interface MiniMaxCodingPlanResponse {
base_resp?: {
status_code?: number;
status_msg?: string;
};
model_remains?: Array<{
model: string;
used: number;
total: number;
}>;
remains_time?: number; // Seconds until reset
start_time?: string;
end_time?: string;
}
export class MiniMaxUsageService {
private providerConfig: ClaudeCompatibleProvider | null = null;
private cachedApiKey: string | null = null;
/**
* Set the provider config (called from settings)
*/
setProviderConfig(config: ClaudeCompatibleProvider | null): void {
this.providerConfig = config;
this.cachedApiKey = null; // Clear cache when config changes
}
/**
* Check if MiniMax is available
*/
async isAvailable(): Promise<boolean> {
const apiKey = this.getApiKey();
return !!apiKey;
}
/**
* Get API key from various sources
*/
private getApiKey(): string | null {
if (this.cachedApiKey) {
return this.cachedApiKey;
}
// 1. Check environment variable
if (process.env.MINIMAX_API_KEY) {
this.cachedApiKey = process.env.MINIMAX_API_KEY;
return this.cachedApiKey;
}
// 2. Check provider config
if (this.providerConfig?.apiKey) {
this.cachedApiKey = this.providerConfig.apiKey;
return this.cachedApiKey;
}
return null;
}
/**
* Determine if we should use China endpoint
*/
private isChina(): boolean {
if (this.providerConfig?.baseUrl) {
return this.providerConfig.baseUrl.includes('minimaxi.com');
}
return false;
}
/**
* Make an authenticated request to MiniMax API
*/
private async makeRequest<T>(url: string): Promise<T | null> {
const apiKey = this.getApiKey();
if (!apiKey) {
return null;
}
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
this.cachedApiKey = null;
logger.warn('MiniMax API authentication failed');
return null;
}
logger.error(`MiniMax API error: ${response.status} ${response.statusText}`);
return null;
}
return (await response.json()) as T;
} catch (error) {
logger.error('Failed to fetch from MiniMax API:', error);
return null;
}
}
/**
* Fetch usage data from MiniMax
*/
async fetchUsageData(): Promise<MiniMaxProviderUsage> {
logger.info('[fetchUsageData] Starting MiniMax usage fetch...');
const baseUsage: MiniMaxProviderUsage = {
providerId: 'minimax',
providerName: 'MiniMax',
available: false,
lastUpdated: new Date().toISOString(),
};
const apiKey = this.getApiKey();
if (!apiKey) {
baseUsage.error = 'MiniMax API key not available';
return baseUsage;
}
// Determine the correct endpoint
const isChina = this.isChina();
const baseUrl = isChina ? MINIMAX_CHINA_BASE : MINIMAX_API_BASE;
const endpoint = `${baseUrl}${CODING_PLAN_ENDPOINT}`;
// Fetch coding plan data
let codingPlan = await this.makeRequest<MiniMaxCodingPlanResponse>(endpoint);
// Try fallback endpoint if primary fails
if (!codingPlan) {
const platformBase = isChina ? MINIMAX_CHINA_BASE : MINIMAX_PLATFORM_BASE;
const fallbackEndpoint = `${platformBase}${PLATFORM_CODING_PLAN_ENDPOINT}`;
codingPlan = await this.makeRequest<MiniMaxCodingPlanResponse>(fallbackEndpoint);
}
if (!codingPlan) {
baseUsage.error = 'Failed to fetch MiniMax usage data';
return baseUsage;
}
// Check for error response
if (codingPlan.base_resp?.status_code && codingPlan.base_resp.status_code !== 0) {
baseUsage.error = codingPlan.base_resp.status_msg || 'MiniMax API error';
return baseUsage;
}
baseUsage.available = true;
// Parse model remains
if (codingPlan.model_remains && codingPlan.model_remains.length > 0) {
let totalUsed = 0;
let totalLimit = 0;
for (const model of codingPlan.model_remains) {
totalUsed += model.used;
totalLimit += model.total;
}
const usedPercent = totalLimit > 0 ? Math.round((totalUsed / totalLimit) * 100) : 0;
// Calculate reset time
const resetsAt = codingPlan.remains_time
? new Date(Date.now() + codingPlan.remains_time * 1000).toISOString()
: codingPlan.end_time || '';
const usageWindow: UsageWindow = {
name: 'Coding Plan',
usedPercent,
resetsAt,
resetText: resetsAt ? this.formatResetTime(resetsAt) : '',
used: totalUsed,
limit: totalLimit,
};
baseUsage.primary = usageWindow;
baseUsage.tokenRemains = totalLimit - totalUsed;
baseUsage.totalTokens = totalLimit;
}
// Parse plan times
if (codingPlan.start_time) {
baseUsage.planStartTime = codingPlan.start_time;
}
if (codingPlan.end_time) {
baseUsage.planEndTime = codingPlan.end_time;
}
logger.info(
`[fetchUsageData] ✓ MiniMax usage: ${baseUsage.primary?.usedPercent || 0}% used, ` +
`${baseUsage.tokenRemains || 0} tokens remaining`
);
return baseUsage;
}
/**
* Format reset time as human-readable string
*/
private formatResetTime(resetAt: string): string {
try {
const date = new Date(resetAt);
const now = new Date();
const diff = date.getTime() - now.getTime();
if (diff < 0) return 'Expired';
const hours = Math.floor(diff / 3600000);
const days = Math.floor(hours / 24);
if (days > 0) {
return `Resets in ${days}d`;
}
if (hours > 0) {
return `Resets in ${hours}h`;
}
return 'Resets soon';
} catch {
return '';
}
}
/**
* Clear cached credentials
*/
clearCache(): void {
this.cachedApiKey = null;
}
}

View File

@@ -0,0 +1,144 @@
/**
* OpenCode Usage Service
*
* Fetches usage data from OpenCode's server API.
* Based on CodexBar reference implementation.
*
* Note: OpenCode usage tracking is limited as they use a proprietary
* server function API that requires browser cookies for authentication.
* This service provides basic status checking based on local config.
*
* API Endpoints (require browser cookies):
* - POST https://opencode.ai/_server - Server functions
* - workspaces: Get workspace info
* - subscription.get: Get usage data
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createLogger } from '@automaker/utils';
import type { OpenCodeProviderUsage, UsageWindow } from '@automaker/types';
const logger = createLogger('OpenCodeUsage');
// OpenCode config locations
const OPENCODE_CONFIG_PATHS = [
path.join(os.homedir(), '.opencode', 'config.json'),
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
];
interface OpenCodeConfig {
workspaceId?: string;
email?: string;
authenticated?: boolean;
}
interface OpenCodeUsageData {
rollingUsage?: {
usagePercent: number;
resetInSec: number;
};
weeklyUsage?: {
usagePercent: number;
resetInSec: number;
};
}
export class OpenCodeUsageService {
private cachedConfig: OpenCodeConfig | null = null;
/**
* Check if OpenCode is available
*/
async isAvailable(): Promise<boolean> {
const config = this.getConfig();
return !!config?.authenticated;
}
/**
* Get OpenCode config from disk
*/
private getConfig(): OpenCodeConfig | null {
if (this.cachedConfig) {
return this.cachedConfig;
}
// Check environment variable for workspace ID
if (process.env.OPENCODE_WORKSPACE_ID) {
this.cachedConfig = {
workspaceId: process.env.OPENCODE_WORKSPACE_ID,
authenticated: true,
};
return this.cachedConfig;
}
// Check config files
for (const configPath of OPENCODE_CONFIG_PATHS) {
try {
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, 'utf8');
const config = JSON.parse(content) as OpenCodeConfig;
this.cachedConfig = config;
return this.cachedConfig;
}
} catch (error) {
logger.debug(`Failed to read OpenCode config from ${configPath}:`, error);
}
}
return null;
}
/**
* Fetch usage data from OpenCode
*
* Note: OpenCode's usage API requires browser cookies which we don't have access to.
* This implementation returns basic availability status.
* For full usage tracking, users should check the OpenCode dashboard.
*/
async fetchUsageData(): Promise<OpenCodeProviderUsage> {
logger.info('[fetchUsageData] Starting OpenCode usage fetch...');
const baseUsage: OpenCodeProviderUsage = {
providerId: 'opencode',
providerName: 'OpenCode',
available: false,
lastUpdated: new Date().toISOString(),
};
const config = this.getConfig();
if (!config) {
baseUsage.error = 'OpenCode not configured';
return baseUsage;
}
if (!config.authenticated) {
baseUsage.error = 'OpenCode not authenticated';
return baseUsage;
}
// OpenCode is available but we can't get detailed usage without browser cookies
baseUsage.available = true;
baseUsage.workspaceId = config.workspaceId;
// Note: Full usage tracking requires browser cookie authentication
// which is not available in a server-side context.
// Users should check the OpenCode dashboard for detailed usage.
baseUsage.error =
'Usage details require browser authentication. Check opencode.ai for details.';
logger.info(
`[fetchUsageData] OpenCode available, workspace: ${config.workspaceId || 'unknown'}`
);
return baseUsage;
}
/**
* Clear cached config
*/
clearCache(): void {
this.cachedConfig = null;
}
}

View File

@@ -234,51 +234,75 @@ export class PipelineService {
*
* Determines what status a feature should transition to based on current status.
* Flow: in_progress -> pipeline_step_0 -> pipeline_step_1 -> ... -> final status
* Steps in the excludedStepIds array will be skipped.
*
* @param currentStatus - Current feature status
* @param config - Pipeline configuration (or null if no pipeline)
* @param skipTests - Whether to skip tests (affects final status)
* @param excludedStepIds - Optional array of step IDs to skip
* @returns The next status in the pipeline flow
*/
getNextStatus(
currentStatus: FeatureStatusWithPipeline,
config: PipelineConfig | null,
skipTests: boolean
skipTests: boolean,
excludedStepIds?: string[]
): FeatureStatusWithPipeline {
const steps = config?.steps || [];
const exclusions = new Set(excludedStepIds || []);
// Sort steps by order
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
// Sort steps by order and filter out excluded steps
const sortedSteps = [...steps]
.sort((a, b) => a.order - b.order)
.filter((step) => !exclusions.has(step.id));
// If no pipeline steps, use original logic
// If no pipeline steps (or all excluded), use original logic
if (sortedSteps.length === 0) {
if (currentStatus === 'in_progress') {
// If coming from in_progress or already in a pipeline step, go to final status
if (currentStatus === 'in_progress' || currentStatus.startsWith('pipeline_')) {
return skipTests ? 'waiting_approval' : 'verified';
}
return currentStatus;
}
// Coming from in_progress -> go to first pipeline step
// Coming from in_progress -> go to first non-excluded pipeline step
if (currentStatus === 'in_progress') {
return `pipeline_${sortedSteps[0].id}`;
}
// Coming from a pipeline step -> go to next step or final status
// Coming from a pipeline step -> go to next non-excluded step or final status
if (currentStatus.startsWith('pipeline_')) {
const currentStepId = currentStatus.replace('pipeline_', '');
const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId);
if (currentIndex === -1) {
// Step not found, go to final status
// Current step not found in filtered list (might be excluded or invalid)
// Find next valid step after this one from the original sorted list
const allSortedSteps = [...steps].sort((a, b) => a.order - b.order);
const originalIndex = allSortedSteps.findIndex((s) => s.id === currentStepId);
if (originalIndex === -1) {
// Step truly doesn't exist, go to final status
return skipTests ? 'waiting_approval' : 'verified';
}
// Find the next non-excluded step after the current one
for (let i = originalIndex + 1; i < allSortedSteps.length; i++) {
if (!exclusions.has(allSortedSteps[i].id)) {
return `pipeline_${allSortedSteps[i].id}`;
}
}
// No more non-excluded steps, go to final status
return skipTests ? 'waiting_approval' : 'verified';
}
if (currentIndex < sortedSteps.length - 1) {
// Go to next step
// Go to next non-excluded step
return `pipeline_${sortedSteps[currentIndex + 1].id}`;
}
// Last step completed, go to final status
// Last non-excluded step completed, go to final status
return skipTests ? 'waiting_approval' : 'verified';
}

View File

@@ -0,0 +1,447 @@
/**
* Provider Usage Tracker
*
* Unified service that aggregates usage data from all supported AI providers.
* Manages caching, polling, and coordination of individual usage services.
*
* Supported providers:
* - Claude (via ClaudeUsageService)
* - Codex (via CodexUsageService)
* - Cursor (via CursorUsageService)
* - Gemini (via GeminiUsageService)
* - GitHub Copilot (via CopilotUsageService)
* - OpenCode (via OpenCodeUsageService)
* - MiniMax (via MiniMaxUsageService)
* - GLM (via GLMUsageService)
*/
import { createLogger } from '@automaker/utils';
import type {
UsageProviderId,
ProviderUsage,
AllProvidersUsage,
ClaudeProviderUsage,
CodexProviderUsage,
ClaudeCompatibleProvider,
} from '@automaker/types';
import { ClaudeUsageService } from './claude-usage-service.js';
import { CodexUsageService, type CodexUsageData } from './codex-usage-service.js';
import { CursorUsageService } from './cursor-usage-service.js';
import { GeminiUsageService } from './gemini-usage-service.js';
import { CopilotUsageService } from './copilot-usage-service.js';
import { OpenCodeUsageService } from './opencode-usage-service.js';
import { MiniMaxUsageService } from './minimax-usage-service.js';
import { GLMUsageService } from './glm-usage-service.js';
import type { ClaudeUsage } from '../routes/claude/types.js';
const logger = createLogger('ProviderUsageTracker');
// Cache TTL in milliseconds (1 minute)
const CACHE_TTL_MS = 60 * 1000;
interface CachedUsage {
data: ProviderUsage;
fetchedAt: number;
}
export class ProviderUsageTracker {
private claudeService: ClaudeUsageService;
private codexService: CodexUsageService;
private cursorService: CursorUsageService;
private geminiService: GeminiUsageService;
private copilotService: CopilotUsageService;
private opencodeService: OpenCodeUsageService;
private minimaxService: MiniMaxUsageService;
private glmService: GLMUsageService;
private cache: Map<UsageProviderId, CachedUsage> = new Map();
private enabledProviders: Set<UsageProviderId> = new Set([
'claude',
'codex',
'cursor',
'gemini',
'copilot',
'opencode',
'minimax',
'glm',
]);
constructor(codexService?: CodexUsageService) {
this.claudeService = new ClaudeUsageService();
this.codexService = codexService || new CodexUsageService();
this.cursorService = new CursorUsageService();
this.geminiService = new GeminiUsageService();
this.copilotService = new CopilotUsageService();
this.opencodeService = new OpenCodeUsageService();
this.minimaxService = new MiniMaxUsageService();
this.glmService = new GLMUsageService();
}
/**
* Set enabled providers (called when settings change)
*/
setEnabledProviders(providers: UsageProviderId[]): void {
this.enabledProviders = new Set(providers);
}
/**
* Update custom provider configs (MiniMax, GLM)
*/
updateCustomProviderConfigs(providers: ClaudeCompatibleProvider[]): void {
const minimaxConfig = providers.find(
(p) => p.providerType === 'minimax' && p.enabled !== false
);
const glmConfig = providers.find((p) => p.providerType === 'glm' && p.enabled !== false);
this.minimaxService.setProviderConfig(minimaxConfig || null);
this.glmService.setProviderConfig(glmConfig || null);
}
/**
* Check if a provider is enabled
*/
isProviderEnabled(providerId: UsageProviderId): boolean {
return this.enabledProviders.has(providerId);
}
/**
* Check if cached data is still fresh
*/
private isCacheFresh(providerId: UsageProviderId): boolean {
const cached = this.cache.get(providerId);
if (!cached) return false;
return Date.now() - cached.fetchedAt < CACHE_TTL_MS;
}
/**
* Get cached data for a provider
*/
private getCached(providerId: UsageProviderId): ProviderUsage | null {
const cached = this.cache.get(providerId);
return cached?.data || null;
}
/**
* Set cached data for a provider
*/
private setCached(providerId: UsageProviderId, data: ProviderUsage): void {
this.cache.set(providerId, {
data,
fetchedAt: Date.now(),
});
}
/**
* Convert Claude usage to unified format
*/
private convertClaudeUsage(usage: ClaudeUsage): ClaudeProviderUsage {
return {
providerId: 'claude',
providerName: 'Claude',
available: true,
lastUpdated: usage.lastUpdated,
userTimezone: usage.userTimezone,
primary: {
name: 'Session (5-hour)',
usedPercent: usage.sessionPercentage,
resetsAt: usage.sessionResetTime,
resetText: usage.sessionResetText,
},
secondary: {
name: 'Weekly (All Models)',
usedPercent: usage.weeklyPercentage,
resetsAt: usage.weeklyResetTime,
resetText: usage.weeklyResetText,
},
sessionWindow: {
name: 'Session (5-hour)',
usedPercent: usage.sessionPercentage,
resetsAt: usage.sessionResetTime,
resetText: usage.sessionResetText,
},
weeklyWindow: {
name: 'Weekly (All Models)',
usedPercent: usage.weeklyPercentage,
resetsAt: usage.weeklyResetTime,
resetText: usage.weeklyResetText,
},
sonnetWindow: {
name: 'Weekly (Sonnet)',
usedPercent: usage.sonnetWeeklyPercentage,
resetsAt: usage.weeklyResetTime,
resetText: usage.sonnetResetText,
},
cost:
usage.costUsed !== null
? {
used: usage.costUsed,
limit: usage.costLimit,
currency: usage.costCurrency || 'USD',
}
: undefined,
};
}
/**
* Convert Codex usage to unified format
*/
private convertCodexUsage(usage: CodexUsageData): CodexProviderUsage {
const result: CodexProviderUsage = {
providerId: 'codex',
providerName: 'Codex',
available: true,
lastUpdated: usage.lastUpdated,
planType: usage.rateLimits?.planType,
};
if (usage.rateLimits?.primary) {
result.primary = {
name: `${usage.rateLimits.primary.windowDurationMins}min Window`,
usedPercent: usage.rateLimits.primary.usedPercent,
resetsAt: new Date(usage.rateLimits.primary.resetsAt * 1000).toISOString(),
resetText: this.formatResetTime(usage.rateLimits.primary.resetsAt * 1000),
windowDurationMins: usage.rateLimits.primary.windowDurationMins,
};
}
if (usage.rateLimits?.secondary) {
result.secondary = {
name: `${usage.rateLimits.secondary.windowDurationMins}min Window`,
usedPercent: usage.rateLimits.secondary.usedPercent,
resetsAt: new Date(usage.rateLimits.secondary.resetsAt * 1000).toISOString(),
resetText: this.formatResetTime(usage.rateLimits.secondary.resetsAt * 1000),
windowDurationMins: usage.rateLimits.secondary.windowDurationMins,
};
}
if (usage.rateLimits?.planType) {
result.plan = {
type: usage.rateLimits.planType,
displayName:
usage.rateLimits.planType.charAt(0).toUpperCase() + usage.rateLimits.planType.slice(1),
isPaid: usage.rateLimits.planType !== 'free',
};
}
return result;
}
/**
* Format reset time as human-readable string
*/
private formatResetTime(resetAtMs: number): string {
const diff = resetAtMs - Date.now();
if (diff < 0) return 'Expired';
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `Resets in ${days}d ${hours % 24}h`;
if (hours > 0) return `Resets in ${hours}h ${minutes % 60}m`;
if (minutes > 0) return `Resets in ${minutes}m`;
return 'Resets soon';
}
/**
* Fetch usage for a specific provider
*/
async fetchProviderUsage(
providerId: UsageProviderId,
forceRefresh = false
): Promise<ProviderUsage | null> {
// Check cache first
if (!forceRefresh && this.isCacheFresh(providerId)) {
return this.getCached(providerId);
}
try {
let usage: ProviderUsage | null = null;
switch (providerId) {
case 'claude': {
if (await this.claudeService.isAvailable()) {
const claudeUsage = await this.claudeService.fetchUsageData();
usage = this.convertClaudeUsage(claudeUsage);
} else {
usage = {
providerId: 'claude',
providerName: 'Claude',
available: false,
lastUpdated: new Date().toISOString(),
error: 'Claude CLI not available',
};
}
break;
}
case 'codex': {
if (await this.codexService.isAvailable()) {
const codexUsage = await this.codexService.fetchUsageData();
usage = this.convertCodexUsage(codexUsage);
} else {
usage = {
providerId: 'codex',
providerName: 'Codex',
available: false,
lastUpdated: new Date().toISOString(),
error: 'Codex CLI not available',
};
}
break;
}
case 'cursor': {
usage = await this.cursorService.fetchUsageData();
break;
}
case 'gemini': {
usage = await this.geminiService.fetchUsageData();
break;
}
case 'copilot': {
usage = await this.copilotService.fetchUsageData();
break;
}
case 'opencode': {
usage = await this.opencodeService.fetchUsageData();
break;
}
case 'minimax': {
usage = await this.minimaxService.fetchUsageData();
break;
}
case 'glm': {
usage = await this.glmService.fetchUsageData();
break;
}
}
if (usage) {
this.setCached(providerId, usage);
}
return usage;
} catch (error) {
logger.error(`Failed to fetch usage for ${providerId}:`, error);
return {
providerId,
providerName: this.getProviderName(providerId),
available: false,
lastUpdated: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
} as ProviderUsage;
}
}
/**
* Get provider display name
*/
private getProviderName(providerId: UsageProviderId): string {
const names: Record<UsageProviderId, string> = {
claude: 'Claude',
codex: 'Codex',
cursor: 'Cursor',
gemini: 'Gemini',
copilot: 'GitHub Copilot',
opencode: 'OpenCode',
minimax: 'MiniMax',
glm: 'z.AI GLM',
};
return names[providerId] || providerId;
}
/**
* Fetch usage for all enabled providers
*/
async fetchAllUsage(forceRefresh = false): Promise<AllProvidersUsage> {
const providers: Partial<Record<UsageProviderId, ProviderUsage>> = {};
const errors: Array<{ providerId: UsageProviderId; message: string }> = [];
// Fetch all enabled providers in parallel
const enabledList = Array.from(this.enabledProviders);
const results = await Promise.allSettled(
enabledList.map((providerId) => this.fetchProviderUsage(providerId, forceRefresh))
);
results.forEach((result, index) => {
const providerId = enabledList[index];
if (result.status === 'fulfilled' && result.value) {
providers[providerId] = result.value;
if (result.value.error) {
errors.push({
providerId,
message: result.value.error,
});
}
} else if (result.status === 'rejected') {
errors.push({
providerId,
message: result.reason?.message || 'Unknown error',
});
}
});
return {
providers,
lastUpdated: new Date().toISOString(),
errors,
};
}
/**
* Check availability for all providers
*/
async checkAvailability(): Promise<Record<UsageProviderId, boolean>> {
const availability: Record<string, boolean> = {};
const checks = await Promise.allSettled([
this.claudeService.isAvailable(),
this.codexService.isAvailable(),
this.cursorService.isAvailable(),
this.geminiService.isAvailable(),
this.copilotService.isAvailable(),
this.opencodeService.isAvailable(),
this.minimaxService.isAvailable(),
this.glmService.isAvailable(),
]);
const providerIds: UsageProviderId[] = [
'claude',
'codex',
'cursor',
'gemini',
'copilot',
'opencode',
'minimax',
'glm',
];
checks.forEach((result, index) => {
availability[providerIds[index]] =
result.status === 'fulfilled' ? result.value : false;
});
return availability as Record<UsageProviderId, boolean>;
}
/**
* Clear all caches
*/
clearCache(): void {
this.cache.clear();
this.claudeService = new ClaudeUsageService(); // Reset Claude service
this.cursorService.clearCache();
this.geminiService.clearCache();
this.copilotService.clearCache();
this.opencodeService.clearCache();
this.minimaxService.clearCache();
this.glmService.clearCache();
}
}

View File

@@ -621,6 +621,21 @@ export class SettingsService {
};
}
// Deep merge autoModeByWorktree if provided (preserves other worktree entries)
if (sanitizedUpdates.autoModeByWorktree) {
type WorktreeEntry = { maxConcurrency: number; branchName: string | null };
const mergedAutoModeByWorktree: Record<string, WorktreeEntry> = {
...current.autoModeByWorktree,
};
for (const [key, value] of Object.entries(sanitizedUpdates.autoModeByWorktree)) {
mergedAutoModeByWorktree[key] = {
...mergedAutoModeByWorktree[key],
...value,
};
}
updated.autoModeByWorktree = mergedAutoModeByWorktree;
}
await writeSettingsJson(settingsPath, updated);
logger.info('Global settings updated');
@@ -827,6 +842,30 @@ export class SettingsService {
delete updated.phaseModelOverrides;
}
// Handle defaultFeatureModel special cases:
// - "__CLEAR__" marker means delete the key (use global setting)
// - object means project-specific override
if (
'defaultFeatureModel' in updates &&
(updates as Record<string, unknown>).defaultFeatureModel === '__CLEAR__'
) {
delete updated.defaultFeatureModel;
}
// Handle devCommand special cases:
// - null means delete the key (use auto-detection)
// - string means custom command
if ('devCommand' in updates && updates.devCommand === null) {
delete updated.devCommand;
}
// Handle testCommand special cases:
// - null means delete the key (use auto-detection)
// - string means custom command
if ('testCommand' in updates && updates.testCommand === null) {
delete updated.testCommand;
}
await writeSettingsJson(settingsPath, updated);
logger.info(`Project settings updated for ${projectPath}`);

View File

@@ -0,0 +1,682 @@
/**
* Test Runner Service
*
* Manages test execution processes for git worktrees.
* Runs user-configured test commands with output streaming.
*
* Features:
* - Process management with graceful shutdown
* - Output buffering and throttling for WebSocket streaming
* - Support for running all tests or specific files
* - Cross-platform process cleanup (Windows/Unix)
*/
import { spawn, execSync, type ChildProcess } from 'child_process';
import * as secureFs from '../lib/secure-fs.js';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js';
const logger = createLogger('TestRunnerService');
// Maximum scrollback buffer size (characters)
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per test run
// Throttle output to prevent overwhelming WebSocket under heavy load
// Note: Too aggressive throttling (< 50ms) can cause memory issues and UI crashes
// due to rapid React state updates and string concatenation overhead
const OUTPUT_THROTTLE_MS = 100; // ~10fps - balances responsiveness with stability
const OUTPUT_BATCH_SIZE = 8192; // Larger batch size to reduce event frequency
/**
* Status of a test run
*/
export type TestRunStatus = 'pending' | 'running' | 'passed' | 'failed' | 'cancelled' | 'error';
/**
* Information about an active test run session
*/
export interface TestRunSession {
/** Unique identifier for this test run */
id: string;
/** Path to the worktree where tests are running */
worktreePath: string;
/** The command being run */
command: string;
/** The spawned child process */
process: ChildProcess | null;
/** When the test run started */
startedAt: Date;
/** When the test run finished (if completed) */
finishedAt: Date | null;
/** Current status of the test run */
status: TestRunStatus;
/** Exit code from the process (if completed) */
exitCode: number | null;
/** Specific test file being run (optional) */
testFile?: string;
/** Scrollback buffer for log history (replay on reconnect) */
scrollbackBuffer: string;
/** Pending output to be flushed to subscribers */
outputBuffer: string;
/** Throttle timer for batching output */
flushTimeout: NodeJS.Timeout | null;
/** Flag to indicate session is stopping (prevents output after stop) */
stopping: boolean;
}
/**
* Result of a test run operation
*/
export interface TestRunResult {
success: boolean;
result?: {
sessionId: string;
worktreePath: string;
command: string;
status: TestRunStatus;
testFile?: string;
message: string;
};
error?: string;
}
/**
* Test Runner Service class
* Manages test execution processes across worktrees
*/
class TestRunnerService {
private sessions: Map<string, TestRunSession> = new Map();
private emitter: EventEmitter | null = null;
/**
* Set the event emitter for streaming log events
* Called during service initialization with the global event emitter
*/
setEventEmitter(emitter: EventEmitter): void {
this.emitter = emitter;
}
/**
* Helper to check if a file exists using secureFs
*/
private async fileExists(filePath: string): Promise<boolean> {
try {
await secureFs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Append data to scrollback buffer with size limit enforcement
* Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE
*/
private appendToScrollback(session: TestRunSession, data: string): void {
session.scrollbackBuffer += data;
if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) {
session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
}
}
/**
* Flush buffered output to WebSocket subscribers
* Sends batched output to prevent overwhelming clients under heavy load
*/
private flushOutput(session: TestRunSession): void {
// Skip flush if session is stopping or buffer is empty
if (session.stopping || session.outputBuffer.length === 0) {
session.flushTimeout = null;
return;
}
let dataToSend = session.outputBuffer;
if (dataToSend.length > OUTPUT_BATCH_SIZE) {
// Send in batches if buffer is large
dataToSend = session.outputBuffer.slice(0, OUTPUT_BATCH_SIZE);
session.outputBuffer = session.outputBuffer.slice(OUTPUT_BATCH_SIZE);
// Schedule another flush for remaining data
session.flushTimeout = setTimeout(() => this.flushOutput(session), OUTPUT_THROTTLE_MS);
} else {
session.outputBuffer = '';
session.flushTimeout = null;
}
// Emit output event for WebSocket streaming
if (this.emitter) {
this.emitter.emit('test-runner:output', {
sessionId: session.id,
worktreePath: session.worktreePath,
content: dataToSend,
timestamp: new Date().toISOString(),
});
}
}
/**
* Handle incoming stdout/stderr data from test process
* Buffers data for scrollback replay and schedules throttled emission
*/
private handleProcessOutput(session: TestRunSession, data: Buffer): void {
// Skip output if session is stopping
if (session.stopping) {
return;
}
const content = data.toString();
// Append to scrollback buffer for replay on reconnect
this.appendToScrollback(session, content);
// Buffer output for throttled live delivery
session.outputBuffer += content;
// Schedule flush if not already scheduled
if (!session.flushTimeout) {
session.flushTimeout = setTimeout(() => this.flushOutput(session), OUTPUT_THROTTLE_MS);
}
// Also log for debugging (existing behavior)
logger.debug(`[${session.id}] ${content.trim()}`);
}
/**
* Kill any process running (platform-specific cleanup)
*/
private killProcessTree(pid: number): void {
try {
if (process.platform === 'win32') {
// Windows: use taskkill to kill process tree
execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore' });
} else {
// Unix: kill the process group
try {
process.kill(-pid, 'SIGTERM');
} catch {
// Fallback to killing just the process
process.kill(pid, 'SIGTERM');
}
}
} catch (error) {
logger.debug(`Error killing process ${pid}:`, error);
}
}
/**
* Generate a unique session ID
*/
private generateSessionId(): string {
return `test-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
/**
* Sanitize a test file path to prevent command injection
* Allows only safe characters for file paths
*/
private sanitizeTestFile(testFile: string): string {
// Remove any shell metacharacters and normalize path
// Allow only alphanumeric, dots, slashes, hyphens, underscores, colons (for Windows paths)
return testFile.replace(/[^a-zA-Z0-9.\\/_\-:]/g, '');
}
/**
* Start tests in a worktree using the provided command
*
* @param worktreePath - Path to the worktree where tests should run
* @param options - Configuration for the test run
* @returns TestRunResult with session info or error
*/
async startTests(
worktreePath: string,
options: {
command: string;
testFile?: string;
}
): Promise<TestRunResult> {
const { command, testFile } = options;
// Check if already running
const existingSession = this.getActiveSession(worktreePath);
if (existingSession) {
return {
success: false,
error: `Tests are already running for this worktree (session: ${existingSession.id})`,
};
}
// Verify the worktree exists
if (!(await this.fileExists(worktreePath))) {
return {
success: false,
error: `Worktree path does not exist: ${worktreePath}`,
};
}
if (!command) {
return {
success: false,
error: 'No test command provided',
};
}
// Build the final command (append test file if specified)
let finalCommand = command;
if (testFile) {
// Sanitize test file path to prevent command injection
const sanitizedFile = this.sanitizeTestFile(testFile);
// Append the test file to the command
// Most test runners support: command -- file or command file
finalCommand = `${command} -- ${sanitizedFile}`;
}
// Parse command into cmd and args (shell execution)
// We use shell: true to support complex commands like "npm run test:server"
logger.info(`Starting tests in ${worktreePath}`);
logger.info(`Command: ${finalCommand}`);
// Create session
const sessionId = this.generateSessionId();
const session: TestRunSession = {
id: sessionId,
worktreePath,
command: finalCommand,
process: null,
startedAt: new Date(),
finishedAt: null,
status: 'pending',
exitCode: null,
testFile,
scrollbackBuffer: '',
outputBuffer: '',
flushTimeout: null,
stopping: false,
};
// Spawn the test process using shell
const env = {
...process.env,
FORCE_COLOR: '1',
COLORTERM: 'truecolor',
TERM: 'xterm-256color',
CI: 'true', // Helps some test runners format output better
};
const testProcess = spawn(finalCommand, [], {
cwd: worktreePath,
env,
shell: true,
stdio: ['ignore', 'pipe', 'pipe'],
detached: process.platform !== 'win32', // Use process groups on Unix for cleanup
});
session.process = testProcess;
session.status = 'running';
// Track if process failed early
const status = { error: null as string | null, exited: false };
// Helper to clean up resources and emit events
const cleanupAndFinish = (
exitCode: number | null,
finalStatus: TestRunStatus,
errorMessage?: string
) => {
session.finishedAt = new Date();
session.exitCode = exitCode;
session.status = finalStatus;
if (session.flushTimeout) {
clearTimeout(session.flushTimeout);
session.flushTimeout = null;
}
// Flush any remaining output
if (session.outputBuffer.length > 0 && this.emitter && !session.stopping) {
this.emitter.emit('test-runner:output', {
sessionId: session.id,
worktreePath: session.worktreePath,
content: session.outputBuffer,
timestamp: new Date().toISOString(),
});
session.outputBuffer = '';
}
// Emit completed event
if (this.emitter && !session.stopping) {
this.emitter.emit('test-runner:completed', {
sessionId: session.id,
worktreePath: session.worktreePath,
command: session.command,
status: finalStatus,
exitCode,
error: errorMessage,
duration: session.finishedAt.getTime() - session.startedAt.getTime(),
timestamp: new Date().toISOString(),
});
}
};
// Capture stdout
if (testProcess.stdout) {
testProcess.stdout.on('data', (data: Buffer) => {
this.handleProcessOutput(session, data);
});
}
// Capture stderr
if (testProcess.stderr) {
testProcess.stderr.on('data', (data: Buffer) => {
this.handleProcessOutput(session, data);
});
}
testProcess.on('error', (error) => {
logger.error(`Process error for ${sessionId}:`, error);
status.error = error.message;
cleanupAndFinish(null, 'error', error.message);
});
testProcess.on('exit', (code) => {
logger.info(`Test process for ${worktreePath} exited with code ${code}`);
status.exited = true;
// Determine final status based on exit code
let finalStatus: TestRunStatus;
if (session.stopping) {
finalStatus = 'cancelled';
} else if (code === 0) {
finalStatus = 'passed';
} else {
finalStatus = 'failed';
}
cleanupAndFinish(code, finalStatus);
});
// Store session
this.sessions.set(sessionId, session);
// Wait a moment to see if the process fails immediately
await new Promise((resolve) => setTimeout(resolve, 200));
if (status.error) {
return {
success: false,
error: `Failed to start tests: ${status.error}`,
};
}
if (status.exited) {
// Process already exited - check if it was immediate failure
const exitedSession = this.sessions.get(sessionId);
if (exitedSession && exitedSession.status === 'error') {
return {
success: false,
error: `Test process exited immediately. Check output for details.`,
};
}
}
// Emit started event
if (this.emitter) {
this.emitter.emit('test-runner:started', {
sessionId,
worktreePath,
command: finalCommand,
testFile,
timestamp: new Date().toISOString(),
});
}
return {
success: true,
result: {
sessionId,
worktreePath,
command: finalCommand,
status: 'running',
testFile,
message: `Tests started: ${finalCommand}`,
},
};
}
/**
* Stop a running test session
*
* @param sessionId - The ID of the test session to stop
* @returns Result with success status and message
*/
async stopTests(sessionId: string): Promise<{
success: boolean;
result?: { sessionId: string; message: string };
error?: string;
}> {
const session = this.sessions.get(sessionId);
if (!session) {
return {
success: false,
error: `Test session not found: ${sessionId}`,
};
}
if (session.status !== 'running') {
return {
success: true,
result: {
sessionId,
message: `Tests already finished (status: ${session.status})`,
},
};
}
logger.info(`Cancelling test session ${sessionId}`);
// Mark as stopping to prevent further output events
session.stopping = true;
// Clean up flush timeout
if (session.flushTimeout) {
clearTimeout(session.flushTimeout);
session.flushTimeout = null;
}
// Kill the process
if (session.process && !session.process.killed && session.process.pid) {
this.killProcessTree(session.process.pid);
}
session.status = 'cancelled';
session.finishedAt = new Date();
// Emit cancelled event
if (this.emitter) {
this.emitter.emit('test-runner:completed', {
sessionId,
worktreePath: session.worktreePath,
command: session.command,
status: 'cancelled',
exitCode: null,
duration: session.finishedAt.getTime() - session.startedAt.getTime(),
timestamp: new Date().toISOString(),
});
}
return {
success: true,
result: {
sessionId,
message: 'Test run cancelled',
},
};
}
/**
* Get the active test session for a worktree
*/
getActiveSession(worktreePath: string): TestRunSession | undefined {
for (const session of this.sessions.values()) {
if (session.worktreePath === worktreePath && session.status === 'running') {
return session;
}
}
return undefined;
}
/**
* Get a test session by ID
*/
getSession(sessionId: string): TestRunSession | undefined {
return this.sessions.get(sessionId);
}
/**
* Get buffered output for a test session
*/
getSessionOutput(sessionId: string): {
success: boolean;
result?: {
sessionId: string;
output: string;
status: TestRunStatus;
startedAt: string;
finishedAt: string | null;
};
error?: string;
} {
const session = this.sessions.get(sessionId);
if (!session) {
return {
success: false,
error: `Test session not found: ${sessionId}`,
};
}
return {
success: true,
result: {
sessionId,
output: session.scrollbackBuffer,
status: session.status,
startedAt: session.startedAt.toISOString(),
finishedAt: session.finishedAt?.toISOString() || null,
},
};
}
/**
* List all test sessions (optionally filter by worktree)
*/
listSessions(worktreePath?: string): {
success: boolean;
result: {
sessions: Array<{
sessionId: string;
worktreePath: string;
command: string;
status: TestRunStatus;
testFile?: string;
startedAt: string;
finishedAt: string | null;
exitCode: number | null;
}>;
};
} {
let sessions = Array.from(this.sessions.values());
if (worktreePath) {
sessions = sessions.filter((s) => s.worktreePath === worktreePath);
}
return {
success: true,
result: {
sessions: sessions.map((s) => ({
sessionId: s.id,
worktreePath: s.worktreePath,
command: s.command,
status: s.status,
testFile: s.testFile,
startedAt: s.startedAt.toISOString(),
finishedAt: s.finishedAt?.toISOString() || null,
exitCode: s.exitCode,
})),
},
};
}
/**
* Check if a worktree has an active test run
*/
isRunning(worktreePath: string): boolean {
return this.getActiveSession(worktreePath) !== undefined;
}
/**
* Clean up old completed sessions (keep only recent ones)
*/
cleanupOldSessions(maxAgeMs: number = 30 * 60 * 1000): void {
const now = Date.now();
for (const [sessionId, session] of this.sessions.entries()) {
if (session.status !== 'running' && session.finishedAt) {
if (now - session.finishedAt.getTime() > maxAgeMs) {
this.sessions.delete(sessionId);
logger.debug(`Cleaned up old test session: ${sessionId}`);
}
}
}
}
/**
* Cancel all running test sessions (for cleanup)
*/
async cancelAll(): Promise<void> {
logger.info(`Cancelling all ${this.sessions.size} test sessions`);
for (const session of this.sessions.values()) {
if (session.status === 'running') {
await this.stopTests(session.id);
}
}
}
/**
* Cleanup service resources
*/
async cleanup(): Promise<void> {
await this.cancelAll();
this.sessions.clear();
}
}
// Singleton instance
let testRunnerServiceInstance: TestRunnerService | null = null;
export function getTestRunnerService(): TestRunnerService {
if (!testRunnerServiceInstance) {
testRunnerServiceInstance = new TestRunnerService();
}
return testRunnerServiceInstance;
}
// Cleanup on process exit
process.on('SIGTERM', () => {
if (testRunnerServiceInstance) {
testRunnerServiceInstance.cleanup().catch((err) => {
logger.error('Cleanup failed on SIGTERM:', err);
});
}
});
process.on('SIGINT', () => {
if (testRunnerServiceInstance) {
testRunnerServiceInstance.cleanup().catch((err) => {
logger.error('Cleanup failed on SIGINT:', err);
});
}
});
// Export the class for testing purposes
export { TestRunnerService };

View File

@@ -4,6 +4,7 @@ import { ClaudeProvider } from '@/providers/claude-provider.js';
import { CursorProvider } from '@/providers/cursor-provider.js';
import { CodexProvider } from '@/providers/codex-provider.js';
import { OpencodeProvider } from '@/providers/opencode-provider.js';
import { GeminiProvider } from '@/providers/gemini-provider.js';
describe('provider-factory.ts', () => {
let consoleSpy: any;
@@ -11,6 +12,7 @@ describe('provider-factory.ts', () => {
let detectCursorSpy: any;
let detectCodexSpy: any;
let detectOpencodeSpy: any;
let detectGeminiSpy: any;
beforeEach(() => {
consoleSpy = {
@@ -30,6 +32,9 @@ describe('provider-factory.ts', () => {
detectOpencodeSpy = vi
.spyOn(OpencodeProvider.prototype, 'detectInstallation')
.mockResolvedValue({ installed: true });
detectGeminiSpy = vi
.spyOn(GeminiProvider.prototype, 'detectInstallation')
.mockResolvedValue({ installed: true });
});
afterEach(() => {
@@ -38,6 +43,7 @@ describe('provider-factory.ts', () => {
detectCursorSpy.mockRestore();
detectCodexSpy.mockRestore();
detectOpencodeSpy.mockRestore();
detectGeminiSpy.mockRestore();
});
describe('getProviderForModel', () => {
@@ -166,9 +172,15 @@ describe('provider-factory.ts', () => {
expect(hasClaudeProvider).toBe(true);
});
it('should return exactly 4 providers', () => {
it('should return exactly 5 providers', () => {
const providers = ProviderFactory.getAllProviders();
expect(providers).toHaveLength(4);
expect(providers).toHaveLength(5);
});
it('should include GeminiProvider', () => {
const providers = ProviderFactory.getAllProviders();
const hasGeminiProvider = providers.some((p) => p instanceof GeminiProvider);
expect(hasGeminiProvider).toBe(true);
});
it('should include CursorProvider', () => {
@@ -206,7 +218,8 @@ describe('provider-factory.ts', () => {
expect(keys).toContain('cursor');
expect(keys).toContain('codex');
expect(keys).toContain('opencode');
expect(keys).toHaveLength(4);
expect(keys).toContain('gemini');
expect(keys).toHaveLength(5);
});
it('should include cursor status', async () => {

View File

@@ -0,0 +1,565 @@
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import type { Request, Response } from 'express';
import { createMockExpressContext } from '../../../utils/mocks.js';
// Mock child_process with importOriginal to keep other exports
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
execFile: vi.fn(),
};
});
// Mock util.promisify to return the function as-is so we can mock execFile
vi.mock('util', async (importOriginal) => {
const actual = await importOriginal<typeof import('util')>();
return {
...actual,
promisify: (fn: unknown) => fn,
};
});
// Import handler after mocks are set up
import { createAddRemoteHandler } from '@/routes/worktree/routes/add-remote.js';
import { execFile } from 'child_process';
// Get the mocked execFile
const mockExecFile = execFile as Mock;
/**
* Helper to create a standard mock implementation for git commands
*/
function createGitMock(options: {
existingRemotes?: string[];
addRemoteFails?: boolean;
addRemoteError?: string;
fetchFails?: boolean;
}): (command: string, args: string[]) => Promise<{ stdout: string; stderr: string }> {
const {
existingRemotes = [],
addRemoteFails = false,
addRemoteError = 'git remote add failed',
fetchFails = false,
} = options;
return (command: string, args: string[]) => {
if (command === 'git' && args[0] === 'remote' && args.length === 1) {
return Promise.resolve({ stdout: existingRemotes.join('\n'), stderr: '' });
}
if (command === 'git' && args[0] === 'remote' && args[1] === 'add') {
if (addRemoteFails) {
return Promise.reject(new Error(addRemoteError));
}
return Promise.resolve({ stdout: '', stderr: '' });
}
if (command === 'git' && args[0] === 'fetch') {
if (fetchFails) {
return Promise.reject(new Error('fetch failed'));
}
return Promise.resolve({ stdout: '', stderr: '' });
}
return Promise.resolve({ stdout: '', stderr: '' });
};
}
describe('add-remote route', () => {
let req: Request;
let res: Response;
beforeEach(() => {
vi.clearAllMocks();
const context = createMockExpressContext();
req = context.req;
res = context.res;
});
describe('input validation', () => {
it('should return 400 if worktreePath is missing', async () => {
req.body = { remoteName: 'origin', remoteUrl: 'https://github.com/user/repo.git' };
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'worktreePath required',
});
});
it('should return 400 if remoteName is missing', async () => {
req.body = { worktreePath: '/test/path', remoteUrl: 'https://github.com/user/repo.git' };
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'remoteName required',
});
});
it('should return 400 if remoteUrl is missing', async () => {
req.body = { worktreePath: '/test/path', remoteName: 'origin' };
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'remoteUrl required',
});
});
});
describe('remote name validation', () => {
it('should return 400 for empty remote name', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: '',
remoteUrl: 'https://github.com/user/repo.git',
};
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'remoteName required',
});
});
it('should return 400 for remote name starting with dash', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: '-invalid',
remoteUrl: 'https://github.com/user/repo.git',
};
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error:
'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.',
});
});
it('should return 400 for remote name starting with period', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: '.invalid',
remoteUrl: 'https://github.com/user/repo.git',
};
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error:
'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.',
});
});
it('should return 400 for remote name with invalid characters', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: 'invalid name',
remoteUrl: 'https://github.com/user/repo.git',
};
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error:
'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.',
});
});
it('should return 400 for remote name exceeding 250 characters', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: 'a'.repeat(251),
remoteUrl: 'https://github.com/user/repo.git',
};
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error:
'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.',
});
});
it('should accept valid remote names with alphanumeric, dashes, underscores, and periods', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: 'my-remote_name.1',
remoteUrl: 'https://github.com/user/repo.git',
};
// Mock git remote to return empty list (no existing remotes)
mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] }));
const handler = createAddRemoteHandler();
await handler(req, res);
// Should not return 400 for invalid name
expect(res.status).not.toHaveBeenCalledWith(400);
});
});
describe('remote URL validation', () => {
it('should return 400 for empty remote URL', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: 'origin',
remoteUrl: '',
};
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'remoteUrl required',
});
});
it('should return 400 for invalid remote URL', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: 'origin',
remoteUrl: 'not-a-valid-url',
};
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Invalid remote URL. Must be a valid git URL (HTTPS, SSH, or git:// protocol).',
});
});
it('should return 400 for URL exceeding 2048 characters', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: 'origin',
remoteUrl: 'https://github.com/' + 'a'.repeat(2049) + '.git',
};
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Invalid remote URL. Must be a valid git URL (HTTPS, SSH, or git:// protocol).',
});
});
it('should accept HTTPS URLs', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: 'origin',
remoteUrl: 'https://github.com/user/repo.git',
};
mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] }));
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).not.toHaveBeenCalledWith(400);
});
it('should accept HTTP URLs', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: 'origin',
remoteUrl: 'http://github.com/user/repo.git',
};
mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] }));
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).not.toHaveBeenCalledWith(400);
});
it('should accept SSH URLs', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: 'origin',
remoteUrl: 'git@github.com:user/repo.git',
};
mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] }));
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).not.toHaveBeenCalledWith(400);
});
it('should accept git:// protocol URLs', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: 'origin',
remoteUrl: 'git://github.com/user/repo.git',
};
mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] }));
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).not.toHaveBeenCalledWith(400);
});
it('should accept ssh:// protocol URLs', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: 'origin',
remoteUrl: 'ssh://git@github.com/user/repo.git',
};
mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] }));
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).not.toHaveBeenCalledWith(400);
});
});
describe('remote already exists check', () => {
it('should return 400 with REMOTE_EXISTS code when remote already exists', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: 'origin',
remoteUrl: 'https://github.com/user/repo.git',
};
mockExecFile.mockImplementation(createGitMock({ existingRemotes: ['origin', 'upstream'] }));
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Remote 'origin' already exists",
code: 'REMOTE_EXISTS',
});
});
it('should proceed if remote does not exist', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: 'new-remote',
remoteUrl: 'https://github.com/user/repo.git',
};
mockExecFile.mockImplementation(createGitMock({ existingRemotes: ['origin'] }));
const handler = createAddRemoteHandler();
await handler(req, res);
// Should call git remote add with array arguments
expect(mockExecFile).toHaveBeenCalledWith(
'git',
['remote', 'add', 'new-remote', 'https://github.com/user/repo.git'],
expect.any(Object)
);
});
});
describe('successful remote addition', () => {
it('should add remote successfully with successful fetch', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: 'upstream',
remoteUrl: 'https://github.com/other/repo.git',
};
mockExecFile.mockImplementation(
createGitMock({ existingRemotes: ['origin'], fetchFails: false })
);
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
result: {
remoteName: 'upstream',
remoteUrl: 'https://github.com/other/repo.git',
fetched: true,
message: "Successfully added remote 'upstream' and fetched its branches",
},
});
});
it('should add remote successfully even if fetch fails', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: 'upstream',
remoteUrl: 'https://github.com/other/repo.git',
};
mockExecFile.mockImplementation(
createGitMock({ existingRemotes: ['origin'], fetchFails: true })
);
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
result: {
remoteName: 'upstream',
remoteUrl: 'https://github.com/other/repo.git',
fetched: false,
message:
"Successfully added remote 'upstream' (fetch failed - you may need to fetch manually)",
},
});
});
it('should pass correct cwd option to git commands', async () => {
req.body = {
worktreePath: '/custom/worktree/path',
remoteName: 'origin',
remoteUrl: 'https://github.com/user/repo.git',
};
const execCalls: { command: string; args: string[]; options: unknown }[] = [];
mockExecFile.mockImplementation((command: string, args: string[], options: unknown) => {
execCalls.push({ command, args, options });
if (command === 'git' && args[0] === 'remote' && args.length === 1) {
return Promise.resolve({ stdout: '', stderr: '' });
}
return Promise.resolve({ stdout: '', stderr: '' });
});
const handler = createAddRemoteHandler();
await handler(req, res);
// Check that git remote was called with correct cwd
expect((execCalls[0].options as { cwd: string }).cwd).toBe('/custom/worktree/path');
// Check that git remote add was called with correct cwd
expect((execCalls[1].options as { cwd: string }).cwd).toBe('/custom/worktree/path');
});
});
describe('error handling', () => {
it('should return 500 when git remote add fails', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: 'origin',
remoteUrl: 'https://github.com/user/repo.git',
};
mockExecFile.mockImplementation(
createGitMock({
existingRemotes: [],
addRemoteFails: true,
addRemoteError: 'git remote add failed',
})
);
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'git remote add failed',
});
});
it('should continue adding remote if git remote check fails', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: 'origin',
remoteUrl: 'https://github.com/user/repo.git',
};
mockExecFile.mockImplementation((command: string, args: string[]) => {
if (command === 'git' && args[0] === 'remote' && args.length === 1) {
return Promise.reject(new Error('not a git repo'));
}
if (command === 'git' && args[0] === 'remote' && args[1] === 'add') {
return Promise.resolve({ stdout: '', stderr: '' });
}
if (command === 'git' && args[0] === 'fetch') {
return Promise.resolve({ stdout: '', stderr: '' });
}
return Promise.resolve({ stdout: '', stderr: '' });
});
const handler = createAddRemoteHandler();
await handler(req, res);
// Should still try to add remote with array arguments
expect(mockExecFile).toHaveBeenCalledWith(
'git',
['remote', 'add', 'origin', 'https://github.com/user/repo.git'],
expect.any(Object)
);
expect(res.json).toHaveBeenCalledWith({
success: true,
result: expect.objectContaining({
remoteName: 'origin',
}),
});
});
it('should handle non-Error exceptions', async () => {
req.body = {
worktreePath: '/test/path',
remoteName: 'origin',
remoteUrl: 'https://github.com/user/repo.git',
};
mockExecFile.mockImplementation((command: string, args: string[]) => {
if (command === 'git' && args[0] === 'remote' && args.length === 1) {
return Promise.resolve({ stdout: '', stderr: '' });
}
if (command === 'git' && args[0] === 'remote' && args[1] === 'add') {
return Promise.reject('String error');
}
return Promise.resolve({ stdout: '', stderr: '' });
});
const handler = createAddRemoteHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: expect.any(String),
});
});
});
});

View File

@@ -0,0 +1,623 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FeatureExportService, FEATURE_EXPORT_VERSION } from '@/services/feature-export-service.js';
import type { Feature, FeatureExport } from '@automaker/types';
import type { FeatureLoader } from '@/services/feature-loader.js';
describe('feature-export-service.ts', () => {
let exportService: FeatureExportService;
let mockFeatureLoader: {
get: ReturnType<typeof vi.fn>;
getAll: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
generateFeatureId: ReturnType<typeof vi.fn>;
};
const testProjectPath = '/test/project';
const sampleFeature: Feature = {
id: 'feature-123-abc',
title: 'Test Feature',
category: 'UI',
description: 'A test feature description',
status: 'pending',
priority: 1,
dependencies: ['feature-456'],
descriptionHistory: [
{
description: 'Initial description',
timestamp: '2024-01-01T00:00:00.000Z',
source: 'initial',
},
],
planSpec: {
status: 'generated',
content: 'Plan content',
version: 1,
reviewedByUser: false,
},
imagePaths: ['/tmp/image1.png', '/tmp/image2.jpg'],
textFilePaths: [
{
id: 'file-1',
path: '/tmp/doc.txt',
filename: 'doc.txt',
mimeType: 'text/plain',
content: 'Some content',
},
],
};
beforeEach(() => {
vi.clearAllMocks();
// Create mock FeatureLoader instance
mockFeatureLoader = {
get: vi.fn(),
getAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
generateFeatureId: vi.fn().mockReturnValue('feature-mock-id'),
};
// Inject mock via constructor
exportService = new FeatureExportService(mockFeatureLoader as unknown as FeatureLoader);
});
describe('exportFeatureData', () => {
it('should export feature to JSON format', () => {
const result = exportService.exportFeatureData(sampleFeature, { format: 'json' });
const parsed = JSON.parse(result) as FeatureExport;
expect(parsed.version).toBe(FEATURE_EXPORT_VERSION);
expect(parsed.feature.id).toBe(sampleFeature.id);
expect(parsed.feature.title).toBe(sampleFeature.title);
expect(parsed.exportedAt).toBeDefined();
});
it('should export feature to YAML format', () => {
const result = exportService.exportFeatureData(sampleFeature, { format: 'yaml' });
expect(result).toContain('version:');
expect(result).toContain('feature:');
expect(result).toContain('Test Feature');
expect(result).toContain('exportedAt:');
});
it('should exclude description history when option is false', () => {
const result = exportService.exportFeatureData(sampleFeature, {
format: 'json',
includeHistory: false,
});
const parsed = JSON.parse(result) as FeatureExport;
expect(parsed.feature.descriptionHistory).toBeUndefined();
});
it('should include description history by default', () => {
const result = exportService.exportFeatureData(sampleFeature, { format: 'json' });
const parsed = JSON.parse(result) as FeatureExport;
expect(parsed.feature.descriptionHistory).toBeDefined();
expect(parsed.feature.descriptionHistory).toHaveLength(1);
});
it('should exclude plan spec when option is false', () => {
const result = exportService.exportFeatureData(sampleFeature, {
format: 'json',
includePlanSpec: false,
});
const parsed = JSON.parse(result) as FeatureExport;
expect(parsed.feature.planSpec).toBeUndefined();
});
it('should include plan spec by default', () => {
const result = exportService.exportFeatureData(sampleFeature, { format: 'json' });
const parsed = JSON.parse(result) as FeatureExport;
expect(parsed.feature.planSpec).toBeDefined();
});
it('should include metadata when provided', () => {
const result = exportService.exportFeatureData(sampleFeature, {
format: 'json',
metadata: { projectName: 'TestProject', branch: 'main' },
});
const parsed = JSON.parse(result) as FeatureExport;
expect(parsed.metadata).toEqual({ projectName: 'TestProject', branch: 'main' });
});
it('should include exportedBy when provided', () => {
const result = exportService.exportFeatureData(sampleFeature, {
format: 'json',
exportedBy: 'test-user',
});
const parsed = JSON.parse(result) as FeatureExport;
expect(parsed.exportedBy).toBe('test-user');
});
it('should remove transient fields (titleGenerating, error)', () => {
const featureWithTransient: Feature = {
...sampleFeature,
titleGenerating: true,
error: 'Some error',
};
const result = exportService.exportFeatureData(featureWithTransient, { format: 'json' });
const parsed = JSON.parse(result) as FeatureExport;
expect(parsed.feature.titleGenerating).toBeUndefined();
expect(parsed.feature.error).toBeUndefined();
});
it('should support compact JSON (prettyPrint: false)', () => {
const prettyResult = exportService.exportFeatureData(sampleFeature, {
format: 'json',
prettyPrint: true,
});
const compactResult = exportService.exportFeatureData(sampleFeature, {
format: 'json',
prettyPrint: false,
});
// Compact should have no newlines/indentation
expect(compactResult).not.toContain('\n');
// Pretty should have newlines
expect(prettyResult).toContain('\n');
});
});
describe('exportFeature', () => {
it('should fetch and export feature by ID', async () => {
mockFeatureLoader.get.mockResolvedValue(sampleFeature);
const result = await exportService.exportFeature(testProjectPath, 'feature-123-abc');
expect(mockFeatureLoader.get).toHaveBeenCalledWith(testProjectPath, 'feature-123-abc');
const parsed = JSON.parse(result) as FeatureExport;
expect(parsed.feature.id).toBe(sampleFeature.id);
});
it('should throw when feature not found', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
await expect(exportService.exportFeature(testProjectPath, 'nonexistent')).rejects.toThrow(
'Feature nonexistent not found'
);
});
});
describe('exportFeatures', () => {
const features: Feature[] = [
{ ...sampleFeature, id: 'feature-1', category: 'UI' },
{ ...sampleFeature, id: 'feature-2', category: 'Backend', status: 'completed' },
{ ...sampleFeature, id: 'feature-3', category: 'UI', status: 'pending' },
];
it('should export all features', async () => {
mockFeatureLoader.getAll.mockResolvedValue(features);
const result = await exportService.exportFeatures(testProjectPath);
const parsed = JSON.parse(result);
expect(parsed.count).toBe(3);
expect(parsed.features).toHaveLength(3);
});
it('should filter by category', async () => {
mockFeatureLoader.getAll.mockResolvedValue(features);
const result = await exportService.exportFeatures(testProjectPath, { category: 'UI' });
const parsed = JSON.parse(result);
expect(parsed.count).toBe(2);
expect(parsed.features.every((f: FeatureExport) => f.feature.category === 'UI')).toBe(true);
});
it('should filter by status', async () => {
mockFeatureLoader.getAll.mockResolvedValue(features);
const result = await exportService.exportFeatures(testProjectPath, { status: 'completed' });
const parsed = JSON.parse(result);
expect(parsed.count).toBe(1);
expect(parsed.features[0].feature.status).toBe('completed');
});
it('should filter by feature IDs', async () => {
mockFeatureLoader.getAll.mockResolvedValue(features);
const result = await exportService.exportFeatures(testProjectPath, {
featureIds: ['feature-1', 'feature-3'],
});
const parsed = JSON.parse(result);
expect(parsed.count).toBe(2);
const ids = parsed.features.map((f: FeatureExport) => f.feature.id);
expect(ids).toContain('feature-1');
expect(ids).toContain('feature-3');
expect(ids).not.toContain('feature-2');
});
it('should export to YAML format', async () => {
mockFeatureLoader.getAll.mockResolvedValue(features);
const result = await exportService.exportFeatures(testProjectPath, { format: 'yaml' });
expect(result).toContain('version:');
expect(result).toContain('count:');
expect(result).toContain('features:');
});
it('should include metadata when provided', async () => {
mockFeatureLoader.getAll.mockResolvedValue(features);
const result = await exportService.exportFeatures(testProjectPath, {
metadata: { projectName: 'TestProject' },
});
const parsed = JSON.parse(result);
expect(parsed.metadata).toEqual({ projectName: 'TestProject' });
});
});
describe('parseImportData', () => {
it('should parse valid JSON', () => {
const json = JSON.stringify(sampleFeature);
const result = exportService.parseImportData(json);
expect(result).toBeDefined();
expect((result as Feature).id).toBe(sampleFeature.id);
});
it('should parse valid YAML', () => {
const yaml = `
id: feature-yaml-123
title: YAML Feature
category: Testing
description: A YAML feature
`;
const result = exportService.parseImportData(yaml);
expect(result).toBeDefined();
expect((result as Feature).id).toBe('feature-yaml-123');
expect((result as Feature).title).toBe('YAML Feature');
});
it('should return null for invalid data', () => {
const result = exportService.parseImportData('not valid {json} or yaml: [');
expect(result).toBeNull();
});
it('should parse FeatureExport wrapper', () => {
const exportData: FeatureExport = {
version: '1.0.0',
feature: sampleFeature,
exportedAt: new Date().toISOString(),
};
const json = JSON.stringify(exportData);
const result = exportService.parseImportData(json) as FeatureExport;
expect(result.version).toBe('1.0.0');
expect(result.feature.id).toBe(sampleFeature.id);
});
});
describe('detectFormat', () => {
it('should detect JSON format', () => {
const json = JSON.stringify({ id: 'test' });
expect(exportService.detectFormat(json)).toBe('json');
});
it('should detect YAML format', () => {
const yaml = `
id: test
title: Test
`;
expect(exportService.detectFormat(yaml)).toBe('yaml');
});
it('should detect YAML for plain text (YAML is very permissive)', () => {
// YAML parses any plain text as a string, so this is detected as valid YAML
// The actual validation happens in parseImportData which checks for required fields
expect(exportService.detectFormat('not valid {[')).toBe('yaml');
});
it('should handle whitespace', () => {
const json = ' { "id": "test" } ';
expect(exportService.detectFormat(json)).toBe('json');
});
});
describe('importFeature', () => {
it('should import feature from raw Feature data', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
const result = await exportService.importFeature(testProjectPath, {
data: sampleFeature,
});
expect(result.success).toBe(true);
expect(result.featureId).toBe(sampleFeature.id);
expect(mockFeatureLoader.create).toHaveBeenCalled();
});
it('should import feature from FeatureExport wrapper', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
const exportData: FeatureExport = {
version: '1.0.0',
feature: sampleFeature,
exportedAt: new Date().toISOString(),
};
const result = await exportService.importFeature(testProjectPath, {
data: exportData,
});
expect(result.success).toBe(true);
expect(result.featureId).toBe(sampleFeature.id);
});
it('should use custom ID when provided', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
...sampleFeature,
id: data.id!,
}));
const result = await exportService.importFeature(testProjectPath, {
data: sampleFeature,
newId: 'custom-id-123',
});
expect(result.success).toBe(true);
expect(result.featureId).toBe('custom-id-123');
});
it('should fail when feature exists and overwrite is false', async () => {
mockFeatureLoader.get.mockResolvedValue(sampleFeature);
const result = await exportService.importFeature(testProjectPath, {
data: sampleFeature,
overwrite: false,
});
expect(result.success).toBe(false);
expect(result.errors).toContain(
`Feature with ID ${sampleFeature.id} already exists. Set overwrite: true to replace.`
);
});
it('should overwrite when overwrite is true', async () => {
mockFeatureLoader.get.mockResolvedValue(sampleFeature);
mockFeatureLoader.update.mockResolvedValue(sampleFeature);
const result = await exportService.importFeature(testProjectPath, {
data: sampleFeature,
overwrite: true,
});
expect(result.success).toBe(true);
expect(result.wasOverwritten).toBe(true);
expect(mockFeatureLoader.update).toHaveBeenCalled();
});
it('should apply target category override', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
...sampleFeature,
...data,
}));
await exportService.importFeature(testProjectPath, {
data: sampleFeature,
targetCategory: 'NewCategory',
});
const createCall = mockFeatureLoader.create.mock.calls[0];
expect(createCall[1].category).toBe('NewCategory');
});
it('should clear branch info when preserveBranchInfo is false', async () => {
const featureWithBranch: Feature = {
...sampleFeature,
branchName: 'feature/test-branch',
};
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
...featureWithBranch,
...data,
}));
await exportService.importFeature(testProjectPath, {
data: featureWithBranch,
preserveBranchInfo: false,
});
const createCall = mockFeatureLoader.create.mock.calls[0];
expect(createCall[1].branchName).toBeUndefined();
});
it('should preserve branch info when preserveBranchInfo is true', async () => {
const featureWithBranch: Feature = {
...sampleFeature,
branchName: 'feature/test-branch',
};
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
...featureWithBranch,
...data,
}));
await exportService.importFeature(testProjectPath, {
data: featureWithBranch,
preserveBranchInfo: true,
});
const createCall = mockFeatureLoader.create.mock.calls[0];
expect(createCall[1].branchName).toBe('feature/test-branch');
});
it('should warn and clear image paths', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
const result = await exportService.importFeature(testProjectPath, {
data: sampleFeature,
});
expect(result.warnings).toBeDefined();
expect(result.warnings).toContainEqual(expect.stringContaining('image path'));
const createCall = mockFeatureLoader.create.mock.calls[0];
expect(createCall[1].imagePaths).toEqual([]);
});
it('should warn and clear text file paths', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
const result = await exportService.importFeature(testProjectPath, {
data: sampleFeature,
});
expect(result.warnings).toBeDefined();
expect(result.warnings).toContainEqual(expect.stringContaining('text file path'));
const createCall = mockFeatureLoader.create.mock.calls[0];
expect(createCall[1].textFilePaths).toEqual([]);
});
it('should fail with validation error for missing required fields', async () => {
const invalidFeature = {
id: 'feature-invalid',
// Missing description, title, and category
} as Feature;
const result = await exportService.importFeature(testProjectPath, {
data: invalidFeature,
});
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
expect(result.errors!.some((e) => e.includes('title or description'))).toBe(true);
});
it('should generate ID when none provided', async () => {
const featureWithoutId = {
title: 'No ID Feature',
category: 'Testing',
description: 'Feature without ID',
} as Feature;
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
...featureWithoutId,
id: data.id!,
}));
const result = await exportService.importFeature(testProjectPath, {
data: featureWithoutId,
});
expect(result.success).toBe(true);
expect(result.featureId).toBe('feature-mock-id');
});
});
describe('importFeatures', () => {
const bulkExport = {
version: '1.0.0',
exportedAt: new Date().toISOString(),
count: 2,
features: [
{
version: '1.0.0',
feature: { ...sampleFeature, id: 'feature-1' },
exportedAt: new Date().toISOString(),
},
{
version: '1.0.0',
feature: { ...sampleFeature, id: 'feature-2' },
exportedAt: new Date().toISOString(),
},
],
};
it('should import multiple features from JSON string', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
...sampleFeature,
id: data.id!,
}));
const results = await exportService.importFeatures(
testProjectPath,
JSON.stringify(bulkExport)
);
expect(results).toHaveLength(2);
expect(results[0].success).toBe(true);
expect(results[1].success).toBe(true);
});
it('should import multiple features from parsed data', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
...sampleFeature,
id: data.id!,
}));
const results = await exportService.importFeatures(testProjectPath, bulkExport);
expect(results).toHaveLength(2);
expect(results.every((r) => r.success)).toBe(true);
});
it('should apply options to all features', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
...sampleFeature,
...data,
}));
await exportService.importFeatures(testProjectPath, bulkExport, {
targetCategory: 'ImportedCategory',
});
const createCalls = mockFeatureLoader.create.mock.calls;
expect(createCalls[0][1].category).toBe('ImportedCategory');
expect(createCalls[1][1].category).toBe('ImportedCategory');
});
it('should return error for invalid bulk format', async () => {
const results = await exportService.importFeatures(testProjectPath, '{ "invalid": "data" }');
expect(results).toHaveLength(1);
expect(results[0].success).toBe(false);
expect(results[0].errors).toContainEqual(expect.stringContaining('Invalid bulk import data'));
});
it('should handle partial failures', async () => {
mockFeatureLoader.get.mockResolvedValueOnce(null).mockResolvedValueOnce(sampleFeature); // Second feature exists
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
...sampleFeature,
id: data.id!,
}));
const results = await exportService.importFeatures(testProjectPath, bulkExport, {
overwrite: false,
});
expect(results).toHaveLength(2);
expect(results[0].success).toBe(true);
expect(results[1].success).toBe(false); // Exists without overwrite
});
});
});

View File

@@ -788,6 +788,367 @@ describe('pipeline-service.ts', () => {
const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
expect(nextStatus).toBe('pipeline_step1'); // Should use step1 (order 0), not step2
});
describe('with exclusions', () => {
it('should skip excluded step when coming from in_progress', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, ['step1']);
expect(nextStatus).toBe('pipeline_step2'); // Should skip step1 and go to step2
});
it('should skip excluded step when moving between steps', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step3',
name: 'Step 3',
order: 2,
instructions: 'Instructions',
colorClass: 'red',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
'step2',
]);
expect(nextStatus).toBe('pipeline_step3'); // Should skip step2 and go to step3
});
it('should go to final status when all remaining steps are excluded', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
'step2',
]);
expect(nextStatus).toBe('verified'); // No more steps after exclusion
});
it('should go to waiting_approval when all remaining steps excluded and skipTests is true', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, true, ['step2']);
expect(nextStatus).toBe('waiting_approval');
});
it('should go to final status when all steps are excluded from in_progress', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, [
'step1',
'step2',
]);
expect(nextStatus).toBe('verified');
});
it('should handle empty exclusions array like no exclusions', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, []);
expect(nextStatus).toBe('pipeline_step1');
});
it('should handle undefined exclusions like no exclusions', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, undefined);
expect(nextStatus).toBe('pipeline_step1');
});
it('should skip multiple excluded steps in sequence', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step3',
name: 'Step 3',
order: 2,
instructions: 'Instructions',
colorClass: 'red',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step4',
name: 'Step 4',
order: 3,
instructions: 'Instructions',
colorClass: 'yellow',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
// Exclude step2 and step3
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
'step2',
'step3',
]);
expect(nextStatus).toBe('pipeline_step4'); // Should skip step2 and step3
});
it('should handle exclusion of non-existent step IDs gracefully', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
// Exclude a non-existent step - should have no effect
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, [
'nonexistent',
]);
expect(nextStatus).toBe('pipeline_step1');
});
it('should find next valid step when current step becomes excluded mid-flow', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step3',
name: 'Step 3',
order: 2,
instructions: 'Instructions',
colorClass: 'red',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
// Feature is at step1 but step1 is now excluded - should find next valid step
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
'step1',
'step2',
]);
expect(nextStatus).toBe('pipeline_step3');
});
it('should go to final status when current step is excluded and no steps remain', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
// Feature is at step1 but both steps are excluded
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
'step1',
'step2',
]);
expect(nextStatus).toBe('verified');
});
});
});
describe('getStep', () => {

View File

@@ -102,6 +102,8 @@
"react-markdown": "10.1.0",
"react-resizable-panels": "3.0.6",
"rehype-raw": "7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"sonner": "2.0.7",
"tailwind-merge": "3.4.0",
"usehooks-ts": "3.1.1",

View File

@@ -58,7 +58,7 @@ const E2E_SETTINGS = {
featureGenerationModel: { model: 'sonnet' },
backlogPlanningModel: { model: 'sonnet' },
projectAnalysisModel: { model: 'sonnet' },
suggestionsModel: { model: 'sonnet' },
ideationModel: { model: 'sonnet' },
},
enhancementModel: 'sonnet',
validationModel: 'opus',

View File

@@ -25,7 +25,7 @@ export function CollapseToggleButton({
<button
onClick={toggleSidebar}
className={cn(
'flex absolute top-[68px] -right-3 z-9999',
'flex absolute top-[40px] -right-3.5 z-9999',
'group/toggle items-center justify-center w-7 h-7 rounded-full',
// Glass morphism button
'bg-card/95 backdrop-blur-sm border border-border/80',

View File

@@ -1,13 +1,31 @@
import { useCallback } from 'react';
import type { NavigateOptions } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
import { Activity, Settings } from 'lucide-react';
import { Activity, Settings, BookOpen, MessageSquare, ExternalLink } from 'lucide-react';
import { useOSDetection } from '@/hooks/use-os-detection';
import { getElectronAPI } from '@/lib/electron';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
function getOSAbbreviation(os: string): string {
switch (os) {
case 'mac':
return 'M';
case 'windows':
return 'W';
case 'linux':
return 'L';
default:
return '?';
}
}
interface SidebarFooterProps {
sidebarOpen: boolean;
isActiveRoute: (id: string) => boolean;
navigate: (opts: NavigateOptions) => void;
hideRunningAgents: boolean;
hideWiki: boolean;
runningAgentsCount: number;
shortcuts: {
settings: string;
@@ -19,86 +37,225 @@ export function SidebarFooter({
isActiveRoute,
navigate,
hideRunningAgents,
hideWiki,
runningAgentsCount,
shortcuts,
}: SidebarFooterProps) {
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
const { os } = useOSDetection();
const appMode = import.meta.env.VITE_APP_MODE || '?';
const versionSuffix = `${getOSAbbreviation(os)}${appMode}`;
const handleWikiClick = useCallback(() => {
navigate({ to: '/wiki' });
}, [navigate]);
const handleFeedbackClick = useCallback(() => {
try {
const api = getElectronAPI();
api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues');
} catch {
// Fallback for non-Electron environments (SSR, web browser)
window.open('https://github.com/AutoMaker-Org/automaker/issues', '_blank');
}
}, []);
// Collapsed state
if (!sidebarOpen) {
return (
<div
className={cn(
'shrink-0 border-t border-border/40',
'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent'
)}
>
<div className="flex flex-col items-center py-2 px-2 gap-1">
{/* Running Agents */}
{!hideRunningAgents && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => navigate({ to: '/running-agents' })}
className={cn(
'relative flex items-center justify-center w-10 h-10 rounded-xl',
'transition-all duration-200 ease-out titlebar-no-drag',
isActiveRoute('running-agents')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
]
)}
data-testid="running-agents-link"
>
<Activity
className={cn(
'w-[18px] h-[18px]',
isActiveRoute('running-agents') && 'text-brand-500'
)}
/>
{runningAgentsCount > 0 && (
<span
className={cn(
'absolute -top-1 -right-1 flex items-center justify-center',
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
'bg-brand-500 text-white shadow-sm'
)}
>
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Running Agents
{runningAgentsCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 bg-brand-500 text-white rounded-full text-[10px]">
{runningAgentsCount}
</span>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Settings */}
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => navigate({ to: '/settings' })}
className={cn(
'flex items-center justify-center w-10 h-10 rounded-xl',
'transition-all duration-200 ease-out titlebar-no-drag',
isActiveRoute('settings')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
]
)}
data-testid="settings-button"
>
<Settings
className={cn(
'w-[18px] h-[18px]',
isActiveRoute('settings') && 'text-brand-500'
)}
/>
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Global Settings
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
{formatShortcut(shortcuts.settings, true)}
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Documentation */}
{!hideWiki && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleWikiClick}
className={cn(
'flex items-center justify-center w-10 h-10 rounded-xl',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'transition-all duration-200 ease-out titlebar-no-drag'
)}
data-testid="documentation-button"
>
<BookOpen className="w-[18px] h-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Documentation
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Feedback */}
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleFeedbackClick}
className={cn(
'flex items-center justify-center w-10 h-10 rounded-xl',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'transition-all duration-200 ease-out titlebar-no-drag'
)}
data-testid="feedback-button"
>
<MessageSquare className="w-[18px] h-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Feedback
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
);
}
// Expanded state
return (
<div
className={cn(
'shrink-0',
// Top border with gradient fade
'border-t border-border/40',
// Elevated background for visual separation
'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent'
)}
>
<div className="shrink-0">
{/* Running Agents Link */}
{!hideRunningAgents && (
<div className="p-2 pb-0">
<div className="px-3 py-0.5">
<button
onClick={() => navigate({ to: '/running-agents' })}
className={cn(
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
'group flex items-center w-full px-3 py-2 rounded-lg relative overflow-hidden titlebar-no-drag',
'transition-all duration-200 ease-out',
isActiveRoute('running-agents')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground font-medium',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
'shadow-sm shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
'hover:shadow-sm',
],
sidebarOpen ? 'justify-start' : 'justify-center',
'hover:scale-[1.02] active:scale-[0.97]'
]
)}
title={!sidebarOpen ? 'Running Agents' : undefined}
data-testid="running-agents-link"
>
<div className="relative">
<Activity
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActiveRoute('running-agents')
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
{/* Running agents count badge - shown in collapsed state */}
{!sidebarOpen && runningAgentsCount > 0 && (
<span
className={cn(
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
'bg-brand-500 text-white shadow-sm',
'animate-in fade-in zoom-in duration-200'
)}
data-testid="running-agents-count-collapsed"
>
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
</span>
)}
</div>
<span
<Activity
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'block' : 'hidden'
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActiveRoute('running-agents')
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400'
)}
>
Running Agents
</span>
{/* Running agents count badge - shown in expanded state */}
{sidebarOpen && runningAgentsCount > 0 && (
/>
<span className="ml-3 text-sm flex-1 text-left">Running Agents</span>
{runningAgentsCount > 0 && (
<span
className={cn(
'flex items-center justify-center',
'min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full',
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
'bg-brand-500 text-white shadow-sm',
'animate-in fade-in zoom-in duration-200',
isActiveRoute('running-agents') && 'bg-brand-600'
)}
data-testid="running-agents-count"
@@ -106,52 +263,30 @@ export function SidebarFooter({
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
</span>
)}
{!sidebarOpen && (
<span
className={cn(
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
'bg-popover text-popover-foreground text-xs font-medium',
'border border-border shadow-lg',
'opacity-0 group-hover:opacity-100',
'transition-all duration-200 whitespace-nowrap z-50',
'translate-x-1 group-hover:translate-x-0'
)}
>
Running Agents
{runningAgentsCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 bg-brand-500 text-white rounded-full text-[10px] font-semibold">
{runningAgentsCount}
</span>
)}
</span>
)}
</button>
</div>
)}
{/* Settings Link */}
<div className="p-2">
<div className="px-3 py-0.5">
<button
onClick={() => navigate({ to: '/settings' })}
className={cn(
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
'group flex items-center w-full px-3 py-2 rounded-lg relative overflow-hidden titlebar-no-drag',
'transition-all duration-200 ease-out',
isActiveRoute('settings')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground font-medium',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
'shadow-sm shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
'hover:shadow-sm',
],
sidebarOpen ? 'justify-start' : 'justify-center',
'hover:scale-[1.02] active:scale-[0.97]'
]
)}
title={!sidebarOpen ? 'Global Settings' : undefined}
data-testid="settings-button"
>
<Settings
@@ -159,49 +294,70 @@ export function SidebarFooter({
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActiveRoute('settings')
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:rotate-90 group-hover:scale-110'
: 'group-hover:text-brand-400'
)}
/>
<span className="ml-3 text-sm flex-1 text-left">Settings</span>
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'block' : 'hidden'
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded transition-all duration-200',
isActiveRoute('settings')
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'
)}
data-testid="shortcut-settings"
>
Global Settings
{formatShortcut(shortcuts.settings, true)}
</span>
{sidebarOpen && (
<span
className={cn(
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
isActiveRoute('settings')
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'
)}
data-testid="shortcut-settings"
>
{formatShortcut(shortcuts.settings, true)}
</span>
)}
{!sidebarOpen && (
<span
className={cn(
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
'bg-popover text-popover-foreground text-xs font-medium',
'border border-border shadow-lg',
'opacity-0 group-hover:opacity-100',
'transition-all duration-200 whitespace-nowrap z-50',
'translate-x-1 group-hover:translate-x-0'
)}
>
Global Settings
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
{formatShortcut(shortcuts.settings, true)}
</span>
</span>
)}
</button>
</div>
{/* Separator */}
<div className="h-px bg-border/40 mx-3 my-2" />
{/* Documentation Link */}
{!hideWiki && (
<div className="px-3 py-0.5">
<button
onClick={handleWikiClick}
className={cn(
'group flex items-center w-full px-3 py-1.5 rounded-md titlebar-no-drag',
'text-muted-foreground/70 hover:text-foreground',
'hover:bg-accent/30',
'transition-all duration-200 ease-out'
)}
data-testid="documentation-button"
>
<BookOpen className="w-4 h-4 shrink-0" />
<span className="ml-2.5 text-xs">Documentation</span>
</button>
</div>
)}
{/* Feedback Link */}
<div className="px-3 pt-0.5">
<button
onClick={handleFeedbackClick}
className={cn(
'group flex items-center w-full px-3 py-1.5 rounded-md titlebar-no-drag',
'text-muted-foreground/70 hover:text-foreground',
'hover:bg-accent/30',
'transition-all duration-200 ease-out'
)}
data-testid="feedback-button"
>
<MessageSquare className="w-4 h-4 shrink-0" />
<span className="ml-2.5 text-xs">Feedback</span>
<ExternalLink className="w-3 h-3 ml-auto text-muted-foreground/50" />
</button>
</div>
{/* Version */}
<div className="px-6 py-1.5 text-center">
<span className="text-[9px] text-muted-foreground/40">
v{appVersion} {versionSuffix}
</span>
</div>
</div>
);
}

View File

@@ -1,179 +1,411 @@
import { useState } from 'react';
import { Folder, LucideIcon, X, Menu, Check } from 'lucide-react';
import { useState, useCallback } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { ChevronsUpDown, Folder, Plus, FolderOpen } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn, isMac } from '@/lib/utils';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { formatShortcut } from '@/store/app-store';
import { isElectron, type Project } from '@/lib/electron';
import { useIsCompact } from '@/hooks/use-media-query';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { useAppStore } from '@/store/app-store';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface SidebarHeaderProps {
sidebarOpen: boolean;
currentProject: Project | null;
onClose?: () => void;
onExpand?: () => void;
onNewProject: () => void;
onOpenFolder: () => void;
onProjectContextMenu: (project: Project, event: React.MouseEvent) => void;
}
export function SidebarHeader({
sidebarOpen,
currentProject,
onClose,
onExpand,
onNewProject,
onOpenFolder,
onProjectContextMenu,
}: SidebarHeaderProps) {
const isCompact = useIsCompact();
const [projectListOpen, setProjectListOpen] = useState(false);
const navigate = useNavigate();
const { projects, setCurrentProject } = useAppStore();
// Get the icon component from lucide-react
const getIconComponent = (): LucideIcon => {
if (currentProject?.icon && currentProject.icon in LucideIcons) {
return (LucideIcons as unknown as Record<string, LucideIcon>)[currentProject.icon];
const [dropdownOpen, setDropdownOpen] = useState(false);
const handleLogoClick = useCallback(() => {
navigate({ to: '/dashboard' });
}, [navigate]);
const handleProjectSelect = useCallback(
(project: Project) => {
setCurrentProject(project);
setDropdownOpen(false);
navigate({ to: '/board' });
},
[setCurrentProject, navigate]
);
const getIconComponent = (project: Project): LucideIcon => {
if (project.icon && project.icon in LucideIcons) {
return (LucideIcons as unknown as Record<string, LucideIcon>)[project.icon];
}
return Folder;
};
const IconComponent = getIconComponent();
const hasCustomIcon = !!currentProject?.customIconPath;
const renderProjectIcon = (project: Project, size: 'sm' | 'md' = 'md') => {
const IconComponent = getIconComponent(project);
const sizeClasses = size === 'sm' ? 'w-6 h-6' : 'w-8 h-8';
const iconSizeClasses = size === 'sm' ? 'w-4 h-4' : 'w-5 h-5';
if (project.customIconPath) {
return (
<img
src={getAuthenticatedImageUrl(project.customIconPath, project.path)}
alt={project.name}
className={cn(sizeClasses, 'rounded-lg object-cover ring-1 ring-border/50')}
/>
);
}
return (
<div
className={cn(
sizeClasses,
'rounded-lg bg-brand-500/10 border border-brand-500/20 flex items-center justify-center'
)}
>
<IconComponent className={cn(iconSizeClasses, 'text-brand-500')} />
</div>
);
};
// Collapsed state - show logo only
if (!sidebarOpen) {
return (
<div
className={cn(
'shrink-0 flex flex-col items-center relative px-2 pt-3 pb-2',
isMac && isElectron() && 'pt-[10px]'
)}
>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleLogoClick}
className="group flex flex-col items-center"
data-testid="logo-button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="size-8 group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient
id="bg-collapsed"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
</linearGradient>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-collapsed)" />
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Go to Dashboard
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Collapsed project icon with dropdown */}
{currentProject && (
<>
<div className="w-full h-px bg-border/40 my-2" />
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
onContextMenu={(e) => onProjectContextMenu(currentProject, e)}
className="p-1 rounded-lg hover:bg-accent/50 transition-colors"
data-testid="collapsed-project-button"
>
{renderProjectIcon(currentProject)}
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{currentProject.name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent
align="start"
side="right"
sideOffset={8}
className="w-64"
data-testid="collapsed-project-dropdown-content"
>
<div className="px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground">Projects</span>
</div>
{projects.map((project, index) => {
const isActive = currentProject?.id === project.id;
const hotkeyLabel = index < 9 ? `${index + 1}` : index === 9 ? '0' : undefined;
return (
<DropdownMenuItem
key={project.id}
onClick={() => handleProjectSelect(project)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setDropdownOpen(false);
onProjectContextMenu(project, e);
}}
className="flex items-center gap-3 cursor-pointer"
data-testid={`collapsed-project-item-${project.id}`}
>
{renderProjectIcon(project, 'sm')}
<span
className={cn(
'flex-1 truncate',
isActive && 'font-semibold text-foreground'
)}
>
{project.name}
</span>
{hotkeyLabel && (
<span className="text-xs text-muted-foreground">
{formatShortcut(`Cmd+${hotkeyLabel}`, true)}
</span>
)}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false);
onNewProject();
}}
className="cursor-pointer"
data-testid="collapsed-new-project-dropdown-item"
>
<Plus className="w-4 h-4 mr-2" />
<span>New Project</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false);
onOpenFolder();
}}
className="cursor-pointer"
data-testid="collapsed-open-project-dropdown-item"
>
<FolderOpen className="w-4 h-4 mr-2" />
<span>Open Project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
);
}
// Expanded state - show logo + project dropdown
return (
<div
className={cn(
'shrink-0 flex flex-col relative',
// Add padding on macOS Electron for traffic light buttons
'shrink-0 flex flex-col relative px-3 pt-3 pb-2',
isMac && isElectron() && 'pt-[10px]'
)}
>
{/* Mobile close button - only visible on mobile when sidebar is open */}
{sidebarOpen && onClose && (
{/* Header with logo and project dropdown */}
<div className="flex items-center gap-3">
{/* Logo */}
<button
onClick={onClose}
className={cn(
'lg:hidden absolute top-3 right-3 z-10',
'flex items-center justify-center w-8 h-8 rounded-lg',
'bg-muted/50 hover:bg-muted',
'text-muted-foreground hover:text-foreground',
'transition-colors duration-200'
)}
aria-label="Close navigation"
data-testid="sidebar-mobile-close"
onClick={handleLogoClick}
className="group flex items-center shrink-0 titlebar-no-drag"
title="Go to Dashboard"
data-testid="logo-button"
>
<X className="w-5 h-5" />
</button>
)}
{/* Mobile expand button - hamburger menu to expand sidebar when collapsed on mobile */}
{!sidebarOpen && isCompact && onExpand && (
<button
onClick={onExpand}
className={cn(
'flex items-center justify-center w-10 h-10 mx-auto mt-2 rounded-lg',
'bg-muted/50 hover:bg-muted',
'text-muted-foreground hover:text-foreground',
'transition-colors duration-200'
)}
aria-label="Expand navigation"
data-testid="sidebar-mobile-expand"
>
<Menu className="w-5 h-5" />
</button>
)}
{/* Project name and icon display - entire element clickable on mobile */}
{currentProject && (
<Popover open={projectListOpen} onOpenChange={setProjectListOpen}>
<PopoverTrigger asChild>
<button
className={cn(
'flex items-center gap-3 px-4 pt-3 pb-1 w-full text-left',
'rounded-lg transition-colors duration-150',
!sidebarOpen && 'justify-center px-2',
// Only enable click behavior on compact screens
isCompact && 'hover:bg-accent/50 cursor-pointer',
!isCompact && 'pointer-events-none'
)}
title={isCompact ? 'Switch project' : undefined}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="h-8 w-8 group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient
id="bg-header"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
</linearGradient>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-header)" />
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
>
{/* Project Icon */}
<div className="shrink-0">
{hasCustomIcon ? (
<img
src={getAuthenticatedImageUrl(
currentProject.customIconPath!,
currentProject.path
)}
alt={currentProject.name}
className="w-8 h-8 rounded-lg object-cover ring-1 ring-border/50"
/>
) : (
<div className="w-8 h-8 rounded-lg bg-brand-500/10 border border-brand-500/20 flex items-center justify-center">
<IconComponent className="w-5 h-5 text-brand-500" />
</div>
)}
</div>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
</button>
{/* Project Name - only show when sidebar is open */}
{sidebarOpen && (
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-foreground truncate">
{currentProject.name}
</h2>
</div>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="start" side="bottom" sideOffset={8}>
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground px-2 py-1">Switch Project</p>
{projects.map((project) => {
const ProjectIcon =
project.icon && project.icon in LucideIcons
? (LucideIcons as unknown as Record<string, LucideIcon>)[project.icon]
: Folder;
{/* Project Dropdown */}
{currentProject ? (
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex-1 flex items-center gap-2 px-2 py-1.5 rounded-lg min-w-0',
'hover:bg-accent/50 transition-colors titlebar-no-drag',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1'
)}
onContextMenu={(e) => onProjectContextMenu(currentProject, e)}
data-testid="project-dropdown-trigger"
>
{renderProjectIcon(currentProject, 'sm')}
<span className="flex-1 text-sm font-semibold text-foreground truncate text-left">
{currentProject.name}
</span>
<ChevronsUpDown className="w-4 h-4 text-muted-foreground shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
sideOffset={8}
className="w-64"
data-testid="project-dropdown-content"
>
<div className="px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground">Projects</span>
</div>
{projects.map((project, index) => {
const isActive = currentProject?.id === project.id;
const hotkeyLabel = index < 9 ? `${index + 1}` : index === 9 ? '0' : undefined;
return (
<button
<DropdownMenuItem
key={project.id}
onClick={() => {
setCurrentProject(project);
setProjectListOpen(false);
onClick={() => handleProjectSelect(project)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setDropdownOpen(false);
onProjectContextMenu(project, e);
}}
className={cn(
'w-full flex items-center gap-3 px-2 py-2 rounded-lg text-left',
'transition-colors duration-150',
isActive
? 'bg-brand-500/10 text-brand-500'
: 'hover:bg-accent text-foreground'
)}
className="flex items-center gap-3 cursor-pointer"
data-testid={`project-item-${project.id}`}
>
{project.customIconPath ? (
<img
src={getAuthenticatedImageUrl(project.customIconPath, project.path)}
alt={project.name}
className="w-6 h-6 rounded object-cover ring-1 ring-border/50"
/>
) : (
<div
className={cn(
'w-6 h-6 rounded flex items-center justify-center',
isActive ? 'bg-brand-500/20' : 'bg-muted'
)}
>
<ProjectIcon
className={cn(
'w-4 h-4',
isActive ? 'text-brand-500' : 'text-muted-foreground'
)}
/>
</div>
{renderProjectIcon(project, 'sm')}
<span
className={cn('flex-1 truncate', isActive && 'font-semibold text-foreground')}
>
{project.name}
</span>
{hotkeyLabel && (
<span className="text-xs text-muted-foreground">
{formatShortcut(`Cmd+${hotkeyLabel}`, true)}
</span>
)}
<span className="flex-1 text-sm truncate">{project.name}</span>
{isActive && <Check className="w-4 h-4 text-brand-500" />}
</button>
</DropdownMenuItem>
);
})}
</div>
</PopoverContent>
</Popover>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false);
onNewProject();
}}
className="cursor-pointer"
data-testid="new-project-dropdown-item"
>
<Plus className="w-4 h-4 mr-2" />
<span>New Project</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false);
onOpenFolder();
}}
className="cursor-pointer"
data-testid="open-project-dropdown-item"
>
<FolderOpen className="w-4 h-4 mr-2" />
<span>Open Project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="flex-1 flex items-center gap-2">
<button
onClick={onNewProject}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg',
'text-sm text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 transition-colors titlebar-no-drag'
)}
data-testid="new-project-button"
>
<Plus className="w-4 h-4" />
<span>New Project</span>
</button>
<button
onClick={onOpenFolder}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg',
'text-sm text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 transition-colors titlebar-no-drag'
)}
data-testid="open-project-button"
>
<FolderOpen className="w-4 h-4" />
<span>Open</span>
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,9 +1,24 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import type { NavigateOptions } from '@tanstack/react-router';
import { ChevronDown, Wrench, Github } 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';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
// Map section labels to icons
const sectionIcons: Record<string, React.ComponentType<{ className?: string }>> = {
Tools: Wrench,
GitHub: Github,
};
interface SidebarNavigationProps {
currentProject: Project | null;
@@ -11,6 +26,7 @@ interface SidebarNavigationProps {
navSections: NavSection[];
isActiveRoute: (id: string) => boolean;
navigate: (opts: NavigateOptions) => void;
onScrollStateChange?: (canScrollDown: boolean) => void;
}
export function SidebarNavigation({
@@ -19,174 +35,299 @@ export function SidebarNavigation({
navSections,
isActiveRoute,
navigate,
onScrollStateChange,
}: SidebarNavigationProps) {
const navRef = useRef<HTMLElement>(null);
// Track collapsed state for each collapsible section
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
// Initialize collapsed state when sections change (e.g., GitHub section appears)
useEffect(() => {
setCollapsedSections((prev) => {
const updated = { ...prev };
navSections.forEach((section) => {
if (section.collapsible && section.label && !(section.label in updated)) {
updated[section.label] = section.defaultCollapsed ?? false;
}
});
return updated;
});
}, [navSections]);
// Check scroll state
const checkScrollState = useCallback(() => {
if (!navRef.current || !onScrollStateChange) return;
const { scrollTop, scrollHeight, clientHeight } = navRef.current;
const canScrollDown = scrollTop + clientHeight < scrollHeight - 10;
onScrollStateChange(canScrollDown);
}, [onScrollStateChange]);
// Monitor scroll state
useEffect(() => {
checkScrollState();
const nav = navRef.current;
if (!nav) return;
nav.addEventListener('scroll', checkScrollState);
const resizeObserver = new ResizeObserver(checkScrollState);
resizeObserver.observe(nav);
return () => {
nav.removeEventListener('scroll', checkScrollState);
resizeObserver.disconnect();
};
}, [checkScrollState, collapsedSections]);
const toggleSection = useCallback((label: string) => {
setCollapsedSections((prev) => ({
...prev,
[label]: !prev[label],
}));
}, []);
// Filter sections: always show non-project sections, only show project sections when project exists
const visibleSections = navSections.filter((section) => {
// Always show Dashboard (first section with no label)
if (!section.label && section.items.some((item) => item.id === 'dashboard')) {
return true;
}
// Show other sections only when project is selected
return !!currentProject;
});
return (
<nav
className={cn(
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
sidebarOpen ? 'mt-1' : 'mt-1'
)}
>
{!currentProject && sidebarOpen ? (
// Placeholder when no project is selected (only in expanded state)
<div className="flex items-center justify-center h-full px-4">
<p className="text-muted-foreground text-sm text-center">
<span className="block">Select or create a project above</span>
</p>
</div>
) : currentProject ? (
// Navigation sections when project is selected
navSections.map((section, sectionIdx) => (
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? 'mt-6' : ''}>
{/* Section Label */}
<nav ref={navRef} className={cn('flex-1 overflow-y-auto scrollbar-hide px-3 pb-2 mt-1')}>
{/* Navigation sections */}
{visibleSections.map((section, sectionIdx) => {
const isCollapsed = section.label ? collapsedSections[section.label] : false;
const isCollapsible = section.collapsible && section.label && sidebarOpen;
const SectionIcon = section.label ? sectionIcons[section.label] : null;
return (
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? 'mt-4' : ''}>
{/* Section Label - clickable if collapsible (expanded sidebar) */}
{section.label && sidebarOpen && (
<div className="px-3 mb-2">
<button
onClick={() => isCollapsible && toggleSection(section.label!)}
className={cn(
'flex items-center w-full px-3 mb-1.5',
isCollapsible && 'cursor-pointer hover:text-foreground'
)}
disabled={!isCollapsible}
>
<span className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
{section.label}
</span>
</div>
{isCollapsible && (
<ChevronDown
className={cn(
'w-3 h-3 ml-auto text-muted-foreground/50 transition-transform duration-200',
isCollapsed && '-rotate-90'
)}
/>
)}
</button>
)}
{/* Section icon with dropdown (collapsed sidebar) */}
{section.label && !sidebarOpen && SectionIcon && section.collapsible && isCollapsed && (
<DropdownMenu>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
className={cn(
'group flex items-center justify-center w-full py-2 rounded-lg',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'transition-all duration-200 ease-out'
)}
>
<SectionIcon className="w-[18px] h-[18px]" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{section.label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent side="right" align="start" sideOffset={8} className="w-48">
{section.items.map((item) => {
const ItemIcon = item.icon;
return (
<DropdownMenuItem
key={item.id}
onClick={() => navigate({ to: `/${item.id}` as unknown as '/' })}
className="flex items-center gap-2 cursor-pointer"
>
<ItemIcon className="w-4 h-4" />
<span>{item.label}</span>
{item.shortcut && (
<span className="ml-auto text-[10px] font-mono text-muted-foreground">
{formatShortcut(item.shortcut, true)}
</span>
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Separator for sections without label (visual separation) */}
{!section.label && sectionIdx > 0 && sidebarOpen && (
<div className="h-px bg-border/40 mx-3 mb-4"></div>
<div className="h-px bg-border/40 mx-3 mb-3"></div>
)}
{(section.label || sectionIdx > 0) && !sidebarOpen && (
<div className="h-px bg-border/30 mx-2 my-1.5"></div>
)}
{/* Nav Items */}
<div className="space-y-1.5">
{section.items.map((item) => {
const isActive = isActiveRoute(item.id);
const Icon = item.icon;
{/* Nav Items - show when section is expanded, or when sidebar is collapsed and section doesn't use dropdown */}
{!isCollapsed && (
<div className="space-y-1">
{section.items.map((item) => {
const isActive = isActiveRoute(item.id);
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => {
// Cast to the router's path type; item.id is constrained to known routes
navigate({ to: `/${item.id}` as unknown as '/' });
}}
className={cn(
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
'transition-all duration-200 ease-out',
isActive
? [
// Active: Premium gradient with glow
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground font-medium',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
// Inactive: Subtle hover state
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
'hover:shadow-sm',
],
sidebarOpen ? 'justify-start' : 'justify-center',
'hover:scale-[1.02] active:scale-[0.97]'
)}
title={!sidebarOpen ? item.label : undefined}
data-testid={`nav-${item.id}`}
>
<div className="relative">
{item.isLoading ? (
<Spinner
size="md"
className={cn(
'shrink-0',
isActive ? 'text-brand-500' : 'text-muted-foreground'
)}
/>
) : (
<Icon
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActive
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
return (
<button
key={item.id}
onClick={() => {
// Cast to the router's path type; item.id is constrained to known routes
navigate({ to: `/${item.id}` as unknown as '/' });
}}
className={cn(
'group flex items-center w-full px-3 py-2 rounded-lg relative overflow-hidden titlebar-no-drag',
'transition-all duration-200 ease-out',
isActive
? [
// Active: Premium gradient with glow
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground font-medium',
'border border-brand-500/30',
'shadow-sm shadow-brand-500/10',
]
: [
// Inactive: Subtle hover state
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
],
sidebarOpen ? 'justify-start' : 'justify-center'
)}
{/* Count badge for collapsed state */}
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
title={!sidebarOpen ? item.label : undefined}
data-testid={`nav-${item.id}`}
>
<div className="relative">
{item.isLoading ? (
<Spinner
size="sm"
className={cn(
'shrink-0',
isActive ? 'text-brand-500' : 'text-muted-foreground'
)}
/>
) : (
<Icon
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActive
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400'
)}
/>
)}
{/* Count badge for collapsed state */}
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
<span
className={cn(
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
'min-w-4 h-4 px-0.5 text-[9px] font-bold rounded-full',
'bg-primary text-primary-foreground shadow-sm',
'animate-in fade-in zoom-in duration-200'
)}
>
{item.count > 99 ? '99' : item.count}
</span>
)}
</div>
<span
className={cn(
'ml-3 text-sm flex-1 text-left',
sidebarOpen ? 'block' : 'hidden'
)}
>
{item.label}
</span>
{/* Count badge */}
{item.count !== undefined && item.count > 0 && sidebarOpen && (
<span
className={cn(
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
'flex items-center justify-center',
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
'bg-primary text-primary-foreground shadow-sm',
'animate-in fade-in zoom-in duration-200'
)}
data-testid={`count-${item.id}`}
>
{item.count > 99 ? '99' : item.count}
{item.count > 99 ? '99+' : item.count}
</span>
)}
</div>
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'block' : 'hidden'
{item.shortcut && sidebarOpen && !item.count && (
<span
className={cn(
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded transition-all duration-200',
isActive
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'
)}
data-testid={`shortcut-${item.id}`}
>
{formatShortcut(item.shortcut, true)}
</span>
)}
>
{item.label}
</span>
{/* Count badge */}
{item.count !== undefined && item.count > 0 && sidebarOpen && (
<span
className={cn(
'flex items-center justify-center',
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
'bg-primary text-primary-foreground shadow-sm',
'animate-in fade-in zoom-in duration-200'
)}
data-testid={`count-${item.id}`}
>
{item.count > 99 ? '99+' : item.count}
</span>
)}
{item.shortcut && sidebarOpen && !item.count && (
<span
className={cn(
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
isActive
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'
)}
data-testid={`shortcut-${item.id}`}
>
{formatShortcut(item.shortcut, true)}
</span>
)}
{/* Tooltip for collapsed state */}
{!sidebarOpen && (
<span
className={cn(
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
'bg-popover text-popover-foreground text-xs font-medium',
'border border-border shadow-lg',
'opacity-0 group-hover:opacity-100',
'transition-all duration-200 whitespace-nowrap z-50',
'translate-x-1 group-hover:translate-x-0'
)}
data-testid={`sidebar-tooltip-${item.label.toLowerCase()}`}
>
{item.label}
{item.shortcut && (
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
{formatShortcut(item.shortcut, true)}
</span>
)}
</span>
)}
</button>
);
})}
</div>
{/* Tooltip for collapsed state */}
{!sidebarOpen && (
<span
className={cn(
'absolute left-full ml-3 px-2.5 py-1.5 rounded-md',
'bg-popover text-popover-foreground text-sm',
'border border-border shadow-lg',
'opacity-0 group-hover:opacity-100',
'transition-all duration-200 whitespace-nowrap z-50',
'translate-x-1 group-hover:translate-x-0'
)}
data-testid={`sidebar-tooltip-${item.label.toLowerCase()}`}
>
{item.label}
{item.shortcut && (
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
{formatShortcut(item.shortcut, true)}
</span>
)}
</span>
)}
</button>
);
})}
</div>
)}
</div>
))
) : null}
);
})}
{/* Placeholder when no project is selected */}
{!currentProject && sidebarOpen && (
<div className="flex items-center justify-center px-4 py-8">
<p className="text-muted-foreground text-xs text-center">
Select or create a project to continue
</p>
</div>
)}
</nav>
);
}

View File

@@ -13,6 +13,7 @@ import {
Network,
Bell,
Settings,
Home,
} from 'lucide-react';
import type { NavSection, NavItem } from '../types';
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
@@ -174,13 +175,30 @@ export function useNavigation({
}
const sections: NavSection[] = [
// Dashboard - standalone at top
{
label: '',
items: [
{
id: 'dashboard',
label: 'Dashboard',
icon: Home,
},
],
},
// Project section - expanded by default
{
label: 'Project',
items: projectItems,
collapsible: true,
defaultCollapsed: false,
},
// Tools section - collapsed by default
{
label: 'Tools',
items: visibleToolsItems,
collapsible: true,
defaultCollapsed: true,
},
];
@@ -203,6 +221,8 @@ export function useNavigation({
shortcut: shortcuts.githubPrs,
},
],
collapsible: true,
defaultCollapsed: true,
});
}

View File

@@ -0,0 +1 @@
export { Sidebar } from './sidebar';

View File

@@ -1,8 +1,7 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useNavigate, useLocation } from '@tanstack/react-router';
const logger = createLogger('Sidebar');
import { PanelLeftClose, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { useNotificationsStore } from '@/store/notifications-store';
@@ -10,22 +9,18 @@ import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-ke
import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
import { toast } from 'sonner';
import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
// Local imports from subfolder
import {
CollapseToggleButton,
SidebarHeader,
SidebarNavigation,
SidebarFooter,
MobileSidebarToggle,
} from './sidebar/components';
import { useIsCompact } from '@/hooks/use-media-query';
import { PanelLeftClose } from 'lucide-react';
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
import type { Project } from '@/lib/electron';
// Sidebar components
import {
SidebarNavigation,
CollapseToggleButton,
MobileSidebarToggle,
SidebarHeader,
SidebarFooter,
} from './components';
import { SIDEBAR_FEATURE_FLAGS } from './constants';
import {
useSidebarAutoCollapse,
useRunningAgents,
@@ -35,7 +30,19 @@ import {
useSetupDialog,
useTrashOperations,
useUnviewedValidations,
} from './sidebar/hooks';
} from './hooks';
import { TrashDialog, OnboardingDialog } from './dialogs';
// Reuse dialogs from project-switcher
import { ProjectContextMenu } from '../project-switcher/components/project-context-menu';
import { EditProjectDialog } from '../project-switcher/components/edit-project-dialog';
// Import shared dialogs
import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
const logger = createLogger('Sidebar');
export function Sidebar() {
const navigate = useNavigate();
@@ -59,12 +66,14 @@ export function Sidebar() {
moveProjectToTrash,
specCreatingForProject,
setSpecCreatingForProject,
setCurrentProject,
} = useAppStore();
const isCompact = useIsCompact();
// Environment variable flags for hiding sidebar items
const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor } = SIDEBAR_FEATURE_FLAGS;
const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor, hideWiki } =
SIDEBAR_FEATURE_FLAGS;
// Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
@@ -72,6 +81,13 @@ export function Sidebar() {
// Get unread notifications count
const unreadNotificationsCount = useNotificationsStore((s) => s.unreadCount);
// State for context menu
const [contextMenuProject, setContextMenuProject] = useState<Project | null>(null);
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(
null
);
const [editDialogProject, setEditDialogProject] = useState<Project | null>(null);
// State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
@@ -129,7 +145,7 @@ export function Sidebar() {
const isCurrentProjectGeneratingSpec =
specCreatingForProject !== null && specCreatingForProject === currentProject?.path;
// Auto-collapse sidebar on small screens and update Electron window minWidth
// Auto-collapse sidebar on small screens
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
// Running agents count
@@ -163,9 +179,28 @@ export function Sidebar() {
setNewProjectPath,
});
// Context menu handlers
const handleContextMenu = useCallback((project: Project, event: React.MouseEvent) => {
event.preventDefault();
setContextMenuProject(project);
setContextMenuPosition({ x: event.clientX, y: event.clientY });
}, []);
const handleCloseContextMenu = useCallback(() => {
setContextMenuProject(null);
setContextMenuPosition(null);
}, []);
const handleEditProject = useCallback(
(project: Project) => {
setEditDialogProject(project);
handleCloseContextMenu();
},
[handleCloseContextMenu]
);
/**
* Opens the system folder selection dialog and initializes the selected project.
* Used by both the 'O' keyboard shortcut and the folder icon button.
*/
const handleOpenFolder = useCallback(async () => {
const api = getElectronAPI();
@@ -173,14 +208,10 @@ export function Sidebar() {
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
// Extract folder name from path (works on both Windows and Mac/Linux)
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
try {
// Check if this is a brand new project (no .automaker directory)
const hadAutomakerDir = await hasAutomakerDir(path);
// Initialize the .automaker directory structure
const initResult = await initializeProject(path);
if (!initResult.success) {
@@ -190,15 +221,10 @@ export function Sidebar() {
return;
}
// Upsert project and set as current (handles both create and update cases)
// 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);
if (!hadAutomakerDir && !specExists) {
// This is a brand new project - show setup dialog
setSetupProjectPath(path);
setShowSetupDialog(true);
toast.success('Project opened', {
@@ -213,6 +239,8 @@ export function Sidebar() {
description: `Opened ${name}`,
});
}
navigate({ to: '/board' });
} catch (error) {
logger.error('Failed to open project:', error);
toast.error('Failed to open project', {
@@ -220,9 +248,13 @@ export function Sidebar() {
});
}
}
}, [upsertAndSetCurrentProject]);
}, [upsertAndSetCurrentProject, navigate, setSetupProjectPath, setShowSetupDialog]);
// Navigation sections and keyboard shortcuts (defined after handlers)
const handleNewProject = useCallback(() => {
setShowNewProjectModal(true);
}, [setShowNewProjectModal]);
// Navigation sections and keyboard shortcuts
const { navSections, navigationShortcuts } = useNavigation({
shortcuts,
hideSpecEditor,
@@ -244,12 +276,48 @@ export function Sidebar() {
// Register keyboard shortcuts
useKeyboardShortcuts(navigationShortcuts);
// Keyboard shortcuts for project switching (1-9, 0)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
if (event.ctrlKey || event.metaKey || event.altKey) {
return;
}
const key = event.key;
let projectIndex: number | null = null;
if (key >= '1' && key <= '9') {
projectIndex = parseInt(key, 10) - 1;
} else if (key === '0') {
projectIndex = 9;
}
if (projectIndex !== null && projectIndex < projects.length) {
const targetProject = projects[projectIndex];
if (targetProject && targetProject.id !== currentProject?.id) {
setCurrentProject(targetProject);
navigate({ to: '/board' });
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [projects, currentProject, setCurrentProject, navigate]);
const isActiveRoute = (id: string) => {
// Map view IDs to route paths
const routePath = id === 'welcome' ? '/' : `/${id}`;
return location.pathname === routePath;
};
// Track if nav can scroll down
const [canScrollDown, setCanScrollDown] = useState(false);
// Check if sidebar should be completely hidden on mobile
const shouldHideSidebar = isCompact && mobileSidebarHidden;
@@ -266,6 +334,7 @@ export function Sidebar() {
data-testid="sidebar-backdrop"
/>
)}
<aside
className={cn(
'flex-shrink-0 flex flex-col z-30',
@@ -277,9 +346,11 @@ export function Sidebar() {
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
// Mobile: completely hidden when mobileSidebarHidden is true
shouldHideSidebar && 'hidden',
// Mobile: overlay when open, collapsed when closed
// Width based on state
!shouldHideSidebar &&
(sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16')
(sidebarOpen
? 'fixed inset-y-0 left-0 w-[17rem] lg:relative lg:w-[17rem]'
: 'relative w-14')
)}
data-testid="sidebar"
>
@@ -313,8 +384,9 @@ export function Sidebar() {
<SidebarHeader
sidebarOpen={sidebarOpen}
currentProject={currentProject}
onClose={toggleSidebar}
onExpand={toggleSidebar}
onNewProject={handleNewProject}
onOpenFolder={handleOpenFolder}
onProjectContextMenu={handleContextMenu}
/>
<SidebarNavigation
@@ -323,17 +395,27 @@ export function Sidebar() {
navSections={navSections}
isActiveRoute={isActiveRoute}
navigate={navigate}
onScrollStateChange={setCanScrollDown}
/>
</div>
{/* Scroll indicator - shows there's more content below */}
{canScrollDown && sidebarOpen && (
<div className="flex justify-center py-1 border-t border-border/30">
<ChevronDown className="w-4 h-4 text-muted-foreground/50 animate-bounce" />
</div>
)}
<SidebarFooter
sidebarOpen={sidebarOpen}
isActiveRoute={isActiveRoute}
navigate={navigate}
hideRunningAgents={hideRunningAgents}
hideWiki={hideWiki}
runningAgentsCount={runningAgentsCount}
shortcuts={{ settings: shortcuts.settings }}
/>
<TrashDialog
open={showTrashDialog}
onOpenChange={setShowTrashDialog}
@@ -392,6 +474,25 @@ export function Sidebar() {
isCreating={isCreatingProject}
/>
</aside>
{/* Context Menu */}
{contextMenuProject && contextMenuPosition && (
<ProjectContextMenu
project={contextMenuProject}
position={contextMenuPosition}
onClose={handleCloseContextMenu}
onEdit={handleEditProject}
/>
)}
{/* Edit Project Dialog */}
{editDialogProject && (
<EditProjectDialog
project={editDialogProject}
open={!!editDialogProject}
onOpenChange={(open) => !open && setEditDialogProject(null)}
/>
)}
</>
);
}

View File

@@ -4,6 +4,10 @@ import type React from 'react';
export interface NavSection {
label?: string;
items: NavItem[];
/** Whether this section can be collapsed */
collapsible?: boolean;
/** Whether this section should start collapsed */
defaultCollapsed?: boolean;
}
export interface NavItem {

View File

@@ -0,0 +1,389 @@
/**
* Provider Usage Bar
*
* A compact usage bar that displays usage statistics for all enabled AI providers.
* Shows a unified view with individual provider usage indicators.
*/
import { useState, useMemo } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import {
AnthropicIcon,
OpenAIIcon,
CursorIcon,
GeminiIcon,
OpenCodeIcon,
MiniMaxIcon,
GlmIcon,
} from '@/components/ui/provider-icon';
import { useAllProvidersUsage } from '@/hooks/queries';
import type { UsageProviderId, ProviderUsage } from '@automaker/types';
import { getMaxUsagePercent } from '@automaker/types';
// GitHub icon component
function GitHubIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={cn('inline-block', className)} fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
}
// Provider icon mapping
const PROVIDER_ICONS: Record<UsageProviderId, React.FC<{ className?: string }>> = {
claude: AnthropicIcon,
codex: OpenAIIcon,
cursor: CursorIcon,
gemini: GeminiIcon,
copilot: GitHubIcon,
opencode: OpenCodeIcon,
minimax: MiniMaxIcon,
glm: GlmIcon,
};
// Provider dashboard URLs
const PROVIDER_DASHBOARD_URLS: Record<UsageProviderId, string | undefined> = {
claude: 'https://status.claude.com',
codex: 'https://platform.openai.com/usage',
cursor: 'https://cursor.com/settings',
gemini: 'https://aistudio.google.com',
copilot: 'https://github.com/settings/copilot',
opencode: 'https://opencode.ai',
minimax: 'https://platform.minimax.io/user-center/payment/coding-plan',
glm: 'https://z.ai/account',
};
// Helper to get status color based on percentage
function getStatusInfo(percentage: number) {
if (percentage >= 90) return { color: 'text-red-500', icon: XCircle, bg: 'bg-red-500' };
if (percentage >= 75) return { color: 'text-orange-500', icon: AlertTriangle, bg: 'bg-orange-500' };
if (percentage >= 50) return { color: 'text-yellow-500', icon: AlertTriangle, bg: 'bg-yellow-500' };
return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' };
}
// Progress bar component
function ProgressBar({ percentage, colorClass }: { percentage: number; colorClass: string }) {
return (
<div className="h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
<div
className={cn('h-full transition-all duration-500', colorClass)}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
);
}
// Usage card component
function UsageCard({
title,
subtitle,
percentage,
resetText,
isPrimary = false,
stale = false,
}: {
title: string;
subtitle: string;
percentage: number;
resetText?: string;
isPrimary?: boolean;
stale?: boolean;
}) {
const isValidPercentage =
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
const safePercentage = isValidPercentage ? percentage : 0;
const status = getStatusInfo(safePercentage);
const StatusIcon = status.icon;
return (
<div
className={cn(
'rounded-xl border bg-card/50 p-3 transition-opacity',
isPrimary ? 'border-border/60 shadow-sm' : 'border-border/40',
(stale || !isValidPercentage) && 'opacity-50'
)}
>
<div className="flex items-start justify-between mb-2">
<div>
<h4 className={cn('font-semibold', isPrimary ? 'text-sm' : 'text-xs')}>{title}</h4>
<p className="text-[10px] text-muted-foreground">{subtitle}</p>
</div>
{isValidPercentage ? (
<div className="flex items-center gap-1.5">
<StatusIcon className={cn('w-3.5 h-3.5', status.color)} />
<span
className={cn(
'font-mono font-bold',
status.color,
isPrimary ? 'text-base' : 'text-sm'
)}
>
{Math.round(safePercentage)}%
</span>
</div>
) : (
<span className="text-xs text-muted-foreground">N/A</span>
)}
</div>
<ProgressBar
percentage={safePercentage}
colorClass={isValidPercentage ? status.bg : 'bg-muted-foreground/30'}
/>
{resetText && (
<div className="mt-1.5 flex justify-end">
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
<Clock className="w-2.5 h-2.5" />
{resetText}
</p>
</div>
)}
</div>
);
}
// Provider usage panel component
function ProviderUsagePanel({
providerId,
usage,
isStale,
}: {
providerId: UsageProviderId;
usage: ProviderUsage;
isStale: boolean;
}) {
const ProviderIcon = PROVIDER_ICONS[providerId];
const dashboardUrl = PROVIDER_DASHBOARD_URLS[providerId];
if (!usage.available) {
return (
<div className="p-3 space-y-2">
<div className="flex items-center gap-2">
<ProviderIcon className="w-4 h-4" />
<span className="text-sm font-medium">{usage.providerName}</span>
</div>
<div className="flex flex-col items-center justify-center py-4 text-center space-y-2">
<AlertTriangle className="w-6 h-6 text-yellow-500/80" />
<p className="text-xs text-muted-foreground">
{usage.error || 'Not available'}
</p>
</div>
</div>
);
}
return (
<div className="p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ProviderIcon className="w-4 h-4" />
<span className="text-sm font-medium">{usage.providerName}</span>
</div>
{usage.plan && (
<span className="text-[10px] px-1.5 py-0.5 bg-secondary rounded text-muted-foreground">
{usage.plan.displayName}
</span>
)}
</div>
{usage.primary && (
<UsageCard
title={usage.primary.name}
subtitle={usage.primary.windowDurationMins ? `${usage.primary.windowDurationMins}min window` : 'Usage quota'}
percentage={usage.primary.usedPercent}
resetText={usage.primary.resetText}
isPrimary={true}
stale={isStale}
/>
)}
{usage.secondary && (
<UsageCard
title={usage.secondary.name}
subtitle={usage.secondary.windowDurationMins ? `${usage.secondary.windowDurationMins}min window` : 'Usage quota'}
percentage={usage.secondary.usedPercent}
resetText={usage.secondary.resetText}
stale={isStale}
/>
)}
{!usage.primary && !usage.secondary && (
<div className="text-xs text-muted-foreground text-center py-2">
{dashboardUrl ? (
<>
Check{' '}
<a
href={dashboardUrl}
target="_blank"
rel="noreferrer"
className="underline hover:text-foreground"
>
dashboard
</a>{' '}
for details
</>
) : (
'No usage data available'
)}
</div>
)}
</div>
);
}
export function ProviderUsageBar() {
const [open, setOpen] = useState(false);
const {
data: allUsage,
isLoading,
error,
dataUpdatedAt,
refetch,
} = useAllProvidersUsage(open);
// Calculate overall max usage percentage
const { maxPercent, maxProviderId, availableCount } = useMemo(() => {
if (!allUsage?.providers) {
return { maxPercent: 0, maxProviderId: null as UsageProviderId | null, availableCount: 0 };
}
let max = 0;
let maxId: UsageProviderId | null = null;
let count = 0;
for (const [id, usage] of Object.entries(allUsage.providers)) {
if (usage?.available) {
count++;
const percent = getMaxUsagePercent(usage);
if (percent > max) {
max = percent;
maxId = id as UsageProviderId;
}
}
}
return { maxPercent: max, maxProviderId: maxId, availableCount: count };
}, [allUsage]);
// Check if data is stale (older than 2 minutes)
const isStale = !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000;
const getProgressBarColor = (percentage: number) => {
if (percentage >= 90) return 'bg-red-500';
if (percentage >= 75) return 'bg-orange-500';
if (percentage >= 50) return 'bg-yellow-500';
return 'bg-green-500';
};
// Get the icon for the provider with highest usage
const MaxProviderIcon = maxProviderId ? PROVIDER_ICONS[maxProviderId] : AnthropicIcon;
const statusColor = getStatusInfo(maxPercent).color;
// Get list of available providers for the dropdown
const availableProviders = useMemo(() => {
if (!allUsage?.providers) return [];
return Object.entries(allUsage.providers)
.filter(([_, usage]) => usage?.available)
.map(([id, usage]) => ({ id: id as UsageProviderId, usage: usage! }));
}, [allUsage]);
const trigger = (
<Button variant="ghost" size="sm" className="h-9 gap-2 bg-secondary border border-border px-3">
{availableCount > 0 && <MaxProviderIcon className={cn('w-4 h-4', statusColor)} />}
<span className="text-sm font-medium">Usage</span>
{availableCount > 0 && (
<div
className={cn(
'h-1.5 w-16 bg-muted-foreground/20 rounded-full overflow-hidden transition-opacity',
isStale && 'opacity-60'
)}
>
<div
className={cn('h-full transition-all duration-500', getProgressBarColor(maxPercent))}
style={{ width: `${Math.min(maxPercent, 100)}%` }}
/>
</div>
)}
{availableCount > 1 && (
<span className="text-[10px] text-muted-foreground">+{availableCount - 1}</span>
)}
</Button>
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className="w-80 p-0 overflow-hidden bg-background/95 backdrop-blur-xl border-border shadow-2xl max-h-[80vh] overflow-y-auto"
align="end"
sideOffset={8}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-secondary/10 sticky top-0 z-10">
<span className="text-sm font-semibold">Provider Usage</span>
<Button
variant="ghost"
size="icon"
className={cn('h-6 w-6', isLoading && 'animate-spin')}
onClick={() => refetch()}
disabled={isLoading}
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
</div>
{/* Content */}
<div className="divide-y divide-border/50">
{isLoading && !allUsage ? (
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<Spinner size="lg" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3 px-4">
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
<div className="space-y-1">
<p className="text-sm font-medium">Failed to load usage</p>
<p className="text-xs text-muted-foreground">
{error instanceof Error ? error.message : 'Unknown error'}
</p>
</div>
</div>
) : availableProviders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3 px-4">
<AlertTriangle className="w-8 h-8 text-muted-foreground/50" />
<div className="space-y-1">
<p className="text-sm font-medium">No providers available</p>
<p className="text-xs text-muted-foreground">
Configure providers in Settings to track usage
</p>
</div>
</div>
) : (
availableProviders.map(({ id, usage }) => (
<ProviderUsagePanel
key={id}
providerId={id}
usage={usage}
isStale={isStale}
/>
))
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2 bg-secondary/10 border-t border-border/50 sticky bottom-0">
<span className="text-[10px] text-muted-foreground">
{availableCount} provider{availableCount !== 1 ? 's' : ''} active
</span>
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,13 +1,97 @@
import ReactMarkdown from 'react-markdown';
import ReactMarkdown, { Components } from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import remarkGfm from 'remark-gfm';
import { cn } from '@/lib/utils';
import { Square, CheckSquare } from 'lucide-react';
interface MarkdownProps {
children: string;
className?: string;
}
/**
* Renders a tasks code block as a proper task list with checkboxes
*/
function TasksBlock({ content }: { content: string }) {
const lines = content.split('\n');
return (
<div className="my-4 space-y-1">
{lines.map((line, idx) => {
const trimmed = line.trim();
// Check for phase/section headers (## Phase 1: ...)
const headerMatch = trimmed.match(/^##\s+(.+)$/);
if (headerMatch) {
return (
<div key={idx} className="text-foreground font-semibold mt-4 mb-2 text-sm">
{headerMatch[1]}
</div>
);
}
// Check for task items (- [ ] or - [x])
const taskMatch = trimmed.match(/^-\s*\[([ xX])\]\s*(.+)$/);
if (taskMatch) {
const isChecked = taskMatch[1].toLowerCase() === 'x';
const taskText = taskMatch[2];
return (
<div key={idx} className="flex items-start gap-2 py-1">
{isChecked ? (
<CheckSquare className="w-4 h-4 text-emerald-500 mt-0.5 flex-shrink-0" />
) : (
<Square className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
)}
<span
className={cn(
'text-sm',
isChecked ? 'text-muted-foreground line-through' : 'text-foreground-secondary'
)}
>
{taskText}
</span>
</div>
);
}
// Empty lines
if (!trimmed) {
return <div key={idx} className="h-2" />;
}
// Other content (render as-is)
return (
<div key={idx} className="text-sm text-foreground-secondary">
{trimmed}
</div>
);
})}
</div>
);
}
/**
* Custom components for ReactMarkdown
*/
const markdownComponents: Components = {
// Handle code blocks - special case for 'tasks' language
code({ className, children }) {
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
const content = String(children).replace(/\n$/, '');
// Special handling for tasks code blocks
if (language === 'tasks') {
return <TasksBlock content={content} />;
}
// Regular code (inline or block)
return <code className={className}>{children}</code>;
},
};
/**
* Reusable Markdown component for rendering markdown content
* Theme-aware styling that adapts to all predefined themes
@@ -42,10 +126,20 @@ export function Markdown({ children, className }: MarkdownProps) {
'[&_hr]:border-border [&_hr]:my-4',
// Images
'[&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-lg [&_img]:my-2 [&_img]:border [&_img]:border-border',
// Tables
'[&_table]:w-full [&_table]:border-collapse [&_table]:my-4',
'[&_th]:border [&_th]:border-border [&_th]:bg-muted [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:text-foreground [&_th]:font-semibold',
'[&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-2 [&_td]:text-foreground-secondary',
className
)}
>
<ReactMarkdown rehypePlugins={[rehypeRaw, rehypeSanitize]}>{children}</ReactMarkdown>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={markdownComponents}
>
{children}
</ReactMarkdown>
</div>
);
}

View File

@@ -395,6 +395,7 @@ export const PROVIDER_ICON_COMPONENTS: Record<
cursor: CursorIcon,
codex: OpenAIIcon,
opencode: OpenCodeIcon,
gemini: GeminiIcon,
};
/**

View File

@@ -0,0 +1,426 @@
'use client';
import { useEffect, useRef, useCallback, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import {
Terminal,
ArrowDown,
Square,
RefreshCw,
AlertCircle,
Clock,
GitBranch,
CheckCircle2,
XCircle,
FlaskConical,
} 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 { useTestLogs } from '@/hooks/use-test-logs';
import { useIsMobile } from '@/hooks/use-media-query';
import type { TestRunStatus } from '@/types/electron';
// ============================================================================
// Types
// ============================================================================
export interface TestLogsPanelProps {
/** Whether the panel is open */
open: boolean;
/** Callback when the panel is closed */
onClose: () => void;
/** Path to the worktree to show test logs for */
worktreePath: string | null;
/** Branch name for display */
branch?: string;
/** Specific session ID to fetch logs for (optional) */
sessionId?: string;
/** Callback to stop the running tests */
onStopTests?: () => void;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Get status indicator based on test run status
*/
function getStatusIndicator(status: TestRunStatus | null): {
text: string;
className: string;
icon?: React.ReactNode;
} {
switch (status) {
case 'running':
return {
text: 'Running',
className: 'bg-blue-500/10 text-blue-500',
icon: <span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />,
};
case 'pending':
return {
text: 'Pending',
className: 'bg-amber-500/10 text-amber-500',
icon: <Clock className="w-3 h-3" />,
};
case 'passed':
return {
text: 'Passed',
className: 'bg-green-500/10 text-green-500',
icon: <CheckCircle2 className="w-3 h-3" />,
};
case 'failed':
return {
text: 'Failed',
className: 'bg-red-500/10 text-red-500',
icon: <XCircle className="w-3 h-3" />,
};
case 'cancelled':
return {
text: 'Cancelled',
className: 'bg-yellow-500/10 text-yellow-500',
icon: <AlertCircle className="w-3 h-3" />,
};
case 'error':
return {
text: 'Error',
className: 'bg-red-500/10 text-red-500',
icon: <AlertCircle className="w-3 h-3" />,
};
default:
return {
text: 'Idle',
className: 'bg-muted text-muted-foreground',
};
}
}
/**
* Format duration in milliseconds to human-readable string
*/
function formatDuration(ms: number | null): string | null {
if (ms === null) return null;
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60000);
const seconds = ((ms % 60000) / 1000).toFixed(0);
return `${minutes}m ${seconds}s`;
}
/**
* Format timestamp to localized time string
*/
function formatTime(timestamp: string | null): string | null {
if (!timestamp) return null;
try {
const date = new Date(timestamp);
return date.toLocaleTimeString();
} catch {
return null;
}
}
// ============================================================================
// Inner Content Component
// ============================================================================
interface TestLogsPanelContentProps {
worktreePath: string | null;
branch?: string;
sessionId?: string;
onStopTests?: () => void;
}
function TestLogsPanelContent({
worktreePath,
branch,
sessionId,
onStopTests,
}: TestLogsPanelContentProps) {
const xtermRef = useRef<XtermLogViewerRef>(null);
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const lastLogsLengthRef = useRef(0);
const lastSessionIdRef = useRef<string | null>(null);
const {
logs,
isLoading,
error,
status,
sessionId: currentSessionId,
command,
testFile,
startedAt,
exitCode,
duration,
isRunning,
fetchLogs,
} = useTestLogs({
worktreePath,
sessionId,
autoSubscribe: true,
});
// Write logs to xterm when they change
useEffect(() => {
if (!xtermRef.current || !logs) return;
// If session changed, reset the terminal and write all content
if (lastSessionIdRef.current !== currentSessionId) {
lastSessionIdRef.current = currentSessionId;
lastLogsLengthRef.current = 0;
xtermRef.current.write(logs);
lastLogsLengthRef.current = logs.length;
return;
}
// If logs got shorter (e.g., cleared), rewrite all
if (logs.length < lastLogsLengthRef.current) {
xtermRef.current.write(logs);
lastLogsLengthRef.current = logs.length;
return;
}
// Append only the new content
if (logs.length > lastLogsLengthRef.current) {
const newContent = logs.slice(lastLogsLengthRef.current);
xtermRef.current.append(newContent);
lastLogsLengthRef.current = logs.length;
}
}, [logs, currentSessionId]);
// Reset auto-scroll when session changes
useEffect(() => {
if (currentSessionId !== lastSessionIdRef.current) {
setAutoScrollEnabled(true);
lastLogsLengthRef.current = 0;
}
}, [currentSessionId]);
// Scroll to bottom handler
const scrollToBottom = useCallback(() => {
xtermRef.current?.scrollToBottom();
setAutoScrollEnabled(true);
}, []);
const statusIndicator = getStatusIndicator(status);
const formattedStartTime = formatTime(startedAt);
const formattedDuration = formatDuration(duration);
const lineCount = logs ? logs.split('\n').length : 0;
return (
<>
{/* Header */}
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2 text-base">
<FlaskConical className="w-4 h-4 text-primary" />
<span>Test Runner</span>
{status && (
<span
className={cn(
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium',
statusIndicator.className
)}
>
{statusIndicator.icon}
{statusIndicator.text}
</span>
)}
{formattedDuration && !isRunning && (
<span className="text-xs text-muted-foreground font-mono">{formattedDuration}</span>
)}
</DialogTitle>
<div className="flex items-center gap-1.5">
{isRunning && onStopTests && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={onStopTests}
>
<Square className="w-3 h-3 mr-1.5 fill-current" />
Stop
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => fetchLogs()}
title="Refresh logs"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</div>
</div>
{/* Info bar */}
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
{branch && (
<span className="inline-flex items-center gap-1.5">
<GitBranch className="w-3 h-3" />
<span className="font-medium text-foreground/80">{branch}</span>
</span>
)}
{command && (
<span className="inline-flex items-center gap-1.5">
<span className="text-muted-foreground/60">Command</span>
<span className="font-mono text-primary truncate max-w-[200px]">{command}</span>
</span>
)}
{testFile && (
<span className="inline-flex items-center gap-1.5">
<span className="text-muted-foreground/60">File</span>
<span className="font-mono truncate max-w-[150px]">{testFile}</span>
</span>
)}
{formattedStartTime && (
<span className="inline-flex items-center gap-1.5">
<Clock className="w-3 h-3" />
{formattedStartTime}
</span>
)}
</div>
</DialogHeader>
{/* Error displays */}
{error && (
<div className="shrink-0 px-4 py-2 bg-destructive/5 border-b border-destructive/20">
<div className="flex items-center gap-2 text-xs text-destructive">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
<span>{error}</span>
</div>
</div>
)}
{/* Log content area */}
<div className="flex-1 min-h-0 overflow-hidden bg-zinc-950" data-testid="test-logs-content">
{isLoading && !logs ? (
<div className="flex items-center justify-center h-full min-h-[300px] text-muted-foreground">
<Spinner size="md" className="mr-2" />
<span className="text-sm">Loading logs...</span>
</div>
) : !logs && !isRunning && !status ? (
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
<Terminal className="w-10 h-10 mb-3 opacity-20" />
<p className="text-sm">No test run active</p>
<p className="text-xs mt-1 opacity-60">Start a test run to see logs here</p>
</div>
) : isRunning && !logs ? (
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
<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 tests generate output</p>
</div>
) : (
<XtermLogViewer
ref={xtermRef}
className="h-full"
minHeight={280}
autoScroll={autoScrollEnabled}
onScrollAwayFromBottom={() => setAutoScrollEnabled(false)}
onScrollToBottom={() => setAutoScrollEnabled(true)}
/>
)}
</div>
{/* Footer status bar */}
<div className="shrink-0 flex items-center justify-between px-4 py-2 bg-muted/30 border-t border-border/50 text-xs text-muted-foreground">
<div className="flex items-center gap-3">
<span className="font-mono">{lineCount > 0 ? `${lineCount} lines` : 'No output'}</span>
{exitCode !== null && (
<span className={cn('font-mono', exitCode === 0 ? 'text-green-500' : 'text-red-500')}>
Exit: {exitCode}
</span>
)}
</div>
{!autoScrollEnabled && logs && (
<button
onClick={scrollToBottom}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded hover:bg-muted transition-colors text-primary"
>
<ArrowDown className="w-3 h-3" />
Scroll to bottom
</button>
)}
{autoScrollEnabled && logs && (
<span className="inline-flex items-center gap-1.5 opacity-60">
<ArrowDown className="w-3 h-3" />
Auto-scroll
</span>
)}
</div>
</>
);
}
// ============================================================================
// Main Component
// ============================================================================
/**
* Panel component for displaying test runner logs with ANSI color rendering
* and real-time streaming support.
*
* Features:
* - Real-time log streaming via WebSocket
* - Full ANSI color code rendering via xterm.js
* - Auto-scroll to bottom (can be paused by scrolling up)
* - Test status indicators (pending, running, passed, failed, etc.)
* - Dialog on desktop, Sheet on mobile
* - Quick actions (stop tests, refresh logs)
*/
export function TestLogsPanel({
open,
onClose,
worktreePath,
branch,
sessionId,
onStopTests,
}: TestLogsPanelProps) {
const isMobile = useIsMobile();
if (!worktreePath) return null;
// Mobile: use Sheet (bottom drawer)
if (isMobile) {
return (
<Sheet open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<SheetContent side="bottom" className="h-[80vh] p-0 flex flex-col">
<SheetHeader className="sr-only">
<SheetTitle>Test Logs</SheetTitle>
</SheetHeader>
<TestLogsPanelContent
worktreePath={worktreePath}
branch={branch}
sessionId={sessionId}
onStopTests={onStopTests}
/>
</SheetContent>
</Sheet>
);
}
// Desktop: use Dialog
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden"
data-testid="test-logs-panel"
compact
>
<TestLogsPanelContent
worktreePath={worktreePath}
branch={branch}
sessionId={sessionId}
onStopTests={onStopTests}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -87,6 +87,7 @@ import { usePipelineConfig } from '@/hooks/queries';
import { useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
import { useAutoModeQueryInvalidation } from '@/hooks/use-query-invalidation';
import { useUpdateGlobalSettings } from '@/hooks/mutations/use-settings-mutations';
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
@@ -451,6 +452,8 @@ export function BoardView() {
const maxConcurrency = autoMode.maxConcurrency;
// Get worktree-specific setter
const setMaxConcurrencyForWorktree = useAppStore((state) => state.setMaxConcurrencyForWorktree);
// Mutation to persist maxConcurrency to server settings
const updateGlobalSettings = useUpdateGlobalSettings({ showSuccessToast: false });
// Get the current branch from the selected worktree (not from store which may be stale)
const currentWorktreeBranch = selectedWorktree?.branch ?? null;
@@ -1277,6 +1280,15 @@ export function BoardView() {
if (currentProject && selectedWorktree) {
const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch;
setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency);
// Persist to server settings so capacity checks use the correct value
const worktreeKey = `${currentProject.id}::${branchName ?? '__main__'}`;
updateGlobalSettings.mutate({
autoModeByWorktree: {
[worktreeKey]: { maxConcurrency: newMaxConcurrency },
},
});
// Also update backend if auto mode is running
if (autoMode.isRunning) {
// Restart auto mode with new concurrency (backend will handle this)
@@ -1489,6 +1501,7 @@ export function BoardView() {
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentWorktreeBranch || undefined}
projectPath={currentProject?.path}
/>
{/* Board Background Modal */}
@@ -1538,6 +1551,7 @@ export function BoardView() {
isMaximized={isMaximized}
parentFeature={spawnParentFeature}
allFeatures={hookFeatures}
projectPath={currentProject?.path}
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
selectedNonMainWorktreeBranch={
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
@@ -1568,6 +1582,7 @@ export function BoardView() {
currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized}
allFeatures={hookFeatures}
projectPath={currentProject?.path}
/>
{/* Agent Output Modal */}

View File

@@ -10,20 +10,22 @@ interface BoardControlsProps {
export function BoardControls({ isMounted, onShowBoardBackground }: BoardControlsProps) {
if (!isMounted) return null;
const buttonClass = cn(
'inline-flex h-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-all duration-200 cursor-pointer',
'text-muted-foreground hover:text-foreground hover:bg-accent',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'border border-border'
);
return (
<TooltipProvider>
<div className="flex items-center gap-5">
<div className="flex items-center gap-2">
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onShowBoardBackground}
className={cn(
'inline-flex h-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-all duration-200 cursor-pointer',
'text-muted-foreground hover:text-foreground hover:bg-accent',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'border border-border'
)}
className={buttonClass}
data-testid="board-background-button"
>
<ImageIcon className="w-4 h-4" />

View File

@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react';
import { UsagePopover } from '@/components/usage-popover';
import { ProviderUsageBar } from '@/components/provider-usage-bar';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { useIsTablet } from '@/hooks/use-media-query';
@@ -127,8 +127,8 @@ export function BoardHeader({
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
</div>
<div className="flex gap-4 items-center">
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
{/* Provider Usage Bar - shows all available providers, only on desktop */}
{isMounted && !isTablet && <ProviderUsageBar />}
{/* Tablet/Mobile view: show hamburger menu with all controls */}
{isMounted && isTablet && (

View File

@@ -1,4 +1,4 @@
import { memo, useEffect, useState, useMemo } from 'react';
import { memo, useEffect, useState, useMemo, useRef } from 'react';
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
import type { ReasoningEffort } from '@automaker/types';
import { getProviderFromModel } from '@/lib/utils';
@@ -69,21 +69,70 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
const [taskStatusMap, setTaskStatusMap] = useState<
Map<string, 'pending' | 'in_progress' | 'completed'>
>(new Map());
// Track last WebSocket event timestamp to know if we're receiving real-time updates
const [lastWsEventTimestamp, setLastWsEventTimestamp] = useState<number | null>(null);
// Determine if we should poll for updates
const shouldPoll = isCurrentAutoTask || feature.status === 'in_progress';
const shouldFetchData = feature.status !== 'backlog';
// Track whether we're receiving WebSocket events (within threshold)
// Use a state to trigger re-renders when the WebSocket connection becomes stale
const [isReceivingWsEvents, setIsReceivingWsEvents] = useState(false);
const wsEventTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// WebSocket activity threshold in ms - if no events within this time, consider WS inactive
const WS_ACTIVITY_THRESHOLD = 10000;
// Update isReceivingWsEvents when we get new WebSocket events
useEffect(() => {
if (lastWsEventTimestamp !== null) {
// We just received an event, mark as active
setIsReceivingWsEvents(true);
// Clear any existing timeout
if (wsEventTimeoutRef.current) {
clearTimeout(wsEventTimeoutRef.current);
}
// Set a timeout to mark as inactive if no new events
wsEventTimeoutRef.current = setTimeout(() => {
setIsReceivingWsEvents(false);
}, WS_ACTIVITY_THRESHOLD);
}
return () => {
if (wsEventTimeoutRef.current) {
clearTimeout(wsEventTimeoutRef.current);
}
};
}, [lastWsEventTimestamp]);
// Polling interval logic:
// - If receiving WebSocket events: use longer interval (10s) as a fallback
// - If not receiving WebSocket events but in_progress: use normal interval (3s)
// - Otherwise: no polling
const pollingInterval = useMemo((): number | false => {
if (!(isCurrentAutoTask || feature.status === 'in_progress')) {
return false;
}
// If receiving WebSocket events, use longer polling interval as fallback
if (isReceivingWsEvents) {
return WS_ACTIVITY_THRESHOLD;
}
// Default polling interval
return 3000;
}, [isCurrentAutoTask, feature.status, isReceivingWsEvents]);
// Fetch fresh feature data for planSpec (store data can be stale for task progress)
const { data: freshFeature } = useFeature(projectPath, feature.id, {
enabled: shouldFetchData && !contextContent,
pollingInterval: shouldPoll ? 3000 : false,
pollingInterval,
});
// Fetch agent output for parsing
const { data: agentOutputContent } = useAgentOutput(projectPath, feature.id, {
enabled: shouldFetchData && !contextContent,
pollingInterval: shouldPoll ? 3000 : false,
pollingInterval,
});
// Parse agent output into agentInfo
@@ -174,6 +223,9 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
// Only handle events for this feature
if (!('featureId' in event) || event.featureId !== feature.id) return;
// Update timestamp for any event related to this feature
setLastWsEventTimestamp(Date.now());
switch (event.type) {
case 'auto_mode_task_started':
if ('taskId' in event) {

View File

@@ -3,9 +3,10 @@ import { memo, useEffect, useMemo, useState } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
import { AlertCircle, Lock, Hand, Sparkles, SkipForward } from 'lucide-react';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { useShallow } from 'zustand/react/shallow';
import { usePipelineConfig } from '@/hooks/queries/use-pipeline';
/** Uniform badge style for all card badges */
const uniformBadgeClass =
@@ -51,9 +52,13 @@ export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps)
interface PriorityBadgesProps {
feature: Feature;
projectPath?: string;
}
export const PriorityBadges = memo(function PriorityBadges({ feature }: PriorityBadgesProps) {
export const PriorityBadges = memo(function PriorityBadges({
feature,
projectPath,
}: PriorityBadgesProps) {
const { enableDependencyBlocking, features } = useAppStore(
useShallow((state) => ({
enableDependencyBlocking: state.enableDependencyBlocking,
@@ -62,6 +67,9 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
);
const [currentTime, setCurrentTime] = useState(() => Date.now());
// Fetch pipeline config to check if there are pipelines to exclude
const { data: pipelineConfig } = usePipelineConfig(projectPath);
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
const blockingDependencies = useMemo(() => {
if (!enableDependencyBlocking || feature.status !== 'backlog') {
@@ -108,7 +116,19 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
const showManualVerification =
feature.skipTests && !feature.error && feature.status === 'backlog';
const showBadges = feature.priority || showManualVerification || isBlocked || isJustFinished;
// Check if feature has excluded pipeline steps
const excludedStepCount = feature.excludedPipelineSteps?.length || 0;
const totalPipelineSteps = pipelineConfig?.steps?.length || 0;
const hasPipelineExclusions =
excludedStepCount > 0 && totalPipelineSteps > 0 && feature.status === 'backlog';
const allPipelinesExcluded = hasPipelineExclusions && excludedStepCount >= totalPipelineSteps;
const showBadges =
feature.priority ||
showManualVerification ||
isBlocked ||
isJustFinished ||
hasPipelineExclusions;
if (!showBadges) {
return null;
@@ -227,6 +247,39 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
</Tooltip>
</TooltipProvider>
)}
{/* Pipeline exclusion badge */}
{hasPipelineExclusions && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
allPipelinesExcluded
? 'bg-violet-500/20 border-violet-500/50 text-violet-500'
: 'bg-violet-500/10 border-violet-500/30 text-violet-400'
)}
data-testid={`pipeline-exclusion-badge-${feature.id}`}
>
<SkipForward className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p className="font-medium mb-1">
{allPipelinesExcluded
? 'All pipelines skipped'
: `${excludedStepCount} of ${totalPipelineSteps} pipeline${totalPipelineSteps !== 1 ? 's' : ''} skipped`}
</p>
<p className="text-muted-foreground">
{allPipelinesExcluded
? 'This feature will skip all custom pipeline steps'
: 'Some custom pipeline steps will be skipped for this feature'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
});

View File

@@ -136,8 +136,9 @@ export const KanbanCard = memo(function KanbanCard({
});
// Make the card a drop target for creating dependency links
// Only backlog cards can be link targets (to avoid complexity with running features)
const isDroppable = !isOverlay && feature.status === 'backlog' && !isSelectionMode;
// All non-completed cards can be link targets to allow flexible dependency creation
// (completed features are excluded as they're already done)
const isDroppable = !isOverlay && feature.status !== 'completed' && !isSelectionMode;
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
id: `card-drop-${feature.id}`,
disabled: !isDroppable,
@@ -236,7 +237,7 @@ export const KanbanCard = memo(function KanbanCard({
</div>
{/* Priority and Manual Verification badges */}
<PriorityBadges feature={feature} />
<PriorityBadges feature={feature} projectPath={currentProject?.path} />
{/* Card Header */}
<CardHeaderSection

View File

@@ -45,6 +45,7 @@ import {
AncestorContextSection,
EnhanceWithAI,
EnhancementHistoryButton,
PipelineExclusionControls,
type BaseHistoryEntry,
} from '../shared';
import type { WorkMode } from '../shared';
@@ -101,6 +102,7 @@ type FeatureData = {
requirePlanApproval: boolean;
dependencies?: string[];
childDependencies?: string[]; // Feature IDs that should depend on this feature
excludedPipelineSteps?: string[]; // Pipeline step IDs to skip for this feature
workMode: WorkMode;
};
@@ -118,6 +120,10 @@ interface AddFeatureDialogProps {
isMaximized: boolean;
parentFeature?: Feature | null;
allFeatures?: Feature[];
/**
* Path to the current project for loading pipeline config.
*/
projectPath?: string;
/**
* When a non-main worktree is selected in the board header, this will be set to that worktree's branch.
* When set, the dialog will default to 'custom' work mode with this branch pre-filled.
@@ -151,6 +157,7 @@ export function AddFeatureDialog({
isMaximized,
parentFeature = null,
allFeatures = [],
projectPath,
selectedNonMainWorktreeBranch,
forceCurrentBranchMode,
}: AddFeatureDialogProps) {
@@ -194,9 +201,20 @@ export function AddFeatureDialog({
const [parentDependencies, setParentDependencies] = useState<string[]>([]);
const [childDependencies, setChildDependencies] = useState<string[]>([]);
// Pipeline exclusion state
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>([]);
// Get defaults from store
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees, defaultFeatureModel } =
useAppStore();
const {
defaultPlanningMode,
defaultRequirePlanApproval,
useWorktrees,
defaultFeatureModel,
currentProject,
} = useAppStore();
// Use project-level default feature model if set, otherwise fall back to global
const effectiveDefaultFeatureModel = currentProject?.defaultFeatureModel ?? defaultFeatureModel;
// Track previous open state to detect when dialog opens
const wasOpenRef = useRef(false);
@@ -216,7 +234,7 @@ export function AddFeatureDialog({
);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setModelEntry(defaultFeatureModel);
setModelEntry(effectiveDefaultFeatureModel);
// Initialize description history (empty for new feature)
setDescriptionHistory([]);
@@ -234,6 +252,9 @@ export function AddFeatureDialog({
// Reset dependency selections
setParentDependencies([]);
setChildDependencies([]);
// Reset pipeline exclusions (all pipelines enabled by default)
setExcludedPipelineSteps([]);
}
}, [
open,
@@ -241,7 +262,7 @@ export function AddFeatureDialog({
defaultBranch,
defaultPlanningMode,
defaultRequirePlanApproval,
defaultFeatureModel,
effectiveDefaultFeatureModel,
useWorktrees,
selectedNonMainWorktreeBranch,
forceCurrentBranchMode,
@@ -328,6 +349,7 @@ export function AddFeatureDialog({
requirePlanApproval,
dependencies: finalDependencies,
childDependencies: childDependencies.length > 0 ? childDependencies : undefined,
excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined,
workMode,
};
};
@@ -343,7 +365,7 @@ export function AddFeatureDialog({
// When a non-main worktree is selected, use its branch name for custom mode
setBranchName(selectedNonMainWorktreeBranch || '');
setPriority(2);
setModelEntry(defaultFeatureModel);
setModelEntry(effectiveDefaultFeatureModel);
setWorkMode(
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
);
@@ -354,6 +376,7 @@ export function AddFeatureDialog({
setDescriptionHistory([]);
setParentDependencies([]);
setChildDependencies([]);
setExcludedPipelineSteps([]);
onOpenChange(false);
};
@@ -696,6 +719,16 @@ export function AddFeatureDialog({
</div>
</div>
)}
{/* Pipeline Exclusion Controls */}
<div className="pt-2">
<PipelineExclusionControls
projectPath={projectPath}
excludedPipelineSteps={excludedPipelineSteps}
onExcludedStepsChange={setExcludedPipelineSteps}
testIdPrefix="add-feature-pipeline"
/>
</div>
</div>
</div>

View File

@@ -12,6 +12,8 @@ import { Button } from '@/components/ui/button';
import { ArrowDown, ArrowUp, Link2, X } from 'lucide-react';
import type { Feature } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { StatusBadge } from '../components';
import type { FeatureStatusWithPipeline } from '@automaker/types';
export type DependencyLinkType = 'parent' | 'child';
@@ -57,7 +59,10 @@ export function DependencyLinkDialog({
<div className="py-4 space-y-4">
{/* Dragged feature */}
<div className="p-3 rounded-lg border bg-muted/30">
<div className="text-xs text-muted-foreground mb-1">Dragged Feature</div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-muted-foreground">Dragged Feature</span>
<StatusBadge status={draggedFeature.status as FeatureStatusWithPipeline} size="sm" />
</div>
<div className="text-sm font-medium line-clamp-3 break-words">
{draggedFeature.description}
</div>
@@ -71,7 +76,10 @@ export function DependencyLinkDialog({
{/* Target feature */}
<div className="p-3 rounded-lg border bg-muted/30">
<div className="text-xs text-muted-foreground mb-1">Target Feature</div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-muted-foreground">Target Feature</span>
<StatusBadge status={targetFeature.status as FeatureStatusWithPipeline} size="sm" />
</div>
<div className="text-sm font-medium line-clamp-3 break-words">
{targetFeature.description}
</div>

View File

@@ -36,6 +36,7 @@ import {
PlanningModeSelect,
EnhanceWithAI,
EnhancementHistoryButton,
PipelineExclusionControls,
type EnhancementMode,
} from '../shared';
import type { WorkMode } from '../shared';
@@ -67,6 +68,7 @@ interface EditFeatureDialogProps {
requirePlanApproval: boolean;
dependencies?: string[];
childDependencies?: string[]; // Feature IDs that should depend on this feature
excludedPipelineSteps?: string[]; // Pipeline step IDs to skip for this feature
},
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: EnhancementMode,
@@ -78,6 +80,7 @@ interface EditFeatureDialogProps {
currentBranch?: string;
isMaximized: boolean;
allFeatures: Feature[];
projectPath?: string;
}
export function EditFeatureDialog({
@@ -90,6 +93,7 @@ export function EditFeatureDialog({
currentBranch,
isMaximized,
allFeatures,
projectPath,
}: EditFeatureDialogProps) {
const navigate = useNavigate();
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
@@ -146,6 +150,11 @@ export function EditFeatureDialog({
return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id);
});
// Pipeline exclusion state
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>(
feature?.excludedPipelineSteps ?? []
);
useEffect(() => {
setEditingFeature(feature);
if (feature) {
@@ -171,6 +180,8 @@ export function EditFeatureDialog({
.map((f) => f.id);
setChildDependencies(childDeps);
setOriginalChildDependencies(childDeps);
// Reset pipeline exclusion state
setExcludedPipelineSteps(feature.excludedPipelineSteps ?? []);
} else {
setEditFeaturePreviewMap(new Map());
setDescriptionChangeSource(null);
@@ -179,6 +190,7 @@ export function EditFeatureDialog({
setParentDependencies([]);
setChildDependencies([]);
setOriginalChildDependencies([]);
setExcludedPipelineSteps([]);
}
}, [feature, allFeatures]);
@@ -232,6 +244,7 @@ export function EditFeatureDialog({
workMode,
dependencies: parentDependencies,
childDependencies: childDepsChanged ? childDependencies : undefined,
excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined,
};
// Determine if description changed and what source to use
@@ -618,6 +631,16 @@ export function EditFeatureDialog({
</div>
</div>
)}
{/* Pipeline Exclusion Controls */}
<div className="pt-2">
<PipelineExclusionControls
projectPath={projectPath}
excludedPipelineSteps={excludedPipelineSteps}
onExcludedStepsChange={setExcludedPipelineSteps}
testIdPrefix="edit-feature-pipeline"
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,196 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Download, FileJson, FileText } from 'lucide-react';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Feature } from '@/store/app-store';
type ExportFormat = 'json' | 'yaml';
interface ExportFeaturesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectPath: string;
features: Feature[];
selectedFeatureIds?: string[];
}
export function ExportFeaturesDialog({
open,
onOpenChange,
projectPath,
features,
selectedFeatureIds,
}: ExportFeaturesDialogProps) {
const [format, setFormat] = useState<ExportFormat>('json');
const [includeHistory, setIncludeHistory] = useState(true);
const [includePlanSpec, setIncludePlanSpec] = useState(true);
const [isExporting, setIsExporting] = useState(false);
// Determine which features to export
const featuresToExport =
selectedFeatureIds && selectedFeatureIds.length > 0
? features.filter((f) => selectedFeatureIds.includes(f.id))
: features;
// Reset state when dialog opens
useEffect(() => {
if (open) {
setFormat('json');
setIncludeHistory(true);
setIncludePlanSpec(true);
}
}, [open]);
const handleExport = async () => {
setIsExporting(true);
try {
const api = getHttpApiClient();
const result = await api.features.export(projectPath, {
featureIds: selectedFeatureIds,
format,
includeHistory,
includePlanSpec,
prettyPrint: true,
});
if (!result.success || !result.data) {
toast.error(result.error || 'Failed to export features');
return;
}
// Create a blob and trigger download
const mimeType = format === 'json' ? 'application/json' : 'application/x-yaml';
const blob = new Blob([result.data], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = result.filename || `features-export.${format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(`Exported ${featuresToExport.length} feature(s) to ${format.toUpperCase()}`);
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to export features');
} finally {
setIsExporting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="export-features-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Download className="w-5 h-5" />
Export Features
</DialogTitle>
<DialogDescription>
Export {featuresToExport.length} feature(s) to a file for backup or sharing with other
projects.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{/* Format Selection */}
<div className="space-y-2">
<Label>Export Format</Label>
<Select value={format} onValueChange={(v) => setFormat(v as ExportFormat)}>
<SelectTrigger data-testid="export-format-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="json">
<div className="flex items-center gap-2">
<FileJson className="w-4 h-4" />
<span>JSON</span>
</div>
</SelectItem>
<SelectItem value="yaml">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4" />
<span>YAML</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Options */}
<div className="space-y-3">
<Label>Options</Label>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="include-history"
checked={includeHistory}
onCheckedChange={(checked) => setIncludeHistory(!!checked)}
data-testid="export-include-history"
/>
<Label htmlFor="include-history" className="text-sm font-normal cursor-pointer">
Include description history
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="include-plan-spec"
checked={includePlanSpec}
onCheckedChange={(checked) => setIncludePlanSpec(!!checked)}
data-testid="export-include-plan-spec"
/>
<Label htmlFor="include-plan-spec" className="text-sm font-normal cursor-pointer">
Include plan specifications
</Label>
</div>
</div>
</div>
{/* Features to Export Preview */}
{featuresToExport.length > 0 && featuresToExport.length <= 10 && (
<div className="space-y-2">
<Label className="text-muted-foreground">Features to export</Label>
<div className="max-h-32 overflow-y-auto rounded-md border border-border/50 bg-muted/30 p-2 text-sm">
{featuresToExport.map((f) => (
<div key={f.id} className="py-1 px-2 truncate text-muted-foreground">
{f.title || f.description.slice(0, 50)}...
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isExporting}>
Cancel
</Button>
<Button onClick={handleExport} disabled={isExporting} data-testid="confirm-export">
<Download className="w-4 h-4 mr-2" />
{isExporting ? 'Exporting...' : 'Export'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,474 @@
import { useState, useEffect, useRef } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
import { Upload, AlertTriangle, CheckCircle2, XCircle, FileJson, FileText } from 'lucide-react';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { cn } from '@/lib/utils';
interface ConflictInfo {
featureId: string;
title?: string;
existingTitle?: string;
hasConflict: boolean;
}
interface ImportResult {
success: boolean;
featureId?: string;
importedAt: string;
warnings?: string[];
errors?: string[];
wasOverwritten?: boolean;
}
interface ImportFeaturesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectPath: string;
categorySuggestions: string[];
onImportComplete?: () => void;
}
type ImportStep = 'upload' | 'review' | 'result';
export function ImportFeaturesDialog({
open,
onOpenChange,
projectPath,
categorySuggestions,
onImportComplete,
}: ImportFeaturesDialogProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [step, setStep] = useState<ImportStep>('upload');
const [fileData, setFileData] = useState<string>('');
const [fileName, setFileName] = useState<string>('');
const [fileFormat, setFileFormat] = useState<'json' | 'yaml' | null>(null);
// Options
const [overwrite, setOverwrite] = useState(false);
const [targetCategory, setTargetCategory] = useState('');
// Conflict check results
const [conflicts, setConflicts] = useState<ConflictInfo[]>([]);
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false);
// Import results
const [importResults, setImportResults] = useState<ImportResult[]>([]);
const [isImporting, setIsImporting] = useState(false);
// Parse error
const [parseError, setParseError] = useState<string>('');
// Reset state when dialog opens
useEffect(() => {
if (open) {
setStep('upload');
setFileData('');
setFileName('');
setFileFormat(null);
setOverwrite(false);
setTargetCategory('');
setConflicts([]);
setImportResults([]);
setParseError('');
}
}, [open]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Check file extension
const ext = file.name.split('.').pop()?.toLowerCase();
if (ext !== 'json' && ext !== 'yaml' && ext !== 'yml') {
setParseError('Please select a JSON or YAML file');
return;
}
try {
const content = await file.text();
setFileData(content);
setFileName(file.name);
setFileFormat(ext === 'yml' ? 'yaml' : (ext as 'json' | 'yaml'));
setParseError('');
// Check for conflicts
await checkConflicts(content);
} catch {
setParseError('Failed to read file');
}
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const checkConflicts = async (data: string) => {
setIsCheckingConflicts(true);
try {
const api = getHttpApiClient();
const result = await api.features.checkConflicts(projectPath, data);
if (!result.success) {
setParseError(result.error || 'Failed to parse import file');
setConflicts([]);
return;
}
setConflicts(result.conflicts || []);
setStep('review');
} catch (error) {
setParseError(error instanceof Error ? error.message : 'Failed to check conflicts');
} finally {
setIsCheckingConflicts(false);
}
};
const handleImport = async () => {
setIsImporting(true);
try {
const api = getHttpApiClient();
const result = await api.features.import(projectPath, fileData, {
overwrite,
targetCategory: targetCategory || undefined,
});
if (!result.success && result.failedCount === result.results?.length) {
toast.error(result.error || 'Failed to import features');
return;
}
setImportResults(result.results || []);
setStep('result');
const successCount = result.importedCount || 0;
const failCount = result.failedCount || 0;
if (failCount === 0) {
toast.success(`Successfully imported ${successCount} feature(s)`);
} else if (successCount > 0) {
toast.warning(`Imported ${successCount} feature(s), ${failCount} failed`);
} else {
toast.error(`Failed to import features`);
}
onImportComplete?.();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to import features');
} finally {
setIsImporting(false);
}
};
const handleDrop = async (event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
const file = event.dataTransfer.files[0];
if (!file) return;
const ext = file.name.split('.').pop()?.toLowerCase();
if (ext !== 'json' && ext !== 'yaml' && ext !== 'yml') {
setParseError('Please drop a JSON or YAML file');
return;
}
try {
const content = await file.text();
setFileData(content);
setFileName(file.name);
setFileFormat(ext === 'yml' ? 'yaml' : (ext as 'json' | 'yaml'));
setParseError('');
await checkConflicts(content);
} catch {
setParseError('Failed to read file');
}
};
const handleDragOver = (event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
};
const conflictingFeatures = conflicts.filter((c) => c.hasConflict);
const hasConflicts = conflictingFeatures.length > 0;
const renderUploadStep = () => (
<div className="py-4 space-y-4">
{/* Drop Zone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
className={cn(
'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer',
'hover:border-primary/50 hover:bg-muted/30',
parseError ? 'border-destructive/50' : 'border-border'
)}
onClick={() => fileInputRef.current?.click()}
data-testid="import-drop-zone"
>
<input
ref={fileInputRef}
type="file"
accept=".json,.yaml,.yml"
onChange={handleFileSelect}
className="hidden"
/>
<div className="flex flex-col items-center gap-3">
<Upload className="w-8 h-8 text-muted-foreground" />
<div className="text-sm">
<span className="text-primary font-medium">Click to upload</span>
<span className="text-muted-foreground"> or drag and drop</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileJson className="w-3.5 h-3.5" />
<span>JSON</span>
<span>or</span>
<FileText className="w-3.5 h-3.5" />
<span>YAML</span>
</div>
</div>
</div>
{parseError && (
<div className="flex items-center gap-2 text-sm text-destructive">
<XCircle className="w-4 h-4" />
{parseError}
</div>
)}
{isCheckingConflicts && (
<div className="text-sm text-muted-foreground text-center">Analyzing file...</div>
)}
</div>
);
const renderReviewStep = () => (
<div className="py-4 space-y-4">
{/* File Info */}
<div className="flex items-center gap-2 p-3 rounded-md border border-border/50 bg-muted/30">
{fileFormat === 'json' ? (
<FileJson className="w-5 h-5 text-muted-foreground" />
) : (
<FileText className="w-5 h-5 text-muted-foreground" />
)}
<div className="flex-1 truncate">
<div className="text-sm font-medium">{fileName}</div>
<div className="text-xs text-muted-foreground">
{conflicts.length} feature(s) to import
</div>
</div>
</div>
{/* Conflict Warning */}
{hasConflicts && (
<div className="flex items-start gap-2 p-3 rounded-md border border-warning/50 bg-warning/10">
<AlertTriangle className="w-5 h-5 text-warning shrink-0 mt-0.5" />
<div className="space-y-1">
<div className="text-sm font-medium text-warning">
{conflictingFeatures.length} conflict(s) detected
</div>
<div className="text-xs text-muted-foreground">
The following features already exist in this project:
</div>
<ul className="text-xs text-muted-foreground list-disc list-inside max-h-24 overflow-y-auto">
{conflictingFeatures.map((c) => (
<li key={c.featureId} className="truncate">
{c.existingTitle || c.featureId}
</li>
))}
</ul>
</div>
</div>
)}
{/* Options */}
<div className="space-y-3">
<Label>Import Options</Label>
{hasConflicts && (
<div className="flex items-center gap-2">
<Checkbox
id="overwrite"
checked={overwrite}
onCheckedChange={(checked) => setOverwrite(!!checked)}
data-testid="import-overwrite"
/>
<Label htmlFor="overwrite" className="text-sm font-normal cursor-pointer">
Overwrite existing features with same ID
</Label>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">
Target Category (optional - override imported categories)
</Label>
<CategoryAutocomplete
value={targetCategory}
onChange={setTargetCategory}
suggestions={categorySuggestions}
placeholder="Keep original categories"
data-testid="import-target-category"
/>
</div>
</div>
{/* Features Preview */}
<div className="space-y-2">
<Label className="text-muted-foreground">Features to import</Label>
<div className="max-h-40 overflow-y-auto rounded-md border border-border/50 bg-muted/30 p-2 text-sm">
{conflicts.map((c) => (
<div
key={c.featureId}
className={cn(
'py-1 px-2 flex items-center gap-2',
c.hasConflict && !overwrite ? 'text-warning' : 'text-muted-foreground'
)}
>
{c.hasConflict ? (
overwrite ? (
<CheckCircle2 className="w-3.5 h-3.5 text-primary shrink-0" />
) : (
<AlertTriangle className="w-3.5 h-3.5 text-warning shrink-0" />
)
) : (
<CheckCircle2 className="w-3.5 h-3.5 text-primary shrink-0" />
)}
<span className="truncate">{c.title || c.featureId}</span>
{c.hasConflict && !overwrite && (
<span className="text-xs text-warning">(will skip)</span>
)}
</div>
))}
</div>
</div>
</div>
);
const renderResultStep = () => {
const successResults = importResults.filter((r) => r.success);
const failedResults = importResults.filter((r) => !r.success);
return (
<div className="py-4 space-y-4">
{/* Summary */}
<div className="flex items-center gap-4 justify-center">
{successResults.length > 0 && (
<div className="flex items-center gap-2 text-primary">
<CheckCircle2 className="w-5 h-5" />
<span className="font-medium">{successResults.length} imported</span>
</div>
)}
{failedResults.length > 0 && (
<div className="flex items-center gap-2 text-destructive">
<XCircle className="w-5 h-5" />
<span className="font-medium">{failedResults.length} failed</span>
</div>
)}
</div>
{/* Results List */}
<div className="max-h-60 overflow-y-auto rounded-md border border-border/50 bg-muted/30 p-2 text-sm space-y-1">
{importResults.map((result, idx) => (
<div
key={idx}
className={cn(
'py-1.5 px-2 rounded',
result.success ? 'text-foreground' : 'text-destructive bg-destructive/10'
)}
>
<div className="flex items-center gap-2">
{result.success ? (
<CheckCircle2 className="w-3.5 h-3.5 text-primary shrink-0" />
) : (
<XCircle className="w-3.5 h-3.5 text-destructive shrink-0" />
)}
<span className="truncate">{result.featureId || `Feature ${idx + 1}`}</span>
{result.wasOverwritten && (
<span className="text-xs text-muted-foreground">(overwritten)</span>
)}
</div>
{result.warnings && result.warnings.length > 0 && (
<div className="mt-1 pl-5 text-xs text-warning">
{result.warnings.map((w, i) => (
<div key={i}>{w}</div>
))}
</div>
)}
{result.errors && result.errors.length > 0 && (
<div className="mt-1 pl-5 text-xs text-destructive">
{result.errors.map((e, i) => (
<div key={i}>{e}</div>
))}
</div>
)}
</div>
))}
</div>
</div>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="import-features-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="w-5 h-5" />
Import Features
</DialogTitle>
<DialogDescription>
{step === 'upload' && 'Import features from a JSON or YAML export file.'}
{step === 'review' && 'Review and configure import options.'}
{step === 'result' && 'Import completed.'}
</DialogDescription>
</DialogHeader>
{step === 'upload' && renderUploadStep()}
{step === 'review' && renderReviewStep()}
{step === 'result' && renderResultStep()}
<DialogFooter>
{step === 'upload' && (
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
)}
{step === 'review' && (
<>
<Button variant="ghost" onClick={() => setStep('upload')}>
Back
</Button>
<Button onClick={handleImport} disabled={isImporting} data-testid="confirm-import">
<Upload className="w-4 h-4 mr-2" />
{isImporting
? 'Importing...'
: `Import ${hasConflicts && !overwrite ? conflicts.filter((c) => !c.hasConflict).length : conflicts.length} Feature(s)`}
</Button>
</>
)}
{step === 'result' && (
<Button onClick={() => onOpenChange(false)} data-testid="close-import">
Done
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -13,3 +13,5 @@ export { MassEditDialog } from './mass-edit-dialog';
export { PullResolveConflictsDialog } from './pull-resolve-conflicts-dialog';
export { PushToRemoteDialog } from './push-to-remote-dialog';
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';
export { ExportFeaturesDialog } from './export-features-dialog';
export { ImportFeaturesDialog } from './import-features-dialog';

View File

@@ -13,7 +13,13 @@ import { Label } from '@/components/ui/label';
import { AlertCircle } from 'lucide-react';
import { modelSupportsThinking } from '@/lib/utils';
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
import { TestingTabContent, PrioritySelect, PlanningModeSelect, WorkModeSelector } from '../shared';
import {
TestingTabContent,
PrioritySelect,
PlanningModeSelect,
WorkModeSelector,
PipelineExclusionControls,
} from '../shared';
import type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
@@ -28,6 +34,7 @@ interface MassEditDialogProps {
branchSuggestions: string[];
branchCardCounts?: Record<string, number>;
currentBranch?: string;
projectPath?: string;
}
interface ApplyState {
@@ -38,11 +45,13 @@ interface ApplyState {
priority: boolean;
skipTests: boolean;
branchName: boolean;
excludedPipelineSteps: boolean;
}
function getMixedValues(features: Feature[]): Record<string, boolean> {
if (features.length === 0) return {};
const first = features[0];
const firstExcludedSteps = JSON.stringify(first.excludedPipelineSteps || []);
return {
model: !features.every((f) => f.model === first.model),
thinkingLevel: !features.every((f) => f.thinkingLevel === first.thinkingLevel),
@@ -53,6 +62,9 @@ function getMixedValues(features: Feature[]): Record<string, boolean> {
priority: !features.every((f) => f.priority === first.priority),
skipTests: !features.every((f) => f.skipTests === first.skipTests),
branchName: !features.every((f) => f.branchName === first.branchName),
excludedPipelineSteps: !features.every(
(f) => JSON.stringify(f.excludedPipelineSteps || []) === firstExcludedSteps
),
};
}
@@ -111,6 +123,7 @@ export function MassEditDialog({
branchSuggestions,
branchCardCounts,
currentBranch,
projectPath,
}: MassEditDialogProps) {
const [isApplying, setIsApplying] = useState(false);
@@ -123,6 +136,7 @@ export function MassEditDialog({
priority: false,
skipTests: false,
branchName: false,
excludedPipelineSteps: false,
});
// Field values
@@ -146,6 +160,11 @@ export function MassEditDialog({
return getInitialValue(selectedFeatures, 'branchName', '') as string;
});
// Pipeline exclusion state
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>(() => {
return getInitialValue(selectedFeatures, 'excludedPipelineSteps', []) as string[];
});
// Calculate mixed values
const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]);
@@ -160,6 +179,7 @@ export function MassEditDialog({
priority: false,
skipTests: false,
branchName: false,
excludedPipelineSteps: false,
});
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
@@ -172,6 +192,10 @@ export function MassEditDialog({
const initialBranchName = getInitialValue(selectedFeatures, 'branchName', '') as string;
setBranchName(initialBranchName);
setWorkMode(initialBranchName ? 'custom' : 'current');
// Reset pipeline exclusions
setExcludedPipelineSteps(
getInitialValue(selectedFeatures, 'excludedPipelineSteps', []) as string[]
);
}
}, [open, selectedFeatures]);
@@ -190,6 +214,10 @@ export function MassEditDialog({
// For 'custom' mode, use the specified branch name
updates.branchName = workMode === 'custom' ? branchName : '';
}
if (applyState.excludedPipelineSteps) {
updates.excludedPipelineSteps =
excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined;
}
if (Object.keys(updates).length === 0) {
onClose();
@@ -353,6 +381,23 @@ export function MassEditDialog({
testIdPrefix="mass-edit-work-mode"
/>
</FieldWrapper>
{/* Pipeline Exclusion */}
<FieldWrapper
label="Pipeline Steps"
isMixed={mixedValues.excludedPipelineSteps}
willApply={applyState.excludedPipelineSteps}
onApplyChange={(apply) =>
setApplyState((prev) => ({ ...prev, excludedPipelineSteps: apply }))
}
>
<PipelineExclusionControls
projectPath={projectPath}
excludedPipelineSteps={excludedPipelineSteps}
onExcludedStepsChange={setExcludedPipelineSteps}
testIdPrefix="mass-edit-pipeline"
/>
</FieldWrapper>
</div>
<DialogFooter>

View File

@@ -11,7 +11,7 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Markdown } from '@/components/ui/markdown';
import { PlanContentViewer } from './plan-content-viewer';
import { Label } from '@/components/ui/label';
import { Feature } from '@/store/app-store';
import { Check, RefreshCw, Edit2, Eye } from 'lucide-react';
@@ -42,6 +42,10 @@ export function PlanApprovalDialog({
const [editedPlan, setEditedPlan] = useState(planContent);
const [showRejectFeedback, setShowRejectFeedback] = useState(false);
const [rejectFeedback, setRejectFeedback] = useState('');
const [showFullDescription, setShowFullDescription] = useState(false);
const DESCRIPTION_LIMIT = 250;
const TITLE_LIMIT = 50;
// Reset state when dialog opens or plan content changes
useEffect(() => {
@@ -50,6 +54,7 @@ export function PlanApprovalDialog({
setIsEditMode(false);
setShowRejectFeedback(false);
setRejectFeedback('');
setShowFullDescription(false);
}
}, [open, planContent]);
@@ -82,15 +87,31 @@ export function PlanApprovalDialog({
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl" data-testid="plan-approval-dialog">
<DialogHeader>
<DialogTitle>{viewOnly ? 'View Plan' : 'Review Plan'}</DialogTitle>
<DialogTitle>
{viewOnly ? 'View Plan' : 'Review Plan'}
{feature?.title && feature.title.length <= TITLE_LIMIT && (
<span className="font-normal text-muted-foreground"> - {feature.title}</span>
)}
</DialogTitle>
<DialogDescription>
{viewOnly
? 'View the generated plan for this feature.'
: 'Review the generated plan before implementation begins.'}
{feature && (
<span className="block mt-2 text-primary">
Feature: {feature.description.slice(0, 150)}
{feature.description.length > 150 ? '...' : ''}
Feature:{' '}
{showFullDescription || feature.description.length <= DESCRIPTION_LIMIT
? feature.description
: `${feature.description.slice(0, DESCRIPTION_LIMIT)}...`}
{feature.description.length > DESCRIPTION_LIMIT && (
<button
type="button"
onClick={() => setShowFullDescription(!showFullDescription)}
className="ml-1 text-muted-foreground hover:text-foreground underline text-sm"
>
{showFullDescription ? 'show less' : 'show more'}
</button>
)}
</span>
)}
</DialogDescription>
@@ -135,9 +156,7 @@ export function PlanApprovalDialog({
disabled={isLoading}
/>
) : (
<div className="p-4 overflow-auto">
<Markdown>{editedPlan || 'No plan content available.'}</Markdown>
</div>
<PlanContentViewer content={editedPlan || ''} className="p-4" />
)}
</div>

View File

@@ -0,0 +1,216 @@
'use client';
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Wrench } from 'lucide-react';
import { Markdown } from '@/components/ui/markdown';
import { cn } from '@/lib/utils';
interface ToolCall {
tool: string;
input: string;
}
interface ParsedPlanContent {
toolCalls: ToolCall[];
planMarkdown: string;
}
/**
* Parses plan content to separate tool calls from the actual plan/specification markdown.
* Tool calls appear at the beginning (exploration phase), followed by the plan markdown.
*/
function parsePlanContent(content: string): ParsedPlanContent {
const lines = content.split('\n');
const toolCalls: ToolCall[] = [];
let planStartIndex = -1;
let currentTool: string | null = null;
let currentInput: string[] = [];
let inJsonBlock = false;
let braceDepth = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Check if this line starts the actual plan/spec (markdown heading)
// Plans typically start with # or ## headings
if (
!inJsonBlock &&
(trimmed.match(/^#{1,3}\s+\S/) || // Markdown headings (including emoji like ## ✅ Plan)
trimmed.startsWith('---') || // Horizontal rule often used as separator
trimmed.match(/^\*\*\S/)) // Bold text starting a section
) {
// Flush any active tool call before starting the plan
if (currentTool && currentInput.length > 0) {
toolCalls.push({
tool: currentTool,
input: currentInput.join('\n').trim(),
});
currentTool = null;
currentInput = [];
}
planStartIndex = i;
break;
}
// Detect tool call start (supports tool names with dots/hyphens like web.run, file-read)
const toolMatch = trimmed.match(/^(?:🔧\s*)?Tool:\s*([^\s]+)/i);
if (toolMatch && !inJsonBlock) {
// Save previous tool call if exists
if (currentTool && currentInput.length > 0) {
toolCalls.push({
tool: currentTool,
input: currentInput.join('\n').trim(),
});
}
currentTool = toolMatch[1];
currentInput = [];
continue;
}
// Detect Input: line
if (trimmed.startsWith('Input:') && currentTool) {
const inputContent = trimmed.replace(/^Input:\s*/, '');
if (inputContent) {
currentInput.push(inputContent);
// Check if JSON starts
if (inputContent.includes('{')) {
braceDepth =
(inputContent.match(/\{/g) || []).length - (inputContent.match(/\}/g) || []).length;
inJsonBlock = braceDepth > 0;
}
}
continue;
}
// If we're collecting input for a tool
if (currentTool) {
if (inJsonBlock) {
currentInput.push(line);
braceDepth += (trimmed.match(/\{/g) || []).length - (trimmed.match(/\}/g) || []).length;
if (braceDepth <= 0) {
inJsonBlock = false;
// Save tool call
toolCalls.push({
tool: currentTool,
input: currentInput.join('\n').trim(),
});
currentTool = null;
currentInput = [];
}
} else if (trimmed.startsWith('{')) {
// JSON block starting
currentInput.push(line);
braceDepth = (trimmed.match(/\{/g) || []).length - (trimmed.match(/\}/g) || []).length;
inJsonBlock = braceDepth > 0;
if (!inJsonBlock) {
// Single-line JSON
toolCalls.push({
tool: currentTool,
input: currentInput.join('\n').trim(),
});
currentTool = null;
currentInput = [];
}
} else if (trimmed === '') {
// Empty line might end the tool call section
if (currentInput.length > 0) {
toolCalls.push({
tool: currentTool,
input: currentInput.join('\n').trim(),
});
currentTool = null;
currentInput = [];
}
}
}
}
// Save any remaining tool call
if (currentTool && currentInput.length > 0) {
toolCalls.push({
tool: currentTool,
input: currentInput.join('\n').trim(),
});
}
// Extract plan markdown
let planMarkdown = '';
if (planStartIndex >= 0) {
planMarkdown = lines.slice(planStartIndex).join('\n').trim();
} else if (toolCalls.length === 0) {
// No tool calls found, treat entire content as markdown
planMarkdown = content.trim();
}
return { toolCalls, planMarkdown };
}
interface PlanContentViewerProps {
content: string;
className?: string;
}
export function PlanContentViewer({ content, className }: PlanContentViewerProps) {
const [showToolCalls, setShowToolCalls] = useState(false);
const { toolCalls, planMarkdown } = useMemo(() => parsePlanContent(content), [content]);
if (!content || !content.trim()) {
return (
<div className={cn('text-muted-foreground text-center py-8', className)}>
No plan content available.
</div>
);
}
return (
<div className={cn('space-y-4', className)}>
{/* Tool Calls Section - Collapsed by default */}
{toolCalls.length > 0 && (
<div className="border border-border rounded-lg overflow-hidden">
<button
onClick={() => setShowToolCalls(!showToolCalls)}
className="w-full px-4 py-2 flex items-center gap-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left"
>
{showToolCalls ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
<Wrench className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Exploration ({toolCalls.length} tool call{toolCalls.length !== 1 ? 's' : ''})
</span>
</button>
{showToolCalls && (
<div className="p-3 space-y-2 bg-muted/10 max-h-[300px] overflow-y-auto">
{toolCalls.map((tc, idx) => (
<div key={idx} className="text-xs font-mono">
<div className="text-cyan-400 mb-1">Tool: {tc.tool}</div>
<pre className="bg-muted/50 rounded p-2 overflow-x-auto text-foreground-secondary whitespace-pre-wrap">
{tc.input}
</pre>
</div>
))}
</div>
)}
</div>
)}
{/* Plan/Specification Content - Main focus */}
{planMarkdown ? (
<div className="min-h-[200px]">
<Markdown>{planMarkdown}</Markdown>
</div>
) : toolCalls.length > 0 ? (
<div className="text-muted-foreground text-center py-8 border border-dashed border-border rounded-lg">
<p className="text-sm">No specification content found.</p>
<p className="text-xs mt-1">The plan appears to only contain exploration tool calls.</p>
</div>
) : null}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
@@ -9,6 +9,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
@@ -18,8 +19,9 @@ import {
SelectValue,
} from '@/components/ui/select';
import { getHttpApiClient } from '@/lib/http-api-client';
import { getErrorMessage } from '@/lib/utils';
import { toast } from 'sonner';
import { Upload, RefreshCw, AlertTriangle, Sparkles } from 'lucide-react';
import { Upload, RefreshCw, AlertTriangle, Sparkles, Plus, Link } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { WorktreeInfo } from '../worktree-panel/types';
@@ -49,18 +51,76 @@ export function PushToRemoteDialog({
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Add remote form state
const [showAddRemoteForm, setShowAddRemoteForm] = useState(false);
const [newRemoteName, setNewRemoteName] = useState('origin');
const [newRemoteUrl, setNewRemoteUrl] = useState('');
const [isAddingRemote, setIsAddingRemote] = useState(false);
const [addRemoteError, setAddRemoteError] = useState<string | null>(null);
/**
* Transforms API remote data to RemoteInfo format
*/
const transformRemoteData = useCallback(
(remotes: Array<{ name: string; url: string }>): RemoteInfo[] => {
return remotes.map((r) => ({
name: r.name,
url: r.url,
}));
},
[]
);
/**
* Updates remotes state and hides add form if remotes exist
*/
const updateRemotesState = useCallback((remoteInfos: RemoteInfo[]) => {
setRemotes(remoteInfos);
if (remoteInfos.length > 0) {
setShowAddRemoteForm(false);
}
}, []);
const fetchRemotes = useCallback(async () => {
if (!worktree) return;
setIsLoading(true);
setError(null);
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) {
const remoteInfos = transformRemoteData(result.result.remotes);
updateRemotesState(remoteInfos);
} else {
setError(result.error || 'Failed to fetch remotes');
}
} catch (err) {
logger.error('Failed to fetch remotes:', err);
setError(getErrorMessage(err));
} finally {
setIsLoading(false);
}
}, [worktree, transformRemoteData, updateRemotesState]);
// Fetch remotes when dialog opens
useEffect(() => {
if (open && worktree) {
fetchRemotes();
}
}, [open, worktree]);
}, [open, worktree, fetchRemotes]);
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setSelectedRemote('');
setError(null);
setShowAddRemoteForm(false);
setNewRemoteName('origin');
setNewRemoteUrl('');
setAddRemoteError(null);
}
}, [open]);
@@ -73,36 +133,12 @@ export function PushToRemoteDialog({
}
}, [remotes, selectedRemote]);
const fetchRemotes = async () => {
if (!worktree) return;
setIsLoading(true);
setError(null);
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) {
// Extract just the remote info (name and URL), not the branches
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
name: r.name,
url: r.url,
}));
setRemotes(remoteInfos);
if (remoteInfos.length === 0) {
setError('No remotes found in this repository. Please add a remote first.');
}
} else {
setError(result.error || 'Failed to fetch remotes');
}
} catch (err) {
logger.error('Failed to fetch remotes:', err);
setError('Failed to fetch remotes');
} finally {
setIsLoading(false);
// Show add remote form when no remotes (but not when there's an error)
useEffect(() => {
if (!isLoading && remotes.length === 0 && !error) {
setShowAddRemoteForm(true);
}
};
}, [isLoading, remotes.length, error]);
const handleRefresh = async () => {
if (!worktree) return;
@@ -115,47 +151,270 @@ export function PushToRemoteDialog({
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) {
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
name: r.name,
url: r.url,
}));
setRemotes(remoteInfos);
const remoteInfos = transformRemoteData(result.result.remotes);
updateRemotesState(remoteInfos);
toast.success('Remotes refreshed');
} else {
toast.error(result.error || 'Failed to refresh remotes');
}
} catch (err) {
logger.error('Failed to refresh remotes:', err);
toast.error('Failed to refresh remotes');
toast.error(getErrorMessage(err));
} finally {
setIsRefreshing(false);
}
};
const handleAddRemote = async () => {
if (!worktree || !newRemoteName.trim() || !newRemoteUrl.trim()) return;
setIsAddingRemote(true);
setAddRemoteError(null);
try {
const api = getHttpApiClient();
const result = await api.worktree.addRemote(
worktree.path,
newRemoteName.trim(),
newRemoteUrl.trim()
);
if (result.success && result.result) {
toast.success(result.result.message);
// Add the new remote to the list and select it
const newRemote: RemoteInfo = {
name: result.result.remoteName,
url: result.result.remoteUrl,
};
setRemotes((prev) => [...prev, newRemote]);
setSelectedRemote(newRemote.name);
setShowAddRemoteForm(false);
setNewRemoteName('origin');
setNewRemoteUrl('');
} else {
setAddRemoteError(result.error || 'Failed to add remote');
}
} catch (err) {
logger.error('Failed to add remote:', err);
setAddRemoteError(getErrorMessage(err));
} finally {
setIsAddingRemote(false);
}
};
const handleConfirm = () => {
if (!worktree || !selectedRemote) return;
onConfirm(worktree, selectedRemote);
onOpenChange(false);
};
const renderAddRemoteForm = () => (
<div className="grid gap-4 py-4">
<div className="flex items-center gap-2 text-muted-foreground mb-2">
<Link className="w-4 h-4" />
<span className="text-sm">
{remotes.length === 0
? 'No remotes found. Add a remote to push your branch.'
: 'Add a new remote'}
</span>
</div>
<div className="grid gap-2">
<Label htmlFor="remote-name">Remote Name</Label>
<Input
id="remote-name"
placeholder="origin"
value={newRemoteName}
onChange={(e) => {
setNewRemoteName(e.target.value);
setAddRemoteError(null);
}}
disabled={isAddingRemote}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="remote-url">Remote URL</Label>
<Input
id="remote-url"
placeholder="https://github.com/user/repo.git"
value={newRemoteUrl}
onChange={(e) => {
setNewRemoteUrl(e.target.value);
setAddRemoteError(null);
}}
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
newRemoteName.trim() &&
newRemoteUrl.trim() &&
!isAddingRemote
) {
handleAddRemote();
}
}}
disabled={isAddingRemote}
/>
<p className="text-xs text-muted-foreground">
Supports HTTPS, SSH (git@github.com:user/repo.git), or git:// URLs
</p>
</div>
{addRemoteError && (
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm">{addRemoteError}</span>
</div>
)}
</div>
);
const renderRemoteSelector = () => (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="remote-select">Select Remote</Label>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setShowAddRemoteForm(true)}
className="h-6 px-2 text-xs"
>
<Plus className="w-3 h-3 mr-1" />
Add
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
className="h-6 px-2 text-xs"
>
{isRefreshing ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div>
</div>
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
<SelectTrigger id="remote-select">
<SelectValue placeholder="Select a remote" />
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
<div className="flex flex-col items-start">
<span className="font-medium">{remote.name}</span>
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedRemote && (
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
This will create a new remote branch{' '}
<span className="font-mono text-foreground">
{selectedRemote}/{worktree?.branch}
</span>{' '}
and set up tracking.
</p>
</div>
)}
</div>
);
const renderFooter = () => {
if (showAddRemoteForm) {
return (
<DialogFooter>
{remotes.length > 0 && (
<Button
variant="outline"
onClick={() => setShowAddRemoteForm(false)}
disabled={isAddingRemote}
>
Back
</Button>
)}
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isAddingRemote}>
Cancel
</Button>
<Button
onClick={handleAddRemote}
disabled={!newRemoteName.trim() || !newRemoteUrl.trim() || isAddingRemote}
>
{isAddingRemote ? (
<>
<Spinner size="sm" className="mr-2" />
Adding...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Add Remote
</>
)}
</Button>
</DialogFooter>
);
}
return (
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!selectedRemote || isLoading}>
<Upload className="w-4 h-4 mr-2" />
Push to {selectedRemote || 'Remote'}
</Button>
</DialogFooter>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="w-5 h-5 text-primary" />
Push New Branch to Remote
<span className="inline-flex items-center gap-1 text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded-full ml-2">
<Sparkles className="w-3 h-3" />
new
</span>
{showAddRemoteForm ? (
<>
<Plus className="w-5 h-5 text-primary" />
Add Remote
</>
) : (
<>
<Upload className="w-5 h-5 text-primary" />
Push New Branch to Remote
<span className="inline-flex items-center gap-1 text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded-full ml-2">
<Sparkles className="w-3 h-3" />
new
</span>
</>
)}
</DialogTitle>
<DialogDescription>
Push{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>{' '}
to a remote repository for the first time.
{showAddRemoteForm ? (
<>Add a remote repository to push your changes to.</>
) : (
<>
Push{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>{' '}
to a remote repository for the first time.
</>
)}
</DialogDescription>
</DialogHeader>
@@ -163,7 +422,7 @@ export function PushToRemoteDialog({
<div className="flex items-center justify-center py-8">
<Spinner size="lg" />
</div>
) : error ? (
) : error && !showAddRemoteForm ? (
<div className="flex flex-col items-center gap-4 py-6">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="w-5 h-5" />
@@ -174,68 +433,13 @@ export function PushToRemoteDialog({
Retry
</Button>
</div>
) : showAddRemoteForm ? (
renderAddRemoteForm()
) : (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="remote-select">Select Remote</Label>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
className="h-6 px-2 text-xs"
>
{isRefreshing ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div>
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
<SelectTrigger id="remote-select">
<SelectValue placeholder="Select a remote" />
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
<div className="flex flex-col items-start">
<span className="font-medium">{remote.name}</span>
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedRemote && (
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
This will create a new remote branch{' '}
<span className="font-mono text-foreground">
{selectedRemote}/{worktree?.branch}
</span>{' '}
and set up tracking.
</p>
</div>
)}
</div>
renderRemoteSelector()
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!selectedRemote || isLoading}>
<Upload className="w-4 h-4 mr-2" />
Push to {selectedRemote || 'Remote'}
</Button>
</DialogFooter>
{renderFooter()}
</DialogContent>
</Dialog>
);

View File

@@ -123,9 +123,34 @@ export function useBoardActions({
}) => {
const workMode = featureData.workMode || 'current';
// For auto worktree mode, we need a title for the branch name.
// If no title provided, generate one from the description first.
let titleForBranch = featureData.title;
let titleWasGenerated = false;
if (workMode === 'auto' && !featureData.title.trim() && featureData.description.trim()) {
// Generate title first so we can use it for the branch name
const api = getElectronAPI();
if (api?.features?.generateTitle) {
try {
const result = await api.features.generateTitle(featureData.description);
if (result.success && result.title) {
titleForBranch = result.title;
titleWasGenerated = true;
}
} catch (error) {
logger.error('Error generating title for branch name:', error);
}
}
// If title generation failed, fall back to first part of description
if (!titleForBranch.trim()) {
titleForBranch = featureData.description.substring(0, 60);
}
}
// Determine final branch name based on work mode:
// - 'current': Use current worktree's branch (or undefined if on main)
// - 'auto': Auto-generate branch name based on current branch
// - 'auto': Auto-generate branch name based on feature title
// - 'custom': Use the provided branch name
let finalBranchName: string | undefined;
@@ -134,13 +159,16 @@ export function useBoardActions({
// This ensures features created on a non-main worktree are associated with that worktree
finalBranchName = currentWorktreeBranch || undefined;
} else if (workMode === 'auto') {
// Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
const baseBranch =
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
const timestamp = Date.now();
// Auto-generate a branch name based on feature title and timestamp
// Create a slug from the title: lowercase, replace non-alphanumeric with hyphens
const titleSlug =
titleForBranch
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric sequences with hyphens
.substring(0, 50) // Limit length first
.replace(/^-|-$/g, '') || 'untitled'; // Then remove leading/trailing hyphens, with fallback
const randomSuffix = Math.random().toString(36).substring(2, 6);
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
finalBranchName = `feature/${titleSlug}-${randomSuffix}`;
} else {
// Custom mode - use provided branch name
finalBranchName = featureData.branchName || undefined;
@@ -183,12 +211,13 @@ export function useBoardActions({
}
}
// Check if we need to generate a title
const needsTitleGeneration = !featureData.title.trim() && featureData.description.trim();
// Check if we need to generate a title (only if we didn't already generate it for the branch name)
const needsTitleGeneration =
!titleWasGenerated && !featureData.title.trim() && featureData.description.trim();
const newFeatureData = {
...featureData,
title: featureData.title,
title: titleWasGenerated ? titleForBranch : featureData.title,
titleGenerating: needsTitleGeneration,
status: 'backlog' as const,
branchName: finalBranchName,
@@ -255,7 +284,6 @@ export function useBoardActions({
projectPath,
onWorktreeCreated,
onWorktreeAutoSelect,
getPrimaryWorktreeBranch,
features,
currentWorktreeBranch,
]
@@ -287,6 +315,31 @@ export function useBoardActions({
) => {
const workMode = updates.workMode || 'current';
// For auto worktree mode, we need a title for the branch name.
// If no title provided, generate one from the description first.
let titleForBranch = updates.title;
let titleWasGenerated = false;
if (workMode === 'auto' && !updates.title.trim() && updates.description.trim()) {
// Generate title first so we can use it for the branch name
const api = getElectronAPI();
if (api?.features?.generateTitle) {
try {
const result = await api.features.generateTitle(updates.description);
if (result.success && result.title) {
titleForBranch = result.title;
titleWasGenerated = true;
}
} catch (error) {
logger.error('Error generating title for branch name:', error);
}
}
// If title generation failed, fall back to first part of description
if (!titleForBranch.trim()) {
titleForBranch = updates.description.substring(0, 60);
}
}
// Determine final branch name based on work mode
let finalBranchName: string | undefined;
@@ -295,13 +348,21 @@ export function useBoardActions({
// This ensures features updated on a non-main worktree are associated with that worktree
finalBranchName = currentWorktreeBranch || undefined;
} else if (workMode === 'auto') {
// Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
const baseBranch =
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 6);
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
// Preserve existing branch name if one exists (avoid orphaning worktrees on edit)
if (updates.branchName?.trim()) {
finalBranchName = updates.branchName;
} else {
// Auto-generate a branch name based on feature title
// Create a slug from the title: lowercase, replace non-alphanumeric with hyphens
const titleSlug =
titleForBranch
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric sequences with hyphens
.substring(0, 50) // Limit length first
.replace(/^-|-$/g, '') || 'untitled'; // Then remove leading/trailing hyphens, with fallback
const randomSuffix = Math.random().toString(36).substring(2, 6);
finalBranchName = `feature/${titleSlug}-${randomSuffix}`;
}
} else {
finalBranchName = updates.branchName || undefined;
}
@@ -343,7 +404,7 @@ export function useBoardActions({
const finalUpdates = {
...restUpdates,
title: updates.title,
title: titleWasGenerated ? titleForBranch : updates.title,
branchName: finalBranchName,
};
@@ -406,7 +467,6 @@ export function useBoardActions({
setEditingFeature,
currentProject,
onWorktreeCreated,
getPrimaryWorktreeBranch,
features,
currentWorktreeBranch,
]
@@ -553,6 +613,11 @@ export function useBoardActions({
};
updateFeature(feature.id, rollbackUpdates);
// Also persist the rollback so it survives page refresh
persistFeatureUpdate(feature.id, rollbackUpdates).catch((persistError) => {
logger.error('Failed to persist rollback:', persistError);
});
// If server is offline (connection refused), redirect to login page
if (isConnectionError(error)) {
handleServerOffline();

View File

@@ -88,10 +88,10 @@ export function useBoardDragDrop({
const targetFeature = features.find((f) => f.id === targetFeatureId);
if (!targetFeature) return;
// Only allow linking backlog features (both must be in backlog)
if (draggedFeature.status !== 'backlog' || targetFeature.status !== 'backlog') {
// Don't allow linking completed features (they're already done)
if (draggedFeature.status === 'completed' || targetFeature.status === 'completed') {
toast.error('Cannot link features', {
description: 'Both features must be in the backlog to create a dependency link.',
description: 'Completed features cannot be linked.',
});
return;
}

View File

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

View File

@@ -4,9 +4,16 @@ import {
CURSOR_MODEL_MAP,
CODEX_MODEL_MAP,
OPENCODE_MODELS as OPENCODE_MODEL_CONFIGS,
GEMINI_MODEL_MAP,
} from '@automaker/types';
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import {
AnthropicIcon,
CursorIcon,
OpenAIIcon,
OpenCodeIcon,
GeminiIcon,
} from '@/components/ui/provider-icon';
export type ModelOption = {
id: string; // All model IDs use canonical prefixed format (e.g., "claude-sonnet", "cursor-auto")
@@ -118,13 +125,29 @@ export const OPENCODE_MODELS: ModelOption[] = OPENCODE_MODEL_CONFIGS.map((config
}));
/**
* All available models (Claude + Cursor + Codex + OpenCode)
* Gemini models derived from GEMINI_MODEL_MAP
* Model IDs already have 'gemini-' prefix (like Cursor models)
*/
export const GEMINI_MODELS: ModelOption[] = Object.entries(GEMINI_MODEL_MAP).map(
([id, config]) => ({
id, // IDs already have gemini- prefix (e.g., 'gemini-2.5-flash')
label: config.label,
description: config.description,
badge: config.supportsThinking ? 'Thinking' : 'Speed',
provider: 'gemini' as ModelProvider,
hasThinking: config.supportsThinking,
})
);
/**
* All available models (Claude + Cursor + Codex + OpenCode + Gemini)
*/
export const ALL_MODELS: ModelOption[] = [
...CLAUDE_MODELS,
...CURSOR_MODELS,
...CODEX_MODELS,
...OPENCODE_MODELS,
...GEMINI_MODELS,
];
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
@@ -171,4 +194,5 @@ export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: str
Cursor: CursorIcon,
Codex: OpenAIIcon,
OpenCode: OpenCodeIcon,
Gemini: GeminiIcon,
};

View File

@@ -0,0 +1,113 @@
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { GitBranch, Workflow } from 'lucide-react';
import { usePipelineConfig } from '@/hooks/queries/use-pipeline';
import { cn } from '@/lib/utils';
interface PipelineExclusionControlsProps {
projectPath: string | undefined;
excludedPipelineSteps: string[];
onExcludedStepsChange: (excludedSteps: string[]) => void;
testIdPrefix?: string;
disabled?: boolean;
}
/**
* Component for selecting which custom pipeline steps should be excluded for a feature.
* Each pipeline step is shown as a toggleable switch, defaulting to enabled (included).
* Disabling a step adds it to the exclusion list.
*/
export function PipelineExclusionControls({
projectPath,
excludedPipelineSteps,
onExcludedStepsChange,
testIdPrefix = 'pipeline-exclusion',
disabled = false,
}: PipelineExclusionControlsProps) {
const { data: pipelineConfig, isLoading } = usePipelineConfig(projectPath);
// Sort steps by order
const sortedSteps = [...(pipelineConfig?.steps || [])].sort((a, b) => a.order - b.order);
// If no pipeline steps exist or loading, don't render anything
if (isLoading || sortedSteps.length === 0) {
return null;
}
const toggleStep = (stepId: string) => {
const isCurrentlyExcluded = excludedPipelineSteps.includes(stepId);
if (isCurrentlyExcluded) {
// Remove from exclusions (enable the step)
onExcludedStepsChange(excludedPipelineSteps.filter((id) => id !== stepId));
} else {
// Add to exclusions (disable the step)
onExcludedStepsChange([...excludedPipelineSteps, stepId]);
}
};
const allExcluded = sortedSteps.every((step) => excludedPipelineSteps.includes(step.id));
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Workflow className="w-4 h-4 text-muted-foreground" />
<Label className="text-sm font-medium">Custom Pipeline Steps</Label>
</div>
<div className="space-y-2">
{sortedSteps.map((step) => {
const isIncluded = !excludedPipelineSteps.includes(step.id);
return (
<div
key={step.id}
className={cn(
'flex items-center justify-between gap-3 px-3 py-2 rounded-md border',
isIncluded
? 'border-border/50 bg-muted/30'
: 'border-border/30 bg-muted/10 opacity-60'
)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<div
className={cn(
'w-2 h-2 rounded-full flex-shrink-0',
step.colorClass || 'bg-gray-400'
)}
style={{
backgroundColor: step.colorClass?.startsWith('#') ? step.colorClass : undefined,
}}
/>
<span
className={cn(
'text-sm truncate',
isIncluded ? 'text-foreground' : 'text-muted-foreground'
)}
>
{step.name}
</span>
</div>
<Switch
checked={isIncluded}
onCheckedChange={() => toggleStep(step.id)}
disabled={disabled}
data-testid={`${testIdPrefix}-step-${step.id}`}
aria-label={`${isIncluded ? 'Disable' : 'Enable'} ${step.name} pipeline step`}
/>
</div>
);
})}
</div>
{allExcluded && (
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<GitBranch className="w-3.5 h-3.5" />
All pipeline steps disabled. Feature will skip directly to verification.
</p>
)}
<p className="text-xs text-muted-foreground">
Enabled steps will run after implementation. Disable steps to skip them for this feature.
</p>
</div>
);
}

View File

@@ -27,16 +27,17 @@ import {
Copy,
Eye,
ScrollText,
Sparkles,
CloudOff,
Terminal,
SquarePlus,
SplitSquareHorizontal,
Undo2,
Zap,
FlaskConical,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus, TestSessionInfo } from '../types';
import { TooltipWrapper } from './tooltip-wrapper';
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
import {
@@ -63,6 +64,14 @@ interface WorktreeActionsDropdownProps {
standalone?: boolean;
/** Whether auto mode is running for this worktree */
isAutoModeRunning?: boolean;
/** Whether a test command is configured in project settings */
hasTestCommand?: boolean;
/** Whether tests are being started for this worktree */
isStartingTests?: boolean;
/** Whether tests are currently running for this worktree */
isTestRunning?: boolean;
/** Active test session info for this worktree */
testSessionInfo?: TestSessionInfo;
onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
@@ -84,6 +93,12 @@ interface WorktreeActionsDropdownProps {
onRunInitScript: (worktree: WorktreeInfo) => void;
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void;
/** Start running tests for this worktree */
onStartTests?: (worktree: WorktreeInfo) => void;
/** Stop running tests for this worktree */
onStopTests?: (worktree: WorktreeInfo) => void;
/** View test logs for this worktree */
onViewTestLogs?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
@@ -101,6 +116,10 @@ export function WorktreeActionsDropdown({
gitRepoStatus,
standalone = false,
isAutoModeRunning = false,
hasTestCommand = false,
isStartingTests = false,
isTestRunning = false,
testSessionInfo,
onOpenChange,
onPull,
onPush,
@@ -122,6 +141,9 @@ export function WorktreeActionsDropdown({
onRunInitScript,
onToggleAutoMode,
onMerge,
onStartTests,
onStopTests,
onViewTestLogs,
hasInitScript,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
@@ -231,6 +253,65 @@ export function WorktreeActionsDropdown({
<DropdownMenuSeparator />
</>
)}
{/* Test Runner section - only show when test command is configured */}
{hasTestCommand && onStartTests && (
<>
{isTestRunning ? (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
Tests Running
</DropdownMenuLabel>
{onViewTestLogs && (
<DropdownMenuItem onClick={() => onViewTestLogs(worktree)} className="text-xs">
<ScrollText className="w-3.5 h-3.5 mr-2" />
View Test Logs
</DropdownMenuItem>
)}
{onStopTests && (
<DropdownMenuItem
onClick={() => onStopTests(worktree)}
className="text-xs text-destructive focus:text-destructive"
>
<Square className="w-3.5 h-3.5 mr-2" />
Stop Tests
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
) : (
<>
<DropdownMenuItem
onClick={() => onStartTests(worktree)}
disabled={isStartingTests}
className="text-xs"
>
<FlaskConical
className={cn('w-3.5 h-3.5 mr-2', isStartingTests && 'animate-pulse')}
/>
{isStartingTests ? 'Starting Tests...' : 'Run Tests'}
</DropdownMenuItem>
{onViewTestLogs && testSessionInfo && (
<DropdownMenuItem onClick={() => onViewTestLogs(worktree)} className="text-xs">
<ScrollText className="w-3.5 h-3.5 mr-2" />
View Last Test Results
{testSessionInfo.status === 'passed' && (
<span className="ml-auto text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded">
passed
</span>
)}
{testSessionInfo.status === 'failed' && (
<span className="ml-auto text-[10px] bg-red-500/20 text-red-600 px-1.5 py-0.5 rounded">
failed
</span>
)}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
</>
)}
{/* Auto Mode toggle */}
{onToggleAutoMode && (
<>
@@ -284,9 +365,9 @@ export function WorktreeActionsDropdown({
{isPushing ? 'Pushing...' : 'Push'}
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
{canPerformGitOps && !hasRemoteBranch && (
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
<Sparkles className="w-2.5 h-2.5" />
new
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-1.5 py-0.5 rounded">
<CloudOff className="w-2.5 h-2.5" />
local only
</span>
)}
{canPerformGitOps && hasRemoteBranch && aheadCount > 0 && (

View File

@@ -5,7 +5,14 @@ import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useDroppable } from '@dnd-kit/core';
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
import type {
WorktreeInfo,
BranchInfo,
DevServerInfo,
PRInfo,
GitRepoStatus,
TestSessionInfo,
} from '../types';
import { BranchSwitchDropdown } from './branch-switch-dropdown';
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
@@ -33,6 +40,12 @@ interface WorktreeTabProps {
gitRepoStatus: GitRepoStatus;
/** Whether auto mode is running for this worktree */
isAutoModeRunning?: boolean;
/** Whether tests are being started for this worktree */
isStartingTests?: boolean;
/** Whether tests are currently running for this worktree */
isTestRunning?: boolean;
/** Active test session info for this worktree */
testSessionInfo?: TestSessionInfo;
onSelectWorktree: (worktree: WorktreeInfo) => void;
onBranchDropdownOpenChange: (open: boolean) => void;
onActionsDropdownOpenChange: (open: boolean) => void;
@@ -59,7 +72,15 @@ interface WorktreeTabProps {
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
/** Start running tests for this worktree */
onStartTests?: (worktree: WorktreeInfo) => void;
/** Stop running tests for this worktree */
onStopTests?: (worktree: WorktreeInfo) => void;
/** View test logs for this worktree */
onViewTestLogs?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
/** Whether a test command is configured in project settings */
hasTestCommand?: boolean;
}
export function WorktreeTab({
@@ -85,6 +106,9 @@ export function WorktreeTab({
hasRemoteBranch,
gitRepoStatus,
isAutoModeRunning = false,
isStartingTests = false,
isTestRunning = false,
testSessionInfo,
onSelectWorktree,
onBranchDropdownOpenChange,
onActionsDropdownOpenChange,
@@ -111,7 +135,11 @@ export function WorktreeTab({
onViewDevServerLogs,
onRunInitScript,
onToggleAutoMode,
onStartTests,
onStopTests,
onViewTestLogs,
hasInitScript,
hasTestCommand = false,
}: WorktreeTabProps) {
// Make the worktree tab a drop target for feature cards
const { setNodeRef, isOver } = useDroppable({
@@ -395,6 +423,10 @@ export function WorktreeTab({
devServerInfo={devServerInfo}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunning}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
isTestRunning={isTestRunning}
testSessionInfo={testSessionInfo}
onOpenChange={onActionsDropdownOpenChange}
onPull={onPull}
onPush={onPush}
@@ -416,6 +448,9 @@ export function WorktreeTab({
onViewDevServerLogs={onViewDevServerLogs}
onRunInitScript={onRunInitScript}
onToggleAutoMode={onToggleAutoMode}
onStartTests={onStartTests}
onStopTests={onStopTests}
onViewTestLogs={onViewTestLogs}
hasInitScript={hasInitScript}
/>
</div>

View File

@@ -30,6 +30,19 @@ export interface DevServerInfo {
url: string;
}
export interface TestSessionInfo {
sessionId: string;
worktreePath: string;
/** The test command being run (from project settings) */
command: string;
status: 'pending' | 'running' | 'passed' | 'failed' | 'cancelled';
testFile?: string;
startedAt: string;
finishedAt?: string;
exitCode?: number | null;
duration?: number;
}
export interface FeatureInfo {
id: string;
branchName?: string;

View File

@@ -6,8 +6,15 @@ import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { useIsMobile } from '@/hooks/use-media-query';
import { useWorktreeInitScript } from '@/hooks/queries';
import type { WorktreePanelProps, WorktreeInfo } from './types';
import { useWorktreeInitScript, useProjectSettings } from '@/hooks/queries';
import { useTestRunnerEvents } from '@/hooks/use-test-runners';
import { useTestRunnersStore } from '@/store/test-runners-store';
import type {
TestRunnerStartedEvent,
TestRunnerOutputEvent,
TestRunnerCompletedEvent,
} from '@/types/electron';
import type { WorktreePanelProps, WorktreeInfo, TestSessionInfo } from './types';
import {
useWorktrees,
useDevServers,
@@ -25,6 +32,7 @@ import {
import { useAppStore } from '@/store/app-store';
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
import { Undo2 } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
@@ -161,6 +169,194 @@ export function WorktreePanel({
const { data: initScriptData } = useWorktreeInitScript(projectPath);
const hasInitScript = initScriptData?.exists ?? false;
// Check if test command is configured in project settings
const { data: projectSettings } = useProjectSettings(projectPath);
const hasTestCommand = !!projectSettings?.testCommand;
// Test runner state management
// Use the test runners store to get global state for all worktrees
const testRunnersStore = useTestRunnersStore();
const [isStartingTests, setIsStartingTests] = useState(false);
// Subscribe to test runner events to update store state in real-time
// This ensures the UI updates when tests start, output is received, or tests complete
useTestRunnerEvents(
// onStarted - a new test run has begun
useCallback(
(event: TestRunnerStartedEvent) => {
testRunnersStore.startSession({
sessionId: event.sessionId,
worktreePath: event.worktreePath,
command: event.command,
status: 'running',
testFile: event.testFile,
startedAt: event.timestamp,
});
},
[testRunnersStore]
),
// onOutput - test output received
useCallback(
(event: TestRunnerOutputEvent) => {
testRunnersStore.appendOutput(event.sessionId, event.content);
},
[testRunnersStore]
),
// onCompleted - test run finished
useCallback(
(event: TestRunnerCompletedEvent) => {
testRunnersStore.completeSession(
event.sessionId,
event.status,
event.exitCode,
event.duration
);
// Show toast notification for test completion
const statusEmoji =
event.status === 'passed' ? '✅' : event.status === 'failed' ? '❌' : '⏹️';
const statusText =
event.status === 'passed' ? 'passed' : event.status === 'failed' ? 'failed' : 'stopped';
toast(`${statusEmoji} Tests ${statusText}`, {
description: `Exit code: ${event.exitCode ?? 'N/A'}`,
duration: 4000,
});
},
[testRunnersStore]
)
);
// Test logs panel state
const [testLogsPanelOpen, setTestLogsPanelOpen] = useState(false);
const [testLogsPanelWorktree, setTestLogsPanelWorktree] = useState<WorktreeInfo | null>(null);
// Helper to check if tests are running for a specific worktree
const isTestRunningForWorktree = useCallback(
(worktree: WorktreeInfo): boolean => {
return testRunnersStore.isWorktreeRunning(worktree.path);
},
[testRunnersStore]
);
// Helper to get test session info for a specific worktree
const getTestSessionInfo = useCallback(
(worktree: WorktreeInfo): TestSessionInfo | undefined => {
const session = testRunnersStore.getActiveSession(worktree.path);
if (!session) {
// Check for completed sessions to show last result
const allSessions = Object.values(testRunnersStore.sessions).filter(
(s) => s.worktreePath === worktree.path
);
const lastSession = allSessions.sort(
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
)[0];
if (lastSession) {
return {
sessionId: lastSession.sessionId,
worktreePath: lastSession.worktreePath,
command: lastSession.command,
status: lastSession.status as TestSessionInfo['status'],
testFile: lastSession.testFile,
startedAt: lastSession.startedAt,
finishedAt: lastSession.finishedAt,
exitCode: lastSession.exitCode,
duration: lastSession.duration,
};
}
return undefined;
}
return {
sessionId: session.sessionId,
worktreePath: session.worktreePath,
command: session.command,
status: session.status as TestSessionInfo['status'],
testFile: session.testFile,
startedAt: session.startedAt,
finishedAt: session.finishedAt,
exitCode: session.exitCode,
duration: session.duration,
};
},
[testRunnersStore]
);
// Handler to start tests for a worktree
const handleStartTests = useCallback(
async (worktree: WorktreeInfo) => {
setIsStartingTests(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.startTests) {
toast.error('Test runner API not available');
return;
}
const result = await api.worktree.startTests(worktree.path, { projectPath });
if (result.success) {
toast.success('Tests started', {
description: `Running tests in ${worktree.branch}`,
});
} else {
toast.error('Failed to start tests', {
description: result.error || 'Unknown error',
});
}
} catch (error) {
toast.error('Failed to start tests', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsStartingTests(false);
}
},
[projectPath]
);
// Handler to stop tests for a worktree
const handleStopTests = useCallback(
async (worktree: WorktreeInfo) => {
try {
const session = testRunnersStore.getActiveSession(worktree.path);
if (!session) {
toast.error('No active test session to stop');
return;
}
const api = getElectronAPI();
if (!api?.worktree?.stopTests) {
toast.error('Test runner API not available');
return;
}
const result = await api.worktree.stopTests(session.sessionId);
if (result.success) {
toast.success('Tests stopped', {
description: `Stopped tests in ${worktree.branch}`,
});
} else {
toast.error('Failed to stop tests', {
description: result.error || 'Unknown error',
});
}
} catch (error) {
toast.error('Failed to stop tests', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
},
[testRunnersStore]
);
// Handler to view test logs for a worktree
const handleViewTestLogs = useCallback((worktree: WorktreeInfo) => {
setTestLogsPanelWorktree(worktree);
setTestLogsPanelOpen(true);
}, []);
// Handler to close test logs panel
const handleCloseTestLogsPanel = useCallback(() => {
setTestLogsPanelOpen(false);
}, []);
// View changes dialog state
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
@@ -392,6 +588,10 @@ export function WorktreePanel({
devServerInfo={getDevServerInfo(selectedWorktree)}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
testSessionInfo={getTestSessionInfo(selectedWorktree)}
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
onPull={handlePull}
onPush={handlePush}
@@ -413,6 +613,9 @@ export function WorktreePanel({
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
hasInitScript={hasInitScript}
/>
)}
@@ -494,6 +697,17 @@ export function WorktreePanel({
onMerged={handleMerged}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
{/* Test Logs Panel */}
<TestLogsPanel
open={testLogsPanelOpen}
onClose={handleCloseTestLogsPanel}
worktreePath={testLogsPanelWorktree?.path ?? null}
branch={testLogsPanelWorktree?.branch}
onStopTests={
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
}
/>
</div>
);
}
@@ -530,6 +744,9 @@ export function WorktreePanel({
hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(mainWorktree)}
testSessionInfo={getTestSessionInfo(mainWorktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
@@ -556,7 +773,11 @@ export function WorktreePanel({
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
)}
</div>
@@ -596,6 +817,9 @@ export function WorktreePanel({
hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(worktree)}
testSessionInfo={getTestSessionInfo(worktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
@@ -622,7 +846,11 @@ export function WorktreePanel({
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
);
})}
@@ -703,6 +931,17 @@ export function WorktreePanel({
onMerged={handleMerged}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
{/* Test Logs Panel */}
<TestLogsPanel
open={testLogsPanelOpen}
onClose={handleCloseTestLogsPanel}
worktreePath={testLogsPanelWorktree?.path ?? null}
branch={testLogsPanelWorktree?.branch}
onStopTests={
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
}
/>
</div>
);
}

View File

@@ -392,6 +392,7 @@ export function GraphViewPage() {
currentBranch={currentWorktreeBranch || undefined}
isMaximized={false}
allFeatures={hookFeatures}
projectPath={currentProject?.path}
/>
{/* Add Feature Dialog (for spawning) */}
@@ -414,6 +415,7 @@ export function GraphViewPage() {
isMaximized={false}
parentFeature={spawnParentFeature}
allFeatures={hookFeatures}
projectPath={currentProject?.path}
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
selectedNonMainWorktreeBranch={
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null

View File

@@ -11,7 +11,6 @@ import { useIdeationStore } from '@/store/ideation-store';
import { useAppStore } from '@/store/app-store';
import { useGenerateIdeationSuggestions } from '@/hooks/mutations';
import { toast } from 'sonner';
import { useNavigate } from '@tanstack/react-router';
import type { IdeaCategory, IdeationPrompt } from '@automaker/types';
interface PromptListProps {
@@ -24,10 +23,8 @@ export function PromptList({ category, onBack }: PromptListProps) {
const generationJobs = useIdeationStore((s) => s.generationJobs);
const setMode = useIdeationStore((s) => s.setMode);
const addGenerationJob = useIdeationStore((s) => s.addGenerationJob);
const updateJobStatus = useIdeationStore((s) => s.updateJobStatus);
const [loadingPromptId, setLoadingPromptId] = useState<string | null>(null);
const [startedPrompts, setStartedPrompts] = useState<Set<string>>(new Set());
const navigate = useNavigate();
// React Query mutation
const generateMutation = useGenerateIdeationSuggestions(currentProject?.path ?? '');
@@ -72,27 +69,13 @@ export function PromptList({ category, onBack }: PromptListProps) {
toast.info(`Generating ideas for "${prompt.title}"...`);
setMode('dashboard');
// Start mutation - onSuccess/onError are handled at the hook level to ensure
// they fire even after this component unmounts (which happens due to setMode above)
generateMutation.mutate(
{ promptId: prompt.id, category },
{ promptId: prompt.id, category, jobId, promptTitle: prompt.title },
{
onSuccess: (data) => {
updateJobStatus(jobId, 'ready', data.suggestions);
toast.success(`Generated ${data.suggestions.length} ideas for "${prompt.title}"`, {
duration: 10000,
action: {
label: 'View Ideas',
onClick: () => {
setMode('dashboard');
navigate({ to: '/ideation' });
},
},
});
setLoadingPromptId(null);
},
onError: (error) => {
console.error('Failed to generate suggestions:', error);
updateJobStatus(jobId, 'error', undefined, error.message);
toast.error(error.message);
// Optional: reset local loading state if component is still mounted
onSettled: () => {
setLoadingPromptId(null);
},
}

View File

@@ -0,0 +1,316 @@
import { useState, useEffect, useCallback, type KeyboardEvent } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Terminal, Save, RotateCcw, Info, X, Play, FlaskConical } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { useProjectSettings } from '@/hooks/queries';
import { useUpdateProjectSettings } from '@/hooks/mutations';
import type { Project } from '@/lib/electron';
/** Preset dev server commands for quick selection */
const DEV_SERVER_PRESETS = [
{ label: 'npm run dev', command: 'npm run dev' },
{ label: 'yarn dev', command: 'yarn dev' },
{ label: 'pnpm dev', command: 'pnpm dev' },
{ label: 'bun dev', command: 'bun dev' },
{ label: 'npm start', command: 'npm start' },
{ label: 'cargo watch', command: 'cargo watch -x run' },
{ label: 'go run', command: 'go run .' },
] as const;
/** Preset test commands for quick selection */
const TEST_PRESETS = [
{ label: 'npm test', command: 'npm test' },
{ label: 'yarn test', command: 'yarn test' },
{ label: 'pnpm test', command: 'pnpm test' },
{ label: 'bun test', command: 'bun test' },
{ label: 'pytest', command: 'pytest' },
{ label: 'cargo test', command: 'cargo test' },
{ label: 'go test', command: 'go test ./...' },
] as const;
interface CommandsSectionProps {
project: Project;
}
export function CommandsSection({ project }: CommandsSectionProps) {
// Fetch project settings using TanStack Query
const { data: projectSettings, isLoading, isError } = useProjectSettings(project.path);
// Mutation hook for updating project settings
const updateSettingsMutation = useUpdateProjectSettings(project.path);
// Local state for the input fields
const [devCommand, setDevCommand] = useState('');
const [originalDevCommand, setOriginalDevCommand] = useState('');
const [testCommand, setTestCommand] = useState('');
const [originalTestCommand, setOriginalTestCommand] = useState('');
// Sync local state when project settings load or project changes
useEffect(() => {
// Reset local state when project changes to avoid showing stale values
setDevCommand('');
setOriginalDevCommand('');
setTestCommand('');
setOriginalTestCommand('');
}, [project.path]);
useEffect(() => {
if (projectSettings) {
const dev = projectSettings.devCommand || '';
const test = projectSettings.testCommand || '';
setDevCommand(dev);
setOriginalDevCommand(dev);
setTestCommand(test);
setOriginalTestCommand(test);
}
}, [projectSettings]);
// Check if there are unsaved changes
const hasDevChanges = devCommand !== originalDevCommand;
const hasTestChanges = testCommand !== originalTestCommand;
const hasChanges = hasDevChanges || hasTestChanges;
const isSaving = updateSettingsMutation.isPending;
// Save all commands
const handleSave = useCallback(() => {
const normalizedDevCommand = devCommand.trim();
const normalizedTestCommand = testCommand.trim();
updateSettingsMutation.mutate(
{
devCommand: normalizedDevCommand || null,
testCommand: normalizedTestCommand || null,
},
{
onSuccess: () => {
setDevCommand(normalizedDevCommand);
setOriginalDevCommand(normalizedDevCommand);
setTestCommand(normalizedTestCommand);
setOriginalTestCommand(normalizedTestCommand);
},
}
);
}, [devCommand, testCommand, updateSettingsMutation]);
// Reset to original values
const handleReset = useCallback(() => {
setDevCommand(originalDevCommand);
setTestCommand(originalTestCommand);
}, [originalDevCommand, originalTestCommand]);
// Use a preset command
const handleUseDevPreset = useCallback((command: string) => {
setDevCommand(command);
}, []);
const handleUseTestPreset = useCallback((command: string) => {
setTestCommand(command);
}, []);
// Clear commands
const handleClearDev = useCallback(() => {
setDevCommand('');
}, []);
const handleClearTest = useCallback(() => {
setTestCommand('');
}, []);
// Handle keyboard shortcuts (Enter to save)
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && hasChanges && !isSaving) {
e.preventDefault();
handleSave();
}
},
[hasChanges, isSaving, handleSave]
);
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Terminal className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Project Commands</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure custom commands for development and testing.
</p>
</div>
<div className="p-6 space-y-8">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="md" />
</div>
) : isError ? (
<div className="flex items-center justify-center py-8 text-sm text-destructive">
Failed to load project settings. Please try again.
</div>
) : (
<>
{/* Dev Server Command Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Play className="w-4 h-4 text-brand-500" />
<h3 className="text-base font-medium text-foreground">Dev Server</h3>
{hasDevChanges && (
<span className="text-xs text-amber-500 font-medium">(unsaved)</span>
)}
</div>
<div className="space-y-3 pl-6">
<div className="relative">
<Input
id="dev-command"
value={devCommand}
onChange={(e) => setDevCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="e.g., npm run dev, yarn dev, cargo watch"
className="font-mono text-sm pr-8"
data-testid="dev-command-input"
/>
{devCommand && (
<Button
variant="ghost"
size="sm"
onClick={handleClearDev}
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
aria-label="Clear dev command"
>
<X className="w-3.5 h-3.5" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground/80">
Leave empty to auto-detect based on your package manager.
</p>
{/* Dev Presets */}
<div className="flex flex-wrap gap-1.5">
{DEV_SERVER_PRESETS.map((preset) => (
<Button
key={preset.command}
variant="outline"
size="sm"
onClick={() => handleUseDevPreset(preset.command)}
className="text-xs font-mono h-7 px-2"
>
{preset.label}
</Button>
))}
</div>
</div>
</div>
{/* Divider */}
<div className="border-t border-border/30" />
{/* Test Command Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<FlaskConical className="w-4 h-4 text-brand-500" />
<h3 className="text-base font-medium text-foreground">Test Runner</h3>
{hasTestChanges && (
<span className="text-xs text-amber-500 font-medium">(unsaved)</span>
)}
</div>
<div className="space-y-3 pl-6">
<div className="relative">
<Input
id="test-command"
value={testCommand}
onChange={(e) => setTestCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="e.g., npm test, pytest, cargo test"
className="font-mono text-sm pr-8"
data-testid="test-command-input"
/>
{testCommand && (
<Button
variant="ghost"
size="sm"
onClick={handleClearTest}
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
aria-label="Clear test command"
>
<X className="w-3.5 h-3.5" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground/80">
Leave empty to auto-detect based on your project structure.
</p>
{/* Test Presets */}
<div className="flex flex-wrap gap-1.5">
{TEST_PRESETS.map((preset) => (
<Button
key={preset.command}
variant="outline"
size="sm"
onClick={() => handleUseTestPreset(preset.command)}
className="text-xs font-mono h-7 px-2"
>
{preset.label}
</Button>
))}
</div>
</div>
</div>
{/* Auto-detection Info */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
<div className="text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">Auto-detection</p>
<p>
When no custom command is set, the system automatically detects your package
manager and test framework based on project files (package.json, Cargo.toml,
go.mod, etc.).
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={!hasChanges || isSaving}
className="gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || isSaving}
className="gap-1.5"
>
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
Save
</Button>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,13 @@
import type { LucideIcon } from 'lucide-react';
import { User, GitBranch, Palette, AlertTriangle, Workflow } from 'lucide-react';
import {
User,
GitBranch,
Palette,
AlertTriangle,
Workflow,
Database,
Terminal,
} from 'lucide-react';
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
export interface ProjectNavigationItem {
@@ -11,7 +19,9 @@ export interface ProjectNavigationItem {
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
{ id: 'identity', label: 'Identity', icon: User },
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
{ id: 'commands', label: 'Commands', icon: Terminal },
{ id: 'theme', label: 'Theme', icon: Palette },
{ id: 'claude', label: 'Models', icon: Workflow },
{ id: 'data', label: 'Data', icon: Database },
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
];

View File

@@ -0,0 +1,110 @@
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Database, Download, Upload } from 'lucide-react';
import { ExportFeaturesDialog } from '../board-view/dialogs/export-features-dialog';
import { ImportFeaturesDialog } from '../board-view/dialogs/import-features-dialog';
import { useBoardFeatures } from '../board-view/hooks';
import type { Project } from '@/lib/electron';
interface DataManagementSectionProps {
project: Project;
}
export function DataManagementSection({ project }: DataManagementSectionProps) {
const [showExportDialog, setShowExportDialog] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
// Fetch features and persisted categories using the existing hook
const { features, persistedCategories, loadFeatures } = useBoardFeatures({
currentProject: project,
});
return (
<>
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Database className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Data Management
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Export and import features to backup your data or share with other projects.
</p>
</div>
<div className="p-6 space-y-6">
{/* Export Section */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-medium text-foreground">Export Features</h3>
<p className="text-xs text-muted-foreground mt-1">
Download all features as a JSON or YAML file for backup or sharing.
</p>
</div>
<Button
variant="outline"
onClick={() => setShowExportDialog(true)}
className="gap-2"
data-testid="export-features-button"
>
<Download className="w-4 h-4" />
Export Features
</Button>
</div>
{/* Separator */}
<div className="border-t border-border/50" />
{/* Import Section */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-medium text-foreground">Import Features</h3>
<p className="text-xs text-muted-foreground mt-1">
Import features from a previously exported JSON or YAML file.
</p>
</div>
<Button
variant="outline"
onClick={() => setShowImportDialog(true)}
className="gap-2"
data-testid="import-features-button"
>
<Upload className="w-4 h-4" />
Import Features
</Button>
</div>
</div>
</div>
{/* Export Dialog */}
<ExportFeaturesDialog
open={showExportDialog}
onOpenChange={setShowExportDialog}
projectPath={project.path}
features={features}
/>
{/* Import Dialog */}
<ImportFeaturesDialog
open={showImportDialog}
onOpenChange={setShowImportDialog}
projectPath={project.path}
categorySuggestions={persistedCategories}
onImportComplete={() => {
loadFeatures();
}}
/>
</>
);
}

View File

@@ -1,6 +1,13 @@
import { useState, useCallback } from 'react';
export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'claude' | 'danger';
export type ProjectSettingsViewId =
| 'identity'
| 'theme'
| 'worktrees'
| 'commands'
| 'claude'
| 'data'
| 'danger';
interface UseProjectSettingsViewOptions {
initialView?: ProjectSettingsViewId;

View File

@@ -2,5 +2,6 @@ export { ProjectSettingsView } from './project-settings-view';
export { ProjectIdentitySection } from './project-identity-section';
export { ProjectThemeSection } from './project-theme-section';
export { WorktreePreferencesSection } from './worktree-preferences-section';
export { CommandsSection } from './commands-section';
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
export { ProjectSettingsNavigation } from './components/project-settings-navigation';

View File

@@ -25,7 +25,7 @@ import type {
ClaudeCompatibleProvider,
ClaudeModelAlias,
} from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types';
interface ProjectBulkReplaceDialogProps {
open: boolean;
@@ -44,12 +44,16 @@ const PHASE_LABELS: Record<PhaseModelKey, string> = {
featureGenerationModel: 'Feature Generation',
backlogPlanningModel: 'Backlog Planning',
projectAnalysisModel: 'Project Analysis',
suggestionsModel: 'AI Suggestions',
ideationModel: 'Ideation',
memoryExtractionModel: 'Memory Extraction',
};
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
// Special key for default feature model (not a phase but included in bulk replace)
const DEFAULT_FEATURE_MODEL_KEY = '__defaultFeatureModel__' as const;
type ExtendedPhaseKey = PhaseModelKey | typeof DEFAULT_FEATURE_MODEL_KEY;
// Claude model display names
const CLAUDE_MODEL_DISPLAY: Record<ClaudeModelAlias, string> = {
haiku: 'Claude Haiku',
@@ -62,11 +66,18 @@ export function ProjectBulkReplaceDialog({
onOpenChange,
project,
}: ProjectBulkReplaceDialogProps) {
const { phaseModels, setProjectPhaseModelOverride, claudeCompatibleProviders } = useAppStore();
const {
phaseModels,
setProjectPhaseModelOverride,
claudeCompatibleProviders,
defaultFeatureModel,
setProjectDefaultFeatureModel,
} = useAppStore();
const [selectedProvider, setSelectedProvider] = useState<string>('anthropic');
// Get project-level overrides
const projectOverrides = project.phaseModelOverrides || {};
const projectDefaultFeatureModel = project.defaultFeatureModel;
// Get enabled providers
const enabledProviders = useMemo(() => {
@@ -122,11 +133,15 @@ export function ProjectBulkReplaceDialog({
const findModelForClaudeAlias = (
provider: ClaudeCompatibleProvider | null,
claudeAlias: ClaudeModelAlias,
phase: PhaseModelKey
key: ExtendedPhaseKey
): PhaseModelEntry => {
if (!provider) {
// Anthropic Direct - reset to default phase model (includes correct thinking levels)
return DEFAULT_PHASE_MODELS[phase];
// For default feature model, use the default from global settings
if (key === DEFAULT_FEATURE_MODEL_KEY) {
return DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel;
}
return DEFAULT_PHASE_MODELS[key];
}
// Find model that maps to this Claude alias
@@ -146,60 +161,91 @@ export function ProjectBulkReplaceDialog({
return { model: claudeAlias };
};
// Helper to generate preview item for any entry
const generatePreviewItem = (
key: ExtendedPhaseKey,
label: string,
currentEntry: PhaseModelEntry
) => {
const claudeAlias = getClaudeModelAlias(currentEntry);
const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, key);
// Get display names
const getCurrentDisplay = (): string => {
if (currentEntry.providerId) {
const provider = enabledProviders.find((p) => p.id === currentEntry.providerId);
if (provider) {
const model = provider.models?.find((m) => m.id === currentEntry.model);
return model?.displayName || currentEntry.model;
}
}
return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model;
};
const getNewDisplay = (): string => {
if (newEntry.providerId && selectedProviderConfig) {
const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model);
return model?.displayName || newEntry.model;
}
return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model;
};
const isChanged =
currentEntry.model !== newEntry.model ||
currentEntry.providerId !== newEntry.providerId ||
currentEntry.thinkingLevel !== newEntry.thinkingLevel;
return {
key,
label,
claudeAlias,
currentDisplay: getCurrentDisplay(),
newDisplay: getNewDisplay(),
newEntry,
isChanged,
};
};
// Generate preview of changes
const preview = useMemo(() => {
return ALL_PHASES.map((phase) => {
// Current effective value (project override or global)
// Default feature model entry (first in the list)
const globalDefaultFeature = defaultFeatureModel ?? DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel;
const currentDefaultFeature = projectDefaultFeatureModel || globalDefaultFeature;
const defaultFeaturePreview = generatePreviewItem(
DEFAULT_FEATURE_MODEL_KEY,
'Default Feature Model',
currentDefaultFeature
);
// Phase model entries
const phasePreview = ALL_PHASES.map((phase) => {
const globalEntry = phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase];
const currentEntry = projectOverrides[phase] || globalEntry;
const claudeAlias = getClaudeModelAlias(currentEntry);
const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, phase);
// Get display names
const getCurrentDisplay = (): string => {
if (currentEntry.providerId) {
const provider = enabledProviders.find((p) => p.id === currentEntry.providerId);
if (provider) {
const model = provider.models?.find((m) => m.id === currentEntry.model);
return model?.displayName || currentEntry.model;
}
}
return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model;
};
const getNewDisplay = (): string => {
if (newEntry.providerId && selectedProviderConfig) {
const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model);
return model?.displayName || newEntry.model;
}
return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model;
};
const isChanged =
currentEntry.model !== newEntry.model ||
currentEntry.providerId !== newEntry.providerId ||
currentEntry.thinkingLevel !== newEntry.thinkingLevel;
return {
phase,
label: PHASE_LABELS[phase],
claudeAlias,
currentDisplay: getCurrentDisplay(),
newDisplay: getNewDisplay(),
newEntry,
isChanged,
};
return generatePreviewItem(phase, PHASE_LABELS[phase], currentEntry);
});
}, [phaseModels, projectOverrides, selectedProviderConfig, enabledProviders]);
return [defaultFeaturePreview, ...phasePreview];
}, [
phaseModels,
projectOverrides,
selectedProviderConfig,
enabledProviders,
defaultFeatureModel,
projectDefaultFeatureModel,
]);
// Count how many will change
const changeCount = preview.filter((p) => p.isChanged).length;
// Apply the bulk replace as project overrides
const handleApply = () => {
preview.forEach(({ phase, newEntry, isChanged }) => {
preview.forEach(({ key, newEntry, isChanged }) => {
if (isChanged) {
setProjectPhaseModelOverride(project.id, phase, newEntry);
if (key === DEFAULT_FEATURE_MODEL_KEY) {
setProjectDefaultFeatureModel(project.id, newEntry);
} else {
setProjectPhaseModelOverride(project.id, key as PhaseModelKey, newEntry);
}
}
});
onOpenChange(false);
@@ -295,7 +341,7 @@ export function ProjectBulkReplaceDialog({
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Preview Changes</label>
<span className="text-xs text-muted-foreground">
{changeCount} of {ALL_PHASES.length} will be overridden
{changeCount} of {preview.length} will be overridden
</span>
</div>
<div className="border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto">
@@ -311,15 +357,23 @@ export function ProjectBulkReplaceDialog({
</tr>
</thead>
<tbody>
{preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => (
{preview.map(({ key, label, currentDisplay, newDisplay, isChanged }) => (
<tr
key={phase}
key={key}
className={cn(
'border-t border-border/50',
isChanged ? 'bg-brand-500/5' : 'opacity-50'
isChanged ? 'bg-brand-500/5' : 'opacity-50',
key === DEFAULT_FEATURE_MODEL_KEY && 'bg-accent/30'
)}
>
<td className="p-2 font-medium">{label}</td>
<td className="p-2 font-medium">
{label}
{key === DEFAULT_FEATURE_MODEL_KEY && (
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-brand-500/20 text-brand-500">
Feature Default
</span>
)}
</td>
<td className="p-2 text-muted-foreground">{currentDisplay}</td>
<td className="p-2 text-center">
{isChanged ? (

View File

@@ -1,13 +1,13 @@
import { useState } from 'react';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { Workflow, RotateCcw, Globe, Check, Replace } from 'lucide-react';
import { Workflow, RotateCcw, Globe, Check, Replace, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Project } from '@/lib/electron';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { ProjectBulkReplaceDialog } from './project-bulk-replace-dialog';
import type { PhaseModelKey, PhaseModelEntry } from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types';
interface ProjectModelsSectionProps {
project: Project;
@@ -72,9 +72,9 @@ const GENERATION_TASKS: PhaseConfig[] = [
description: 'Analyzes project structure for suggestions',
},
{
key: 'suggestionsModel',
label: 'AI Suggestions',
description: 'Model for feature, refactoring, security, and performance suggestions',
key: 'ideationModel',
label: 'Ideation',
description: 'Model for ideation view (generating AI suggestions)',
},
];
@@ -88,6 +88,127 @@ const MEMORY_TASKS: PhaseConfig[] = [
const ALL_PHASES = [...QUICK_TASKS, ...VALIDATION_TASKS, ...GENERATION_TASKS, ...MEMORY_TASKS];
/**
* Default feature model override section for per-project settings.
*/
function FeatureDefaultModelOverrideSection({ project }: { project: Project }) {
const {
defaultFeatureModel: globalDefaultFeatureModel,
setProjectDefaultFeatureModel,
claudeCompatibleProviders,
} = useAppStore();
const globalValue: PhaseModelEntry =
globalDefaultFeatureModel ?? DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel;
const projectOverride = project.defaultFeatureModel;
const hasOverride = !!projectOverride;
const effectiveValue = projectOverride || globalValue;
// Get display name for a model
const getModelDisplayName = (entry: PhaseModelEntry): string => {
if (entry.providerId) {
const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId);
if (provider) {
const model = provider.models?.find((m) => m.id === entry.model);
if (model) {
return `${model.displayName} (${provider.name})`;
}
}
}
// Default to model ID for built-in models (both short aliases and canonical IDs)
const modelMap: Record<string, string> = {
haiku: 'Claude Haiku',
sonnet: 'Claude Sonnet',
opus: 'Claude Opus',
'claude-haiku': 'Claude Haiku',
'claude-sonnet': 'Claude Sonnet',
'claude-opus': 'Claude Opus',
};
return modelMap[entry.model] || entry.model;
};
const handleClearOverride = () => {
setProjectDefaultFeatureModel(project.id, null);
};
const handleSetOverride = (entry: PhaseModelEntry) => {
setProjectDefaultFeatureModel(project.id, entry);
};
return (
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium text-foreground">Feature Defaults</h3>
<p className="text-xs text-muted-foreground">
Default model for new feature cards in this project
</p>
</div>
<div className="space-y-3">
<div
className={cn(
'flex items-center justify-between p-4 rounded-xl',
'bg-accent/20 border',
hasOverride ? 'border-brand-500/30 bg-brand-500/5' : 'border-border/30',
'hover:bg-accent/30 transition-colors'
)}
>
<div className="flex-1 pr-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-brand-500/10 flex items-center justify-center">
<Sparkles className="w-4 h-4 text-brand-500" />
</div>
<h4 className="text-sm font-medium text-foreground">Default Feature Model</h4>
{hasOverride ? (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-brand-500/20 text-brand-500">
Override
</span>
) : (
<span className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground">
<Globe className="w-3 h-3" />
Global
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-1 ml-10">
Model and thinking level used when creating new feature cards
</p>
{hasOverride && (
<p className="text-xs text-brand-500 mt-1 ml-10">
Using: {getModelDisplayName(effectiveValue)}
</p>
)}
{!hasOverride && (
<p className="text-xs text-muted-foreground/70 mt-1 ml-10">
Using global: {getModelDisplayName(globalValue)}
</p>
)}
</div>
<div className="flex items-center gap-2">
{hasOverride && (
<Button
variant="ghost"
size="sm"
onClick={handleClearOverride}
className="h-8 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<RotateCcw className="w-3.5 h-3.5 mr-1" />
Reset
</Button>
)}
<PhaseModelSelector
compact
value={effectiveValue}
onChange={handleSetOverride}
align="end"
/>
</div>
</div>
</div>
</div>
);
}
function PhaseOverrideItem({
phase,
project,
@@ -234,8 +355,10 @@ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
useAppStore();
const [showBulkReplace, setShowBulkReplace] = useState(false);
// Count how many overrides are set
const overrideCount = Object.keys(project.phaseModelOverrides || {}).length;
// Count how many overrides are set (including defaultFeatureModel)
const phaseOverrideCount = Object.keys(project.phaseModelOverrides || {}).length;
const hasDefaultFeatureModelOverride = !!project.defaultFeatureModel;
const overrideCount = phaseOverrideCount + (hasDefaultFeatureModelOverride ? 1 : 0);
// Check if Claude is available
const isClaudeDisabled = disabledProviders.includes('claude');
@@ -328,6 +451,9 @@ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
{/* Content */}
<div className="p-6 space-y-8">
{/* Feature Defaults */}
<FeatureDefaultModelOverrideSection project={project} />
{/* Quick Tasks */}
<PhaseGroup
title="Quick Tasks"

View File

@@ -5,7 +5,9 @@ import { Button } from '@/components/ui/button';
import { ProjectIdentitySection } from './project-identity-section';
import { ProjectThemeSection } from './project-theme-section';
import { WorktreePreferencesSection } from './worktree-preferences-section';
import { CommandsSection } from './commands-section';
import { ProjectModelsSection } from './project-models-section';
import { DataManagementSection } from './data-management-section';
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
@@ -85,8 +87,12 @@ export function ProjectSettingsView() {
return <ProjectThemeSection project={currentProject} />;
case 'worktrees':
return <WorktreePreferencesSection project={currentProject} />;
case 'commands':
return <CommandsSection project={currentProject} />;
case 'claude':
return <ProjectModelsSection project={currentProject} />;
case 'data':
return <DataManagementSection project={currentProject} />;
case 'danger':
return (
<DangerZoneSection

View File

@@ -23,6 +23,7 @@ import {
CursorSettingsTab,
CodexSettingsTab,
OpencodeSettingsTab,
GeminiSettingsTab,
} from './settings-view/providers';
import { MCPServersSection } from './settings-view/mcp-servers';
import { PromptCustomizationSection } from './settings-view/prompts';
@@ -123,6 +124,8 @@ export function SettingsView() {
return <CodexSettingsTab />;
case 'opencode-provider':
return <OpencodeSettingsTab />;
case 'gemini-provider':
return <GeminiSettingsTab />;
case 'providers':
case 'claude': // Backwards compatibility - redirect to claude-provider
return <ClaudeSettingsTab />;

View File

@@ -0,0 +1,250 @@
import { Button } from '@/components/ui/button';
import { SkeletonPulse } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, Key } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
import { GeminiIcon } from '@/components/ui/provider-icon';
export type GeminiAuthMethod =
| 'api_key' // API key authentication
| 'google_login' // Google OAuth authentication
| 'vertex_ai' // Vertex AI authentication
| 'none';
export interface GeminiAuthStatus {
authenticated: boolean;
method: GeminiAuthMethod;
hasApiKey?: boolean;
hasEnvApiKey?: boolean;
hasCredentialsFile?: boolean;
error?: string;
}
function getAuthMethodLabel(method: GeminiAuthMethod): string {
switch (method) {
case 'api_key':
return 'API Key';
case 'google_login':
return 'Google OAuth';
case 'vertex_ai':
return 'Vertex AI';
default:
return method || 'Unknown';
}
}
interface GeminiCliStatusProps {
status: CliStatus | null;
authStatus?: GeminiAuthStatus | null;
isChecking: boolean;
onRefresh: () => void;
}
export function GeminiCliStatusSkeleton() {
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<SkeletonPulse className="w-9 h-9 rounded-xl" />
<SkeletonPulse className="h-6 w-36" />
</div>
<SkeletonPulse className="w-9 h-9 rounded-lg" />
</div>
<div className="ml-12">
<SkeletonPulse className="h-4 w-80" />
</div>
</div>
<div className="p-6 space-y-4">
{/* Installation status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-40" />
<SkeletonPulse className="h-3 w-32" />
<SkeletonPulse className="h-3 w-48" />
</div>
</div>
{/* Auth status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-28" />
<SkeletonPulse className="h-3 w-36" />
</div>
</div>
</div>
</div>
);
}
export function GeminiCliStatus({
status,
authStatus,
isChecking,
onRefresh,
}: GeminiCliStatusProps) {
if (!status) return <GeminiCliStatusSkeleton />;
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 flex items-center justify-center border border-blue-500/20">
<GeminiIcon className="w-5 h-5 text-blue-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Gemini CLI</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={isChecking}
data-testid="refresh-gemini-cli"
title="Refresh Gemini CLI detection"
className={cn(
'h-9 w-9 rounded-lg',
'hover:bg-accent/50 hover:scale-105',
'transition-all duration-200'
)}
>
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Gemini CLI provides access to Google&apos;s Gemini AI models with thinking capabilities.
</p>
</div>
<div className="p-6 space-y-4">
{status.success && status.status === 'installed' ? (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Gemini CLI Installed</p>
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
{status.method && (
<p>
Method: <span className="font-mono">{status.method}</span>
</p>
)}
{status.version && (
<p>
Version: <span className="font-mono">{status.version}</span>
</p>
)}
{status.path && (
<p className="truncate" title={status.path}>
Path: <span className="font-mono text-[10px]">{status.path}</span>
</p>
)}
</div>
</div>
</div>
{/* Authentication Status */}
{authStatus?.authenticated ? (
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
<div className="text-xs text-emerald-400/70 mt-1.5">
{authStatus.method !== 'none' && (
<p>
Method:{' '}
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
</p>
)}
</div>
</div>
</div>
) : (
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
<div className="w-10 h-10 rounded-xl bg-red-500/15 flex items-center justify-center border border-red-500/20 shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-red-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-red-400">Authentication Failed</p>
{authStatus?.error && (
<p className="text-xs text-red-400/70 mt-1">{authStatus.error}</p>
)}
<p className="text-xs text-red-400/70 mt-2">
Run <code className="font-mono bg-red-500/10 px-1 rounded">gemini</code>{' '}
interactively in your terminal to log in with Google, or set the{' '}
<code className="font-mono bg-red-500/10 px-1 rounded">GEMINI_API_KEY</code>{' '}
environment variable.
</p>
</div>
</div>
)}
{status.recommendation && (
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">Gemini CLI Not Detected</p>
<p className="text-xs text-amber-400/70 mt-1">
{status.recommendation || 'Install Gemini CLI to use Google Gemini models.'}
</p>
</div>
</div>
{status.installCommands && (
<div className="space-y-3">
<p className="text-xs font-medium text-foreground/80">Installation Commands:</p>
<div className="space-y-2">
{status.installCommands.npm && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
npm
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.npm}
</code>
</div>
)}
{status.installCommands.macos && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
macOS/Linux
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.macos}
</code>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

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