Compare commits

..

186 Commits

Author SHA1 Message Date
DhanushSantosh
1a460c301a fix(test): Set HOSTNAME in dev server tests for consistent behavior
Dev server test was failing on non-localhost hostnames (e.g., 'fedora')
because it expected 'localhost' in the URL. Now sets HOSTNAME env var
in test setup and restores it in teardown for consistent test behavior
across all environments.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 19:55:23 +05:30
DhanushSantosh
c1f480fe49 fix(ui): Make GitHub Copilot icon theme-aware for light mode visibility
The Copilot icon had a hardcoded white fill that made it invisible on
light theme backgrounds. Changed to use currentColor so it adapts to
theme and respects CSS text color classes.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 19:55:08 +05:30
Shirone
ef3f8de33b Merge pull request #715 from OG-Ken/fix/opencode-dynamic-models-404-endpoint
fix: Correct OpenCode dynamic models API endpoint URL
2026-01-27 12:02:33 +00:00
Ken Lopez
d379bf412a fix: Correct OpenCode dynamic models API endpoint URL
The fetchOpencodeModels function was calling '/api/opencode/models' which
returns 404. Changed to '/api/setup/opencode/models' which correctly
returns the dynamic models.

This fixes an issue where enabled OpenCode dynamic models (e.g., local
Ollama models) were not appearing in the Model Defaults dropdown selectors
despite being visible and enabled in the OpenCode Settings page.
2026-01-27 03:06:28 -05:00
Shirone
cf35ca8650 Merge pull request #714 from AutoMaker-Org/feature/bug-request-changes-on-plan-mode-is-not-proceedin-8xpd
refactor(auto-mode): Enhance revision prompt customization
2026-01-26 23:36:30 +00:00
Shirone
4f1555f196 feat(event-history): Replace alert with toast notifications for event replay results
Update the EventHistoryView component to use toast notifications instead of alert dialogs for displaying event replay results, enhancing user experience and providing clearer feedback on success and failure states.
2026-01-27 00:29:34 +01:00
Shirone
5aace0ce0f fix(event-hook): Update featureName assignment to prioritize loaded feature title over payload 2026-01-27 00:25:36 +01:00
Shirone
e439d8a632 fix(routes): Update feature creation event to use title instead of name
Change the feature creation event to emit 'Untitled Feature' when the title is not provided, improving clarity in event handling.
2026-01-27 00:25:16 +01:00
Shirone
b7c6b8bfc6 feat(ui): Show project name in classic sidebar layout
Add project name display at the top of the navigation for the classic
(discord) sidebar style, which previously didn't show the project name
anywhere. Shows the project icon (custom or Lucide) and name with a
separator below.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 00:15:38 +01:00
Shirone
a60904bd51 fix(ui,server): Fix project icon updates and image upload issues
- Fix setProjectCustomIcon using wrong property name (customIcon -> customIconPath)
- Add currentProject state update to setProjectIcon and setProjectCustomIcon
- Fix data URL regex to handle all formats (e.g., charset=utf-8 in GIFs)
- Increase project icon size limit from 2MB to 5MB for animated GIFs
- Add toast notifications for upload validation errors
- Add image error fallback to folder icon in project switcher
- Make HttpApiClient get/put methods public for store access
- Fix TypeScript errors in app-store.ts (trashedAt type, font properties)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 00:09:55 +01:00
Kacper
d7c3337330 refactor(auto-mode): Enhance revision prompt customization and task format validation
- Updated the revision prompt generation to utilize a customizable template, allowing for dynamic insertion of plan version, previous plan content, user feedback, and task format examples.
- Added validation to ensure the presence of a tasks block in the revised specification, with clear instructions on the required format to prevent execution issues.
- Introduced logging for scenarios where no tasks are found in the revised plan, warning about potential fallback to single-agent execution.
2026-01-26 19:53:07 +01:00
Shirone
c848306e4c Merge pull request #709 from AutoMaker-Org/refactor/store-defaults
refactor(store): Extract default values into store/defaults/
2026-01-25 22:39:59 +00:00
Shirone
f0042312d0 refactor(store): Extract default values into store/defaults/
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 23:32:29 +01:00
Shirone
e876d177b8 Merge pull request #708 from AutoMaker-Org/refactor/store-utils
refactor(store): Extract utility functions into store/utils/
2026-01-25 22:18:10 +00:00
Shirone
8caec15199 refactor(store): Extract utility functions into store/utils/
Move pure utility functions from app-store.ts and type files into
dedicated utils modules for better separation of concerns:

- theme-utils.ts: Theme and font storage utilities
- shortcut-utils.ts: Keyboard shortcut parsing/formatting
- usage-utils.ts: Usage limit checking

All utilities are re-exported from store/utils/index.ts and
app-store.ts maintains backward compatibility for existing imports.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 23:03:52 +01:00
Shirone
7fe9aacb09 Merge pull request #706 from AutoMaker-Org/refactor/store-types
refactor(store): Extract types from app-store.ts into modular type files
2026-01-25 21:36:40 +00:00
Shirone
f55c985634 refactor(types): Make FeatureImage extend ImageAttachment
Address Gemini review feedback - reduce code duplication by having
FeatureImage extend ImageAttachment instead of duplicating properties.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 22:32:46 +01:00
Shirone
38e8a4c4ea refactor(store): Extract types from app-store.ts into modular type files
- Create store/types/ directory with 8 modular type files:
  - usage-types.ts: ClaudeUsage, CodexUsage, isClaudeUsageAtLimit
  - ui-types.ts: ViewMode, ThemeMode, KeyboardShortcuts, etc.
  - settings-types.ts: ApiKeys
  - chat-types.ts: ChatMessage, ChatSession, FeatureImage
  - terminal-types.ts: TerminalState, TerminalTab, etc.
  - project-types.ts: Feature, FileTreeNode, ProjectAnalysis
  - state-types.ts: AppState, AppActions interfaces
  - index.ts: Re-exports all types

- Update electron.ts to import from store/types/usage-types
  (breaks circular dependency between electron.ts and app-store.ts)

- Update app-store.ts to import and re-export types for backward
  compatibility - existing imports from @/store/app-store continue
  to work

This is PR 1 of the app-store refactoring plan.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 22:20:27 +01:00
Shirone
f3ce5ce8ab Merge pull request #704 from AutoMaker-Org/refactor/electron-main-process
refactor: Modularize Electron main process into single-responsibility components
2026-01-25 20:10:39 +00:00
Shirone
99de7813c9 fix: Apply titleBarStyle only on macOS
titleBarStyle: 'hiddenInset' is a macOS-only option. Use conditional
spread to only apply it when process.platform === 'darwin', ensuring
Windows and Linux get consistent default styling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 21:05:07 +01:00
Shirone
2de3ae69d4 fix: Address CodeRabbit security and robustness review comments
- Guard against NaN ports from non-numeric env variables in constants.ts
- Validate IPC sender before returning API key to prevent leaking to
  untrusted senders (webviews, additional windows)
- Filter dialog properties to maintain file-only intent and prevent
  renderer from requesting directories via OPEN_FILE
- Fix Windows VS Code URL paths by ensuring leading slash after 'file'

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 21:02:53 +01:00
Shirone
0b4e9573ed refactor: Simplify tsx path lookup and remove redundant try-catch
Address review feedback:
- Simplify tsx CLI path lookup by extracting path variables and
  reducing nested try-catch blocks
- Remove redundant try-catch around electronAppExists check in
  production server path validation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 20:54:10 +01:00
Shirone
d7ad87bd1b fix: Correct __dirname paths for Vite bundled electron modules
Vite bundles all electron modules into a single main.js file,
so __dirname remains apps/ui/dist-electron regardless of source
file location. Updated path comments to clarify this behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 20:49:53 +01:00
Shirone
615823652c refactor: Modularize Electron main process into single-responsibility components
Extract the monolithic main.ts (~1000 lines) into focused modules:

- electron/constants.ts - Window sizing, port defaults, filenames
- electron/state.ts - Shared state container
- electron/utils/ - Port availability and icon utilities
- electron/security/ - API key management
- electron/windows/ - Window bounds and main window creation
- electron/server/ - Backend and static server management
- electron/ipc/ - IPC handlers with shared channel constants

Benefits:
- Improved testability with isolated modules
- Better discoverability and maintainability
- Single source of truth for IPC channels (used by both main and preload)
- Clear separation of concerns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 20:43:08 +01:00
Shirone
2f883bad20 Merge pull request #703 from AutoMaker-Org/fix/false-positive-auth-warning
fix: Check Claude Code CLI auth before showing warning
2026-01-25 19:14:15 +00:00
Shirone
45706990df fix: Also check hasApiKey for CLI authentication
Address CodeRabbit review comment - API keys stored in CLI credentials
file should also be detected as valid authentication.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 20:04:16 +01:00
Shirone
c9c406dd21 fix: Improve error handling for Claude Code CLI authentication check
Updated the error handling in the Claude Code CLI authentication check to log the specific error encountered. This enhancement provides better visibility into issues during the authentication process, ensuring users are informed of any problems that arise.
2026-01-25 19:53:19 +01:00
Shirone
014736bc1d fix: Check Claude Code CLI auth before showing warning
The startup warning "No Claude authentication configured" was shown
even when users have Claude Code CLI installed and authenticated
with a subscription. The Claude Agent SDK can reuse CLI authentication,
so this was a false positive.

Now checks for Claude Code CLI authentication indicators before
showing the warning:
- Recent CLI activity (stats cache)
- CLI setup indicators (settings + project sessions)
- OAuth credentials file

Also updated the warning message to list all authentication options.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:49:41 +01:00
Shirone
c05359c787 Merge pull request #303 from firstfloris/fix/electron-build-missing-libs
fix: include libs directory in Electron build extraResources
2026-01-25 18:44:17 +00:00
Shirone
a32cb08d1e Merge pull request #702 from AutoMaker-Org/chore/fix-ui-typescript-errors
chore: Fix all 246 TypeScript errors in UI
2026-01-25 18:21:26 +00:00
Shirone
08d1497cbe fix: Address PR review comments
- Fix window.Electron to window.isElectron in http-api-client.ts
- Use void operator instead of async/await for onClick handlers in git-diff-panel.tsx
- Fix critical bug: correct parameter order in useStartAutoMode (maxConcurrency was passed as branchName)
- Add error handling for getApiKeys() result in use-cli-status.ts
- Add authClaude guard in claude-cli-status.tsx for consistency with deauthClaude
- Add optional chaining on api object in cursor-cli-status.tsx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 18:55:42 +01:00
Shirone
5c335641fa chore: Fix all 246 TypeScript errors in UI
- Extended SetupAPI interface with 20+ missing methods for Cursor, Codex,
  OpenCode, Gemini, and Copilot CLI integrations
- Fixed WorktreeInfo type to include isCurrent and hasWorktree fields
- Added null checks for optional API properties across all hooks
- Fixed Feature type conflicts between @automaker/types and local definitions
- Added missing CLI status hooks for all providers
- Fixed type mismatches in mutation callbacks and event handlers
- Removed dead code referencing non-existent GlobalSettings properties
- Updated mock implementations in electron.ts for all new API methods

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 18:36:47 +01:00
Shirone
0fb471ca15 chore: Enhance type safety and improve code consistency across components
- Added a new `typecheck` script in `package.json` for better type checking in the UI workspace.
- Refactored several components to remove unnecessary type assertions and improve type safety, particularly in `new-project-modal.tsx`, `edit-project-dialog.tsx`, and `task-progress-panel.tsx`.
- Updated event handling in `git-diff-panel.tsx` to use async functions for better error handling.
- Improved type definitions in various files, including `setup-view` and `electron.ts`, to ensure consistent usage of types across the codebase.
- Cleaned up global type definitions for better clarity and maintainability.

These changes aim to streamline the development process and reduce potential runtime errors.
2026-01-25 18:11:48 +01:00
Shirone
b65037d995 chore: Remove obsolete files related to graph layout and security audit
- Deleted `graph-layout-bug.md`, `SECURITY_TODO.md`, `TODO.md`, and `phase-model-selector.tsx` as they are no longer relevant to the project.
- This cleanup helps streamline the codebase and remove outdated documentation and components.
2026-01-25 17:41:17 +01:00
Shirone
5eda2c9b2b Merge pull request #700 from AutoMaker-Org/chore/fix-lint-errors-cleanup-unused-code
chore: Fix all lint errors and remove unused code
2026-01-25 16:38:43 +00:00
Shirone
006152554b chore: Fix all lint errors and remove unused code
- Fix 75 ESLint errors by updating eslint.config.mjs:
  - Add missing browser globals (MouseEvent, AbortController, Response, etc.)
  - Add Vite define global (__APP_VERSION__)
  - Configure @ts-nocheck to require descriptions
  - Add no-unused-vars rule for .mjs scripts

- Fix runtime bug in agent-output-modal.tsx (setOutput -> setStreamedContent)

- Remove ~120 unused variable warnings across 97 files:
  - Remove unused imports (React hooks, lucide icons, types)
  - Remove unused constants and variables
  - Remove unused function definitions
  - Prefix intentionally unused parameters with underscore

- Add descriptions to all @ts-nocheck comments (25 files)

- Clean up misc issues:
  - Remove invalid deprecation plugin comments
  - Fix eslint-disable comment placement
  - Add missing RefreshCw import in code-view.tsx

Reduces lint warnings from ~300 to 67 (all remaining are no-explicit-any)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 17:33:45 +01:00
Shirone
3b56d553c9 chore: Add linting commands for error detection in UI and server workspaces 2026-01-25 17:05:14 +01:00
Shirone
375f9ea9d4 chore: Update package-lock.json to include zod dependency version "^3.24.1 || ^4.0.0" 2026-01-25 16:57:10 +01:00
Shirone
bf25a7a4e5 Merge pull request #679 from AutoMaker-Org/feature/bug-complete-fix-for-the-plan-mode-system-inside-sbyt
fix: Complete fix for plan mode system across all providers
2026-01-25 15:56:16 +00:00
Shirone
5171abc37f Merge remote-tracking branch 'origin/v0.14.0rc' into feature/bug-complete-fix-for-the-plan-mode-system-inside-sbyt
Resolved conflict in auto-mode-service.ts by keeping the v0.14.0rc version
which uses isFeatureRunning() method and has more informative logging.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 15:12:42 +01:00
Shirone
9c8265c4e5 Merge pull request #699 from AutoMaker-Org/feature/features-stuck-in-in-progress-after-server-restart-j0th
fix: Prevent features from getting stuck in in_progress after server restart
2026-01-25 14:08:15 +00:00
Shirone
ef779daedf refactor: Improve error handling and status preservation in auto-mode service
- Simplified the graceful shutdown process by removing redundant error handling for marking features as interrupted, as it is now managed internally.
- Updated orphan detection logging to streamline the process and enhance clarity.
- Added logic to preserve specific pipeline statuses when marking features as interrupted, ensuring correct resumption of features after a server restart.
- Enhanced unit tests to cover new behavior for preserving pipeline statuses and handling various feature states.
2026-01-25 14:57:23 +01:00
Shirone
011ac404bb fix: Prevent features from getting stuck in in_progress after server restart
- Add graceful shutdown handler that marks running features as 'interrupted'
  before server exit (SIGTERM/SIGINT)
- Add 30-second shutdown timeout to prevent hanging on exit
- Add orphan detection to identify features with missing branches
- Add isFeatureRunning() for idempotent resume checks
- Improve resumeInterruptedFeatures() to handle features without saved context
- Add 'interrupted' status to FeatureStatusWithPipeline type
- Replace console.log with proper logger in auto-mode-service
- Add comprehensive unit tests for all new functionality (15 new tests)

Fixes #696

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 14:38:39 +01:00
Shirone
9587f13de5 Merge pull request #698 from AutoMaker-Org/feature/bug-github-issue-loader-spinner-is-blended-when-v-wms7
fix(ui): fix spinner visibility in github issue validation button
2026-01-25 13:13:40 +00:00
Shirone
08dc90b378 refactor(ui): remove redundant disabled props when using Button loading
The Button component internally sets disabled when loading=true, so
explicit disabled props are redundant and can be removed.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 14:09:14 +01:00
Shirone
80ef21c8d0 refactor(ui): use Button loading prop instead of manual Spinner
Address PR #698 review feedback from Gemini Code Assist to use the
Button component's built-in loading prop instead of manually rendering
Spinner components.

The Button component already handles spinner display with correct
variant selection (foreground for default/destructive buttons, primary
for others), so this simplifies the code and aligns with component
abstractions.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 14:07:17 +01:00
Shirone
98d98cc056 fix(ui): fix spinner visibility in github issue validation button
The spinner component in the GitHub issue validation button was blended
into the button's primary background color, making it invisible. This was
caused by the spinner using the default 'primary' variant which applies
text-primary color, matching the button's background.

Changed the spinner to use the 'foreground' variant which applies
text-primary-foreground for proper contrast against the primary background.
This follows the existing pattern already implemented in the worktree panel
components.

Fixes #697
2026-01-25 13:58:21 +01:00
Shirone
2a24377870 fix: Clear planSpec.currentTaskId instead of feature.currentTaskId in resetStuckFeatures
Address CodeRabbit review comment: The reset logic was incorrectly
clearing feature.currentTaskId (which doesn't exist on Feature type)
instead of feature.planSpec.currentTaskId. This left planSpec.currentTaskId
stale, causing UI/recovery to still point at reverted tasks.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:42:07 +01:00
Shirone
895e4c28ba Merge remote-tracking branch 'origin/v0.14.0rc' into feature/bug-complete-fix-for-the-plan-mode-system-inside-sbyt
Resolved conflicts in dialog components by keeping simplified code
without modelSupportsPlanningMode conditional (always true now).
2026-01-25 13:41:04 +01:00
Shirone
ebf2fcadd6 Merge pull request #695 from AutoMaker-Org/feature/bug-create-global-tooltip-provider-in-main-app-fi-ge48
refactor: Create global TooltipProvider in app.tsx to eliminate duplication
2026-01-25 12:25:08 +00:00
Shirone
019da6b77a fix: Address PR #695 review feedback for TooltipProvider refactor
- Add delayDuration={300} to global TooltipProvider in app.tsx to
  maintain consistent tooltip timing (previously many components used
  delayDuration={200}, so 300ms is a good compromise per review)
- Remove leftover TooltipProvider wrappers in task-node.tsx that were
  still referenced after import was removed (causing build failure)
- Remove leftover TooltipProvider wrapper in account-section.tsx
- Fix Tooltip+Popover nesting focus management issue in
  graph-filter-controls.tsx by adding onOpenAutoFocus={(e) =>
  e.preventDefault()} to PopoverContent components

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:17:51 +01:00
Shirone
605d9658d9 refactor: Create global TooltipProvider in app.tsx to eliminate duplication
- Add global TooltipProvider wrapper in app.tsx for entire application
- Remove 36 duplicate TooltipProvider instances across 20 UI component files
- Clean up imports by removing TooltipProvider from component imports
- Follow Radix UI best practices for TooltipProvider placement
- Reduce code by 62 lines while maintaining all tooltip functionality

Closes #694

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-25 12:59:58 +01:00
Shirone
906f471521 Merge pull request #692 from AutoMaker-Org/feature/bug-summary-not-updated-when-doing-refine-fdo8
fix: Summary not updated when doing Refine
2026-01-25 11:29:34 +00:00
Shirone
a10ddadbde Merge pull request #693 from AutoMaker-Org/feature/bug-fix-columns-overflow-title-wrap-t6j1
Fix: Column header overflow and title wrapping in Kanban board
2026-01-25 11:29:13 +00:00
Shirone
3399d48823 refactor: Extract regex patterns into configurable arrays in extractSummary functions
Address code review feedback from Gemini Code Assist on PR #692.
Refactored the repeated matchAll() logic in extractSummary() functions to use
a loop over a configuration array, reducing code duplication and improving
maintainability.

- agent-context-parser.ts: Use regexesToTry array with group index
- log-parser.ts: Use regexesToTry array with processor functions for special handling

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 12:24:57 +01:00
Shirone
7f5c5e864d feat: Enhance Kanban board UI with tooltips and responsive column adjustments
- Added tooltips for action buttons in the Kanban board to improve user experience.
- Adjusted column title handling to prevent overflow by increasing column width and minimum width.
- Updated button icons for better visual clarity and consistency.
- Ensured that header labels in list views are now truncated to maintain layout integrity.
2026-01-25 12:24:03 +01:00
Shirone
35d2d41821 feat: Update summary extraction logic to return the most recent summary from multiple occurrences
- Enhanced `extractSummary` functions in `agent-context-parser.ts` and `log-parser.ts` to utilize `matchAll` for capturing all summary instances.
- Modified logic to return the last found summary, ensuring the most recent content is extracted.
- Improved handling of fragmented text and various summary formats for consistency.
2026-01-25 12:15:05 +01:00
Shirone
6a3993385e fix: Clear currentTaskId when reverting tasks in auto mode service
- Added logic to clear the currentTaskId for a feature if it points to a reverted task, improving task management and logging clarity.
2026-01-25 11:47:30 +01:00
Shirone
df7024f4ea Merge remote-tracking branch 'origin/v0.14.0rc' into feature/bug-complete-fix-for-the-plan-mode-system-inside-sbyt 2026-01-25 11:45:37 +01:00
Shirone
4485c49c9b feat: Enhance auto mode service with summary extraction and saving
- Added functionality to extract and save the final summary from multi-task or single-agent execution in the auto mode service.
- Updated event types in the query invalidation hook to include 'auto_mode_task_started' and 'auto_mode_task_complete' for better event handling.
2026-01-25 11:36:53 +01:00
Shirone
7a5cb38a37 style: Adjust spacing in ideation header and update button styling in settings popover 2026-01-25 01:50:12 +01:00
Shirone
c9833b67a0 Merge pull request #667 from Monoquark/feature/enhanced-ideation-context-options
feat: Add ideation context settings
2026-01-25 00:46:51 +00:00
Shirone
0f11ee2212 chore: format 2026-01-25 01:41:18 +01:00
Shirone
74b301c2d1 Merge pull request #686 from Monoquark/fix/resolve-claude-required-model-settings
fix: Remove mandatory Claude check for Project Settings -> Models
2026-01-25 00:40:24 +00:00
Shirone
81ee2d1399 Merge pull request #664 from ruant/fix/docker-and-missing-deps
fix: docker, broken npm build script and missing dependency
2026-01-25 00:39:30 +00:00
Shirone
f025ced035 fix: Update data-testid for planning approval checkbox in AddFeatureDialog
- Changed the data-testid from "add-feature-require-approval-checkbox" to "add-feature-planning-require-approval-checkbox" for better clarity and consistency in testing.
2026-01-24 23:24:44 +01:00
Shirone
4f07948712 refactor: Update model references and improve feature summary handling
- Changed model references from `bareModel` to `effectiveBareModel` in multiple locations to ensure consistency.
- Removed redundant event emission for `auto_mode_summary` after saving feature summaries.
- Added checks to prevent resuming features that are already running, enhancing error handling.
- Introduced a new useEffect in various dialogs to clear `requirePlanApproval` when planning mode is set to 'skip' or 'lite'.
- Updated prompt templates to enforce a structured summary output format, ensuring critical information is captured after task completion.
2026-01-24 23:11:37 +01:00
Shirone
07f95ae13b Merge pull request #688 from AutoMaker-Org/feature/bug-worktree-pr-fetching-still-fetch-too-frequent-26ay
fix: Prevent GitHub API rate limiting from frequent worktree PR fetching (fixes #685)
2026-01-24 21:45:55 +00:00
Shirone
8dd6ab2161 fix: Extend cache TTL on GitHub PR fetch failure to prevent retry storms
Address PR #688 review feedback from CodeRabbit: When a GitHub PR fetch
fails and we return stale cached data, also update the fetchedAt timestamp.
This prevents the original TTL from expiring and causing every subsequent
poll to retry the failing request, which would still hammer GitHub during
API outages.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 22:38:50 +01:00
Shirone
b5143f4b00 fix: Return stale cache on GitHub PR fetch failure to prevent repeated API calls
Address PR #688 review feedback: previously the cache was deleted before
fetch, causing repeated API calls if the fetch failed. Now the cache entry
is preserved and stale data is returned on failure, preventing unnecessary
API calls during GitHub API flakiness or temporary outages.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 22:27:58 +01:00
Shirone
f5efa857ca fix: Prevent GitHub API rate limiting from frequent worktree PR fetching
Fixes #685

This commit addresses the GitHub API rate limit issue caused by excessive worktree PR status fetching.

## Changes

### Server-side PR caching (list.ts)
- Added `GitHubPRCacheEntry` interface and `githubPRCache` Map
- Implemented 2-minute TTL cache for GitHub PR data
- Modified `fetchGitHubPRs()` to check cache before making API calls
- Added `forceRefresh` parameter to bypass cache when explicitly requested
- Cache is properly cleared when force refresh is triggered

### Frontend polling reduction (worktree-panel.tsx)
- Increased worktree polling interval from 5 seconds to 30 seconds
- Reduces polling frequency by 6x while keeping UI reasonably fresh
- Updated comment to reflect new polling strategy

### Type improvements (use-worktrees.ts)
- Fixed `fetchWorktrees` callback signature to accept `silent` option
- Returns proper type for removed worktrees detection

## Impact
- Combined ~12x reduction in GitHub API calls
- 2-minute cache prevents repeated API hits during normal operation
- 30-second polling balances responsiveness with API conservation
- Force refresh option allows users to manually update when needed

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-24 22:05:29 +01:00
Monoquark
c401bf4e63 docs: Add docstrings for project models selection 2026-01-24 21:52:46 +01:00
Monoquark
43d5ec9aed refactor: Remove unused disableProviders variable
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-24 21:37:18 +01:00
Monoquark
f8108b1a6c fix: Remove mandatory Claude check for Project Settings -> Models 2026-01-24 21:23:30 +01:00
Shirone
076ab14a5e Merge branch 'v0.14.0rc' into feature/bug-complete-fix-for-the-plan-mode-system-inside-sbyt
Resolved conflict in apps/ui/src/hooks/use-query-invalidation.ts by:
- Keeping the refactored structure from v0.14.0rc (using constants and hasFeatureId() type guard)
- Adding the additional event types from the feature branch (auto_mode_task_status, auto_mode_summary) to SINGLE_FEATURE_INVALIDATION_EVENTS constant

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 21:16:43 +01:00
Shirone
a4c43b99a5 Merge pull request #680 from AutoMaker-Org/feature/bug-improve-the-worktree-ui-79ph
fix(ui): Improve worktree panel UI with dropdown for multiple worktrees
2026-01-24 19:58:45 +00:00
Shirone
0f00180c50 Merge pull request #677 from AutoMaker-Org/feature/bug-fix-custom-pipelines-columns-ui-not-updating-00c1
fix: Custom pipeline columns UI not updating correctly
2026-01-24 19:58:30 +00:00
Shirone
22853c988a Merge pull request #676 from AutoMaker-Org/feature/bug-after-v0-13-0-version-got-merged-some-ui-load-d8lr
fix: Improve spinner visibility on primary-colored backgrounds
2026-01-24 19:58:17 +00:00
Shirone
e52837cbe7 Merge pull request #675 from AutoMaker-Org/feature/bug-fix-the-icon-margin-next-to-green-dot-in-agen-iufz
fix: add proper margin between icon and green dot in auto mode menu item
2026-01-24 19:58:04 +00:00
Shirone
d12e0705f0 Merge pull request #682 from AutoMaker-Org/feature/bug-fix-app-spec-generation-for-non-claude-models-dgq0
fix: Add structured output fallback for non-Claude models in app spec generation
2026-01-24 19:57:48 +00:00
Shirone
a3e536b8e6 test: Update codex provider timeout calculation for feature generation 2026-01-24 20:53:40 +01:00
Shirone
43661e5a6e fix: adress pr comments 2026-01-24 20:41:25 +01:00
Shirone
1b2bf0df3f feat: Extend timeout handling for Codex model feature generation
- Introduced a dedicated 5-minute timeout for Codex models during feature generation to accommodate slower response times when generating 50+ features.
- Updated the CodexProvider to utilize this extended timeout based on the reasoning effort level.
- Enhanced the feature generation logic in generate-features-from-spec.ts to detect Codex models and apply the appropriate timeout.
- Modified the model resolver to include reasoning effort in the resolved phase model structure.

This change improves the reliability of feature generation for Codex models, ensuring they have sufficient time to process requests effectively.
2026-01-24 20:23:34 +01:00
Shirone
b1060c6a11 fix: adress pr comments 2026-01-24 18:45:05 +01:00
Shirone
db87e83aed fix: Address PR feedback for structured output fallback
- Throw error immediately when JSON extraction fails in
  generate-features-from-spec.ts to avoid redundant parsing attempt
  (feedback from Gemini Code Assist review)
- Emit spec_regeneration_error event before throwing for consistency
- Fix TypeScript cast in sync-spec.ts by using double cast through unknown

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 18:34:46 +01:00
Shirone
92b1fb3725 fix: Add structured output fallback for non-Claude models in app spec generation
This fixes the app spec generation failing for non-Claude models (Cursor, Gemini,
OpenCode, Copilot) that don't support structured output capabilities.

Changes:
- Add `supportsStructuredOutput()` utility function in @automaker/types to
  centralize model capability detection
- Update generate-features-from-spec.ts:
  - Add explicit JSON instructions for non-Claude/Codex models
  - Define featuresOutputSchema for structured output
  - Pre-extract JSON from text responses using extractJsonWithArray
  - Handle both structured_output and text responses properly
- Update generate-spec.ts:
  - Replace isCursorModel with supportsStructuredOutput for consistency
- Update sync-spec.ts:
  - Add techStackOutputSchema for structured output
  - Add JSON extraction fallback for text responses
  - Handle both structured_output and text parsing
- Update validate-issue.ts:
  - Use supportsStructuredOutput for cleaner capability detection

The fix follows the same pattern used in generate-spec.ts where non-Claude models
receive explicit JSON formatting instructions in the prompt and responses are
parsed using extractJson utilities.

Fixes #669

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 18:25:39 +01:00
Shirone
d7f86d142a fix: Use onSelect instead of onClick for DropdownMenuItem 2026-01-24 18:22:42 +01:00
Shirone
bbe669cdf2 refactor(worktree-panel): introduce constant for dropdown layout threshold
- Added a constant `WORKTREE_DROPDOWN_THRESHOLD` to define the threshold for switching from tabs to dropdown layout in the WorktreePanel.
- Updated the logic to use this constant for better readability and maintainability of the code.
2026-01-24 18:11:47 +01:00
Shirone
8e13245aab fix(ui): improve worktree panel UI with dropdown for multiple worktrees
Fixes #673

When users have 3+ worktrees, especially with auto-generated long branch
names, the horizontal tab layout would wrap to multiple rows, creating
a cluttered and messy UI. This change introduces a compact dropdown menu
that automatically activates when there are 3 or more worktrees.

Changes:
- Add WorktreeDropdown component for consolidated worktree selection
- Add WorktreeDropdownItem component for individual worktree entries
- Add shared utility functions for indicator styling (PR badges, changes,
  test status) to ensure consistent appearance
- Modify worktree-panel.tsx to switch between tab layout (1-2 worktrees)
  and dropdown layout (3+ worktrees) automatically
- Truncate long branch names with tooltip showing full name
- Maintain all status indicators (dev server, auto mode, PR, changes,
  tests) in both layouts

The dropdown groups worktrees by type (main branch vs feature worktrees)
and provides full integration with branch switching and action dropdowns.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 18:03:59 +01:00
Shirone
cec5f91a86 fix: Complete fix for plan mode system across all providers
Closes #671 (Complete fix for the plan mode system inside automaker)
Related: #619, #627, #531, #660

## Issues Fixed

### 1. Non-Claude Provider Support
- Removed Claude model restriction from planning mode UI selectors
- Added `detectSpecFallback()` function to detect specs without `[SPEC_GENERATED]` marker
- All providers (OpenAI, Gemini, Cursor, etc.) can now use spec and full planning modes
- Fallback detection looks for structural elements: tasks block, acceptance criteria,
  problem statement, implementation plan, etc.

### 2. Crash/Restart Recovery
- Added `resetStuckFeatures()` to clean up transient states on auto-mode start
- Features stuck in `in_progress` are reset to `ready` or `backlog`
- Tasks stuck in `in_progress` are reset to `pending`
- Plan generation stuck in `generating` is reset to `pending`
- `loadPendingFeatures()` now includes recovery cases for interrupted executions
- Persisted task status in `planSpec.tasks` array allows resuming from last completed task

### 3. Spec Todo List UI Updates
- Added `ParsedTask` and `PlanSpec` types to `@automaker/types` for consistent typing
- New `auto_mode_task_status` event emitted when task status changes
- New `auto_mode_summary` event emitted when summary is extracted
- Query invalidation triggers on task status updates for real-time UI refresh
- Task markers (`[TASK_START]`, `[TASK_COMPLETE]`, `[PHASE_COMPLETE]`) are detected
  and persisted to planSpec.tasks for UI display

### 4. Summary Extraction
- Added `extractSummary()` function to parse summaries from multiple formats:
  - `<summary>` tags (explicit)
  - `## Summary` sections (markdown)
  - `**Goal**:` sections (lite mode)
  - `**Problem**:` sections (spec/full modes)
  - `**Solution**:` sections (fallback)
- Summary is saved to `feature.summary` field after execution
- Summary is extracted from plan content during spec generation

### 5. Worktree Mode Support (#619)
- Recovery logic properly handles branchName filtering
- Features in worktrees maintain correct association during recovery

## Files Changed
- libs/types/src/feature.ts - Added ParsedTask and PlanSpec interfaces
- libs/types/src/index.ts - Export new types
- apps/server/src/services/auto-mode-service.ts - Core fixes for all issues
- apps/server/tests/unit/services/auto-mode-task-parsing.test.ts - New tests
- apps/ui/src/store/app-store.ts - Import types from @automaker/types
- apps/ui/src/hooks/use-auto-mode.ts - Handle new events
- apps/ui/src/hooks/use-query-invalidation.ts - Invalidate on task updates
- apps/ui/src/types/electron.d.ts - New event type definitions
- apps/ui/src/components/views/board-view/dialogs/*.tsx - Enable planning for all models

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 17:58:04 +01:00
Shirone
ed92d4fd80 refactor: Extract invalidation events to constants 2026-01-24 15:56:35 +01:00
Shirone
a6190f71b3 refactor: Use Set for button variant lookup and improve undefined handling 2026-01-24 15:48:46 +01:00
Shirone
d04934359a fix: Invalidate all features query on pipeline_step_started event
When a pipeline step starts, the feature status changes to the pipeline
column status. Previously, only the single feature query was invalidated,
but the Kanban board uses the all features query for column grouping.

This caused the UI to not immediately reflect features moving to custom
pipeline columns - updates would only appear after the first pipeline
step completed.

Fixes #668

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:44:39 +01:00
Shirone
7246debb69 feat: Aggregate running auto tasks across all worktrees in BoardView
- Introduced a new memoized function to collect running auto tasks from all worktrees associated with the current project.
- Updated the WorktreeTab component to utilize the aggregated running tasks for improved task management visibility.
- Enhanced spinner visibility by applying a variant based on the selected state, ensuring better UI feedback during loading states.
2026-01-24 15:44:38 +01:00
Shirone
066ffe5639 fix: Improve spinner visibility on primary-colored backgrounds
Add variant prop to Spinner component to support different color contexts:
- 'primary' (default): Uses text-primary for standard backgrounds
- 'foreground': Uses text-primary-foreground for primary backgrounds
- 'muted': Uses text-muted-foreground for subtle contexts

Updated components where spinners were invisible against primary backgrounds:
- TaskProgressPanel: Active task indicators now visible
- Button: Auto-detects spinner variant based on button style
- Various dialogs and setup views using buttons with loaders

Fixes #670

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:26:47 +01:00
Shirone
7bf02b64fa fix: add proper margin between icon and green dot in auto mode menu item
Fixes #672

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:06:14 +01:00
Monoquark
a3c62e8358 docs: Add docstrings for ideation route handler and view components 2026-01-24 13:30:09 +01:00
Monoquark
1ecb97b71c docs: Add docstrings for ideation context settings 2026-01-24 13:13:11 +01:00
Monoquark
1e87b73dfd refactor: Remove redundant count normalization in suggestion parsing
- Removed the suggestionCount variable that was re-clamping the count parameter
- Removed default values from function parameters (count: number = 10 → count: number)
2026-01-24 13:01:48 +01:00
Monoquark
5a3dac1533 feat: Add ideation context settings
- Add settings popover to the ideation view
- Migrate previous context to toggles (memory, context, features, ideas)
- Add app specifications as new context option
2026-01-24 12:30:20 +01:00
ruant
f3b16ad8ce revert: fix not needed 2026-01-23 19:43:30 +01:00
ruant
140c444e6f fix: typo 🤦‍♂️ 2026-01-23 19:38:38 +01:00
ruant
907c1d65b3 fix(deps): add missing zod dependency 2026-01-23 19:30:57 +01:00
ruant
92f2702f3b fix(build): add missing "npm run build" in build script 2026-01-23 19:30:36 +01:00
ruant
735786701f fix(docker): add missing copy of spec-parser in docker 2026-01-23 19:29:46 +01:00
webdevcody
900bbb5e80 Merge branch 'v0.14.0rc' of github.com:AutoMaker-Org/automaker into v0.14.0rc 2026-01-23 12:57:46 -05:00
webdevcody
bc3e3dad1c splash screen configurable in global settings 2026-01-23 12:55:01 -05:00
Shirone
d8fa5c4cd1 feat: Add commit step template for conventional commits
- Introduced a new pipeline step template for committing changes, emphasizing the use of conventional commit format.
- The template includes detailed instructions for reviewing changes, creating a commit message, and executing the git commit command.
- Ensures that all commits follow a consistent pattern for better changelog generation and project management.
- Updated the index to include the new commit template in the pipeline step templates.
2026-01-23 18:34:11 +01:00
Shirone
f005c30017 feat: Enhance sidebar navigation with collapsible sections and state management
- Added support for collapsible navigation sections in the sidebar, allowing users to expand or collapse sections based on their preferences.
- Integrated the collapsed state management into the app store for persistence across sessions.
- Updated the sidebar component to conditionally render the header based on the selected sidebar style.
- Ensured synchronization of collapsed section states with user settings for a consistent experience.
2026-01-23 16:47:32 +01:00
Shirone
4012a2964a feat: Add sidebar style options to appearance settings
- Introduced a new section in the Appearance settings to allow users to choose between 'unified' and 'discord' sidebar layouts.
- Updated the app state and settings migration to include the new sidebarStyle property.
- Enhanced the UI to reflect the selected sidebar style with appropriate visual feedback.
- Ensured synchronization of sidebar style settings across the application.
2026-01-23 16:34:44 +01:00
Stefan de Vogelaere
0b92349890 feat: Add GitHub Copilot SDK provider integration (#661)
* feat: add GitHub Copilot SDK provider integration

Adds comprehensive GitHub Copilot SDK provider support including:
- CopilotProvider class with CLI detection and OAuth authentication check
- Copilot models definition with GPT-4o, Claude, and o1/o3 series models
- Settings UI integration with provider tab, model configuration, and navigation
- Onboarding flow integration with Copilot setup step
- Model selector integration for all phase-specific model dropdowns
- Persistence of enabled models and default model settings via API sync
- Server route for Copilot CLI status endpoint

https://claude.ai/code/session_01D26w7ZyEzP4H6Dor3ttk9d

* chore: update package-lock.json

https://claude.ai/code/session_01D26w7ZyEzP4H6Dor3ttk9d

* refactor: rename Copilot SDK to Copilot CLI and use GitHub icon

- Update all references from "GitHub Copilot SDK" to "GitHub Copilot CLI"
- Change install command from @github/copilot-sdk to @github/copilot
- Update CopilotIcon to use official GitHub Octocat logo
- Update error codes and comments throughout codebase

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

* fix: update Copilot model definitions and add dynamic model discovery

- Update COPILOT_MODEL_MAP with correct models from CLI (claude-sonnet-4.5,
  claude-haiku-4.5, claude-opus-4.5, claude-sonnet-4, gpt-5.x series, gpt-4.1,
  gemini-3-pro-preview)
- Change default Copilot model to copilot-claude-sonnet-4.5
- Add model caching methods to CopilotProvider (hasCachedModels,
  clearModelCache, refreshModels)
- Add API routes for dynamic model discovery:
  - GET /api/setup/copilot/models
  - POST /api/setup/copilot/models/refresh
  - POST /api/setup/copilot/cache/clear

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

* refactor: use @github/copilot-sdk instead of direct CLI calls

- Install @github/copilot-sdk package for proper SDK integration
- Rewrite CopilotProvider to use SDK's CopilotClient API
- Use client.createSession() for session management
- Handle SDK events (assistant.message, tool.execution_*, session.idle)
- Auto-approve permissions for autonomous agent operation
- Remove incorrect CLI flags (--mode, --output-format)
- Update default model to claude-sonnet-4.5

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

* fix: add Copilot and Gemini model support to model resolver

- Import isCopilotModel and isGeminiModel from types
- Add explicit checks for copilot- and gemini- prefixed models
- Pass through Copilot/Gemini models unchanged to their providers
- Update resolver documentation to list all supported providers

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

* fix: pass working directory to Copilot SDK and reduce event noise

- Create CopilotClient per execution with correct cwd from options.cwd
- This ensures the CLI operates in the correct project directory, not the
  server's current directory
- Skip assistant.message_delta events (they create excessive noise)
- Only yield the final assistant.message event which has complete content
- Clean up client on completion and error paths

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

* fix: simplify Copilot SDK execution with sendAndWait

- Use sendAndWait() instead of manual event polling for more reliable
  execution
- Disable streaming (streaming: false) to simplify response handling
- Increase timeout to 10 minutes for agentic operations
- Still capture tool execution events for UI display
- Add more debug logging for troubleshooting
- This should fix the "invalid_request_body" error on subsequent calls

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

* fix: allow Copilot model IDs with claude-, gemini-, gpt- prefixes

Copilot's bare model IDs legitimately contain prefixes like claude-,
gemini-, gpt- because those are the actual model names from the
Copilot CLI (e.g., claude-sonnet-4.5, gemini-3-pro-preview, gpt-5.1).

The generic validateBareModelId function was incorrectly rejecting
these valid model IDs. Now we only check that the copilot- prefix
has been stripped by the ProviderFactory.

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

* feat: enable real-time streaming of tool events for Copilot

- Switch back to streaming mode (streaming: true) for real-time events
- Use async queue pattern to bridge SDK callbacks to async generator
- Events are now yielded as they happen, not batched at the end
- Tool calls (Read, Write, Edit, Bash, TodoWrite, etc.) show in real-time
- Better progress visibility during agentic operations

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

* feat: expand Copilot tool name and input normalization

Tool name mapping additions:
- view → Read (Copilot's file viewing tool)
- create_file → Write
- replace, patch → Edit
- run_shell_command, terminal → Bash
- search_file_content → Grep
- list_directory → Ls
- google_web_search → WebSearch
- report_intent → ReportIntent (Copilot-specific planning)
- think, plan → Think, Plan

Input normalization improvements:
- Read/Write/Edit: Map file, filename, filePath → file_path
- Bash: Map cmd, script → command
- Grep: Map query, search, regex → pattern

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

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

The @electron/node-gyp dependency was resolved with a git+ssh URL
which fails in CI environments without SSH keys. Convert to HTTPS.

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

* fix: address code review feedback for Copilot SDK provider

- Add guard for non-text prompts (vision not yet supported)
- Clear runtime model cache on fetch failure
- Fix race condition in async queue error handling
- Import CopilotAuthStatus from shared types
- Fix comment mismatch for default model constant
- Add auth-copilot and deauth-copilot routes
- Extract shared tool normalization utilities
- Create base model configuration UI component
- Add comprehensive unit tests for CopilotProvider
- Replace magic strings with constants
- Add debug logging for cleanup errors

* fix: address CodeRabbit review nitpicks

- Fix test mocks to include --version check for CLI detection
- Add aria-label for accessibility on refresh button
- Ensure default model checkbox always appears checked/enabled

* fix: address CodeRabbit review feedback

- Fix test mocks by creating fresh provider instances after mock setup
- Extract COPILOT_DISCONNECTED_MARKER_FILE constant to common.ts
- Add AUTONOMOUS MODE comment explaining auto-approval of permissions
- Improve tool-normalization with union types and null guards
- Handle 'canceled' (American spelling) status in todo normalization

* refactor: extract copilot connection logic to service and fix test mocks

- Create copilot-connection-service.ts with connect/disconnect logic
- Update auth-copilot and deauth-copilot routes to use service
- Fix test mocks for CLI detection:
  - Mock fs.existsSync for CLI path validation
  - Mock which/where command for CLI path detection

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-23 14:48:33 +01:00
DhanushSantosh
51a75ae589 ci: fail release upload when installers missing 2026-01-23 18:31:47 +05:30
DhanushSantosh
650edd69ca ci: ensure release installers are uploaded as GitHub assets 2026-01-23 18:30:38 +05:30
DhanushSantosh
46abd34444 fix: base usage indicator on provider window limits 2026-01-23 18:23:08 +05:30
DhanushSantosh
5cf817e9de fix: drive usage indicator by active provider window 2026-01-23 18:21:15 +05:30
DhanushSantosh
42ee4f211d fix: prefer Claude session usage in header indicator 2026-01-23 18:19:21 +05:30
DhanushSantosh
372cfe6982 fix: move session window hint to usage indicator 2026-01-23 18:17:18 +05:30
DhanushSantosh
1430fb6926 feat: use official Gemini icon asset 2026-01-23 18:08:08 +05:30
DhanushSantosh
9e15f3609a fix: show session usage window on board usage button 2026-01-23 18:08:08 +05:30
DhanushSantosh
b34ffd9565 feat: update Gemini icon svg to official sparkle 2026-01-23 18:03:04 +05:30
DhanushSantosh
ac9f33bd2b feat: use official Gemini icon asset 2026-01-23 18:01:51 +05:30
DhanushSantosh
269b1c9478 fix: show session usage window on board usage button 2026-01-23 18:01:24 +05:30
DhanushSantosh
7bc7918cc6 fix: show session usage window on board usage button 2026-01-23 17:57:35 +05:30
DhanushSantosh
860d6836b9 feat: use official Gemini gradient colors for provider icon 2026-01-23 17:49:00 +05:30
DhanushSantosh
5281b81ddf fix: change usage card to show 'Session' with '5h window' subtitle
Updated the first usage card in the popover to correctly label the
session-based usage as 'Session' with '5h window' subtitle instead of
'Weekly' with 'All models', accurately reflecting Claude's 5-hour
rolling window rate limit.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-23 17:44:25 +05:30
Shirone
7a33940816 Merge pull request #644 from AutoMaker-Org/feature/v0.14.0rc-1768981697539-gg62
feat: projects overview dashboard
2026-01-23 09:13:31 +00:00
Shirone
ee4464bdad fix: update feature status counting and enhance overview handler
- Change 'waiting_approval' status to 'in_progress' and add handling for 'waiting_approval' as pending.
- Introduce counting of live running features per project in the overview handler by fetching all running agents.
- Ensure accurate representation of project statuses in the overview.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:10:37 +01:00
Stefan de Vogelaere
7e1095b773 fix(ui): address PR review comments for overview view
- Fix handleOpenProject dependency array to include initializeAndOpenProject
- Use upsertAndSetCurrentProject instead of separate addProject + setCurrentProject
- Reorder callback declarations to avoid use-before-definition
- Update E2E tests to match renamed Dashboard UI (was "Projects Overview")
- Add success: true to mock API responses for proper test functioning
- Fix strict mode violation in project status card test

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 02:57:16 +01:00
Stefan de Vogelaere
9d297c650a fix: address PR #644 review comments
- Add 'waiting' status to computeHealthStatus for projects with pending
  features but no active execution (fixes status never being emitted)
- Fix undefined RunningAgentInfo type to use local RunningAgent interface

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 02:40:24 +01:00
Stefan de Vogelaere
68d78f2f5b fix(ui): verify initializeProject succeeds before mutating state
Check the return value of initializeProject in all three create handlers
(handleCreateBlankProject, handleCreateFromTemplate, handleCreateFromCustomUrl)
and return early with an error toast if initialization fails, preventing
addProject/setCurrentProject/navigate from being called on failure.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 02:37:10 +01:00
Stefan de Vogelaere
fb6d6bbf2f fix(ui): address PR #644 review comments
Keyboard accessibility:
- Add role="button", tabIndex, onKeyDown, and aria-label to clickable divs
  in project-status-card, recent-activity-feed, and running-agents-panel

Bug fixes:
- Fix handleActivityClick to use projectPath instead of projectId for
  initializeProject and check result before navigating
- Fix error handling in use-multi-project-status to use data.error string
  directly instead of data.error?.message

Improvements:
- Use GitBranch icon instead of Folder for branch display in running-agents-panel
- Add error logging for failed project loads in overview.ts
- Use type import for FeatureLoader in projects/index.ts
- Add data-testid to mobile Overview button in dashboard-view
- Add locale options for consistent time formatting in overview-view

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 02:26:57 +01:00
Stefan de Vogelaere
c8ed3fafce feat(ui): add New Project and Open Project buttons to Dashboard header
Add project creation and folder opening functionality directly to the
Dashboard view header for quick access. Includes NewProjectModal with
template support and WorkspacePickerModal integration.

Also renamed title to "Automaker Dashboard".

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 02:18:36 +01:00
Stefan de Vogelaere
5939c5d20b fix(ui): rename Dashboard title to Auto-Maker Dashboard
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 02:15:26 +01:00
Stefan de Vogelaere
ad6fc01045 refactor(ui): simplify Dashboard view header
Remove back button and rename title from "Projects Overview" to "Dashboard"
since the overview is now the main dashboard accessed via sidebar.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 02:15:15 +01:00
Stefan de Vogelaere
ea34f304cb refactor(ui): consolidate Dashboard to link to projects overview
Replace separate Dashboard and Projects Overview nav items with a single
Dashboard item that links to /overview. Update all logo click handlers
to navigate to /overview for consistency.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 02:14:32 +01:00
Stefan de Vogelaere
53ad78dfc8 Merge remote-tracking branch 'upstream/feature/v0.14.0rc-1768981697539-gg62' into feature/unified-sidebar
# Conflicts:
#	apps/server/src/index.ts
#	apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx
#	libs/types/src/index.ts
2026-01-23 02:10:48 +01:00
Stefan de Vogelaere
26b819291f Merge remote-tracking branch 'upstream/v0.14.0rc' into feature/unified-sidebar 2026-01-23 02:09:32 +01: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
a4214276d7 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>
2026-01-23 01:58:15 +01:00
Stefan de Vogelaere
d09da4af20 Merge remote-tracking branch 'upstream/v0.14.0rc' into feature/unified-sidebar 2026-01-23 01:47:05 +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
Stefan de Vogelaere
433e6016c3 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
2026-01-22 18:52:30 +01:00
Stefan de Vogelaere
02dfda108e 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
2026-01-22 18:08:02 +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
c55654b737 feat(ui): add Projects Overview link and button to sidebar and dashboard
- Introduced a new Projects Overview link in the sidebar footer for easy navigation.
- Added a button for Projects Overview in the dashboard view, enhancing accessibility to project insights.
- Updated types to include project overview-related definitions, supporting the new features.
2026-01-21 13:15:24 +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
firstfloris
927ce9121d fix: include libs directory in Electron build extraResources
The @automaker/* packages in server-bundle/node_modules are symlinks
pointing to ../../libs/. Without including the libs directory in
extraResources, these symlinks are broken in the packaged app,
causing 'Server failed to start' error on launch.
2025-12-28 20:09:48 +01:00
366 changed files with 32264 additions and 10719 deletions

View File

@@ -4,6 +4,9 @@ on:
release:
types: [published]
permissions:
contents: write
jobs:
build:
strategy:
@@ -62,7 +65,10 @@ 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
if-no-files-found: error
retention-days: 30
- name: Upload Windows artifacts
@@ -71,6 +77,7 @@ jobs:
with:
name: windows-builds
path: apps/ui/release/*.exe
if-no-files-found: error
retention-days: 30
- name: Upload Linux artifacts
@@ -78,7 +85,11 @@ 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
if-no-files-found: error
retention-days: 30
upload:
@@ -108,9 +119,13 @@ jobs:
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
fail_on_unmatched_files: true
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/windows-builds/*.exe
artifacts/linux-builds/*.AppImage
artifacts/linux-builds/*.deb
artifacts/linux-builds/*.rpm
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -25,9 +25,11 @@ COPY libs/types/package*.json ./libs/types/
COPY libs/utils/package*.json ./libs/utils/
COPY libs/prompts/package*.json ./libs/prompts/
COPY libs/platform/package*.json ./libs/platform/
COPY libs/spec-parser/package*.json ./libs/spec-parser/
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

@@ -1,300 +0,0 @@
# Security Audit Findings - v0.13.0rc Branch
**Date:** $(date)
**Audit Type:** Git diff security review against v0.13.0rc branch
**Status:** ⚠️ Security vulnerabilities found - requires fixes before release
## Executive Summary
No intentionally malicious code was detected in the changes. However, several **critical security vulnerabilities** were identified that could allow command injection attacks. These must be fixed before release.
---
## 🔴 Critical Security Issues
### 1. Command Injection in Merge Handler
**File:** `apps/server/src/routes/worktree/routes/merge.ts`
**Lines:** 43, 54, 65-66, 93
**Severity:** CRITICAL
**Issue:**
User-controlled inputs (`branchName`, `mergeTo`, `options?.message`) are directly interpolated into shell commands without validation, allowing command injection attacks.
**Vulnerable Code:**
```typescript
// Line 43 - branchName not validated
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
// Line 54 - mergeTo not validated
await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath });
// Lines 65-66 - branchName and message not validated
const mergeCmd = options?.squash
? `git merge --squash ${branchName}`
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`;
// Line 93 - message not sanitized
await execAsync(`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, {
cwd: projectPath,
});
```
**Attack Vector:**
An attacker could inject shell commands via branch names or commit messages:
- Branch name: `main; rm -rf /`
- Commit message: `"; malicious_command; "`
**Fix Required:**
1. Validate `branchName` and `mergeTo` using `isValidBranchName()` before use
2. Sanitize commit messages or use `execGitCommand` with proper escaping
3. Replace `execAsync` template literals with `execGitCommand` array-based calls
**Note:** `isValidBranchName` is imported but only used AFTER deletion (line 119), not before execAsync calls.
---
### 2. Command Injection in Push Handler
**File:** `apps/server/src/routes/worktree/routes/push.ts`
**Lines:** 44, 49
**Severity:** CRITICAL
**Issue:**
User-controlled `remote` parameter and `branchName` are directly interpolated into shell commands without validation.
**Vulnerable Code:**
```typescript
// Line 38 - remote defaults to 'origin' but not validated
const targetRemote = remote || 'origin';
// Lines 44, 49 - targetRemote and branchName not validated
await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, {
cwd: worktreePath,
});
await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, {
cwd: worktreePath,
});
```
**Attack Vector:**
An attacker could inject commands via the remote name:
- Remote: `origin; malicious_command; #`
**Fix Required:**
1. Validate `targetRemote` parameter (alphanumeric + `-`, `_` only)
2. Validate `branchName` before use (even though it comes from git output)
3. Use `execGitCommand` with array arguments instead of template literals
---
### 3. Unsafe Environment Variable Export in Shell Script
**File:** `start-automaker.sh`
**Lines:** 5068, 5085
**Severity:** CRITICAL
**Issue:**
Unsafe parsing and export of `.env` file contents using `xargs` without proper handling of special characters.
**Vulnerable Code:**
```bash
export $(grep -v '^#' .env | xargs)
```
**Attack Vector:**
If `.env` file contains malicious content with spaces, special characters, or code, it could be executed:
- `.env` entry: `VAR="value; malicious_command"`
- Could lead to code execution during startup
**Fix Required:**
Replace with safer parsing method:
```bash
# Safer approach
set -a
source <(grep -v '^#' .env | sed 's/^/export /')
set +a
# Or even safer - validate each line
while IFS= read -r line; do
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "$line" ]] && continue
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
export "${BASH_REMATCH[1]}"="${BASH_REMATCH[2]}"
fi
done < .env
```
---
## 🟡 Moderate Security Concerns
### 4. Inconsistent Use of Secure Command Execution
**Issue:**
The codebase has `execGitCommand()` function available (which uses array arguments and is safer), but it's not consistently used. Some places still use `execAsync` with template literals.
**Files Affected:**
- `apps/server/src/routes/worktree/routes/merge.ts`
- `apps/server/src/routes/worktree/routes/push.ts`
**Recommendation:**
- Audit all `execAsync` calls with template literals
- Replace with `execGitCommand` where possible
- Document when `execAsync` is acceptable (only with fully validated inputs)
---
### 5. Missing Input Validation
**Issues:**
1. `targetRemote` in `push.ts` defaults to 'origin' but isn't validated
2. Commit messages in `merge.ts` aren't sanitized before use in shell commands
3. `worktreePath` validation relies on middleware but should be double-checked
**Recommendation:**
- Add validation functions for remote names
- Sanitize commit messages (remove shell metacharacters)
- Add defensive validation even when middleware exists
---
## ✅ Positive Security Findings
1. **No Hardcoded Credentials:** No API keys, passwords, or tokens found in the diff
2. **No Data Exfiltration:** No suspicious network requests or data transmission patterns
3. **No Backdoors:** No hidden functionality or unauthorized access patterns detected
4. **Safe Command Execution:** `execGitCommand` function properly uses array arguments in some places
5. **Environment Variable Handling:** `init-script-service.ts` properly sanitizes environment variables (lines 194-220)
---
## 📋 Action Items
### Immediate (Before Release)
- [ ] **Fix command injection in `merge.ts`**
- [ ] Validate `branchName` with `isValidBranchName()` before line 43
- [ ] Validate `mergeTo` with `isValidBranchName()` before line 54
- [ ] Sanitize commit messages or use `execGitCommand` for merge commands
- [ ] Replace `execAsync` template literals with `execGitCommand` array calls
- [ ] **Fix command injection in `push.ts`**
- [ ] Add validation function for remote names
- [ ] Validate `targetRemote` before use
- [ ] Validate `branchName` before use (defensive programming)
- [ ] Replace `execAsync` template literals with `execGitCommand`
- [ ] **Fix shell script security issue**
- [ ] Replace unsafe `export $(grep ... | xargs)` with safer parsing
- [ ] Add validation for `.env` file contents
- [ ] Test with edge cases (spaces, special chars, quotes)
### Short-term (Next Sprint)
- [ ] **Audit all `execAsync` calls**
- [ ] Create inventory of all `execAsync` calls with template literals
- [ ] Replace with `execGitCommand` where possible
- [ ] Document exceptions and why they're safe
- [ ] **Add input validation utilities**
- [ ] Create `isValidRemoteName()` function
- [ ] Create `sanitizeCommitMessage()` function
- [ ] Add validation for all user-controlled inputs
- [ ] **Security testing**
- [ ] Add unit tests for command injection prevention
- [ ] Add integration tests with malicious inputs
- [ ] Test shell script with malicious `.env` files
### Long-term (Security Hardening)
- [ ] **Code review process**
- [ ] Add security checklist for PR reviews
- [ ] Require security review for shell command execution changes
- [ ] Add automated security scanning
- [ ] **Documentation**
- [ ] Document secure coding practices for shell commands
- [ ] Create security guidelines for contributors
- [ ] Add security section to CONTRIBUTING.md
---
## 🔍 Testing Recommendations
### Command Injection Tests
```typescript
// Test cases for merge.ts
describe('merge handler security', () => {
it('should reject branch names with shell metacharacters', () => {
// Test: branchName = "main; rm -rf /"
// Expected: Validation error, command not executed
});
it('should sanitize commit messages', () => {
// Test: message = '"; malicious_command; "'
// Expected: Sanitized or rejected
});
});
// Test cases for push.ts
describe('push handler security', () => {
it('should reject remote names with shell metacharacters', () => {
// Test: remote = "origin; malicious_command; #"
// Expected: Validation error, command not executed
});
});
```
### Shell Script Tests
```bash
# Test with malicious .env content
echo 'VAR="value; echo PWNED"' > test.env
# Expected: Should not execute the command
# Test with spaces in values
echo 'VAR="value with spaces"' > test.env
# Expected: Should handle correctly
# Test with special characters
echo 'VAR="value\$with\$dollars"' > test.env
# Expected: Should handle correctly
```
---
## 📚 References
- [OWASP Command Injection](https://owasp.org/www-community/attacks/Command_Injection)
- [Node.js Child Process Security](https://nodejs.org/api/child_process.html#child_process_security_concerns)
- [Shell Script Security Best Practices](https://mywiki.wooledge.org/BashGuide/Practices)
---
## Notes
- All findings are based on code diff analysis
- No runtime testing was performed
- Assumes attacker has access to API endpoints (authenticated or unauthenticated)
- Fixes should be tested thoroughly before deployment
---
**Last Updated:** $(date)
**Next Review:** After fixes are implemented

25
TODO.md
View File

@@ -1,25 +0,0 @@
# Bugs
- Setting the default model does not seem like it works.
# Performance (completed)
- [x] Graph performance mode for large graphs (compact nodes/edges + visible-only rendering)
- [x] Render containment on heavy scroll regions (kanban columns, chat history)
- [x] Reduce blur/shadow effects when lists get large
- [x] React Query tuning for heavy datasets (less refetch on focus/reconnect)
- [x] DnD/list rendering optimizations (virtualized kanban + memoized card sections)
# UX
- Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff
- Simplify the create feature modal. It should just be one page. I don't need nessa tabs and all these nested buttons. It's too complex.
- added to do's list checkbox directly into the card so as it's going through if there's any to do items we can see those update live
- When the feature is done, I want to see a summary of the LLM. That's the first thing I should see when I double click the card.
- I went away to mass edit all my features. For example, when I created a new project, it added auto testing on every single feature card. Now I have to manually go through one by one and change those. Have a way to mass edit those, the configuration of all them.
- Double check and debug if there's memory leaks. It seems like the memory of automaker grows like 3 gigabytes. It's 5gb right now and I'm running three different cursor cli features implementing at the same time.
- Typing in the text area of the plan mode was super laggy.
- When I have a bunch of features running at the same time, it seems like I cannot edit the features in the backlog. Like they don't persist their file changes and I think this is because of the secure FS file has an internal queue to prevent hitting that file open write limit. We may have to reconsider refactoring away from file system and do Postgres or SQLite or something.
- modals are not scrollable if height of the screen is small enough
- and the Agent Runner add an archival button for the new sessions.
- investigate a potential issue with the feature cards not refreshing. I see a lock icon on the feature card But it doesn't go away until I open the card and edit it and I turn the testing mode off. I think there's like a refresh sync issue.

View File

@@ -32,6 +32,7 @@
"@automaker/prompts": "1.0.0",
"@automaker/types": "1.0.0",
"@automaker/utils": "1.0.0",
"@github/copilot-sdk": "^0.1.16",
"@modelcontextprotocol/sdk": "1.25.2",
"@openai/codex-sdk": "^0.77.0",
"cookie-parser": "1.4.7",
@@ -40,7 +41,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

@@ -16,7 +16,7 @@ import { createServer } from 'http';
import dotenv from 'dotenv';
import { createEventEmitter, type EventEmitter } from './lib/events.js';
import { initAllowedPaths } from '@automaker/platform';
import { initAllowedPaths, getClaudeAuthIndicators } from '@automaker/platform';
import { createLogger, setLogLevel, LogLevel } from '@automaker/utils';
const logger = createLogger('Server');
@@ -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,8 @@ 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 { createProjectsRoutes } from './routes/projects/index.js';
// Load environment variables
dotenv.config();
@@ -116,15 +117,44 @@ export function isRequestLoggingEnabled(): boolean {
// Width for log box content (excluding borders)
const BOX_CONTENT_WIDTH = 67;
// Check for required environment variables
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
// Check for Claude authentication (async - runs in background)
// The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication
(async () => {
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
if (!hasAnthropicKey) {
if (hasAnthropicKey) {
logger.info('✓ ANTHROPIC_API_KEY detected');
return;
}
// Check for Claude Code CLI authentication
try {
const indicators = await getClaudeAuthIndicators();
const hasCliAuth =
indicators.hasStatsCacheWithActivity ||
(indicators.hasSettingsFile && indicators.hasProjectsSessions) ||
(indicators.hasCredentialsFile &&
(indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey));
if (hasCliAuth) {
logger.info('✓ Claude Code CLI authentication detected');
return;
}
} catch (error) {
// Ignore errors checking CLI auth - will fall through to warning
logger.warn('Error checking for Claude Code CLI authentication:', error);
}
// No authentication found - show warning
const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH);
const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH);
const w2 = 'Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH);
const w3 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH);
const w4 = 'Or use the setup wizard in Settings to configure authentication.'.padEnd(
const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH);
const w3 = '1. Install Claude Code CLI and authenticate with subscription'.padEnd(
BOX_CONTENT_WIDTH
);
const w4 = '2. Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH);
const w5 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH);
const w6 = '3. Use the setup wizard in Settings to configure authentication.'.padEnd(
BOX_CONTENT_WIDTH
);
@@ -137,14 +167,13 @@ if (!hasAnthropicKey) {
║ ║
${w2}
${w3}
║ ║
${w4}
${w5}
${w6}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
`);
} else {
logger.info('✓ ANTHROPIC_API_KEY detected');
}
})();
// Initialize security
initAllowedPaths();
@@ -248,6 +277,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);
@@ -321,12 +354,14 @@ app.get('/api/health/detailed', createDetailedHandler());
app.use('/api/fs', createFsRoutes(events));
app.use('/api/agent', createAgentRoutes(agentService, events));
app.use('/api/sessions', createSessionsRoutes(agentService));
app.use('/api/features', createFeaturesRoutes(featureLoader, settingsService, events));
app.use(
'/api/features',
createFeaturesRoutes(featureLoader, settingsService, events, autoModeService)
);
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 +379,10 @@ 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/projects',
createProjectsRoutes(featureLoader, autoModeService, settingsService, notificationService)
);
// Create HTTP server
const server = createServer(app);
@@ -761,21 +800,36 @@ process.on('uncaughtException', (error: Error) => {
process.exit(1);
});
// Graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM received, shutting down...');
// Graceful shutdown timeout (30 seconds)
const SHUTDOWN_TIMEOUT_MS = 30000;
// Graceful shutdown helper
const gracefulShutdown = async (signal: string) => {
logger.info(`${signal} received, shutting down...`);
// Set up a force-exit timeout to prevent hanging
const forceExitTimeout = setTimeout(() => {
logger.error(`Shutdown timed out after ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit`);
process.exit(1);
}, SHUTDOWN_TIMEOUT_MS);
// Mark all running features as interrupted before shutdown
// This ensures they can be resumed when the server restarts
// Note: markAllRunningFeaturesInterrupted handles errors internally and never rejects
await autoModeService.markAllRunningFeaturesInterrupted(`${signal} signal received`);
terminalService.cleanup();
server.close(() => {
clearTimeout(forceExitTimeout);
logger.info('Server closed');
process.exit(0);
});
};
process.on('SIGTERM', () => {
gracefulShutdown('SIGTERM');
});
process.on('SIGINT', () => {
logger.info('SIGINT received, shutting down...');
terminalService.cleanup();
server.close(() => {
logger.info('Server closed');
process.exit(0);
});
gracefulShutdown('SIGINT');
});

View File

@@ -98,9 +98,14 @@ const TEXT_ENCODING = 'utf-8';
* This is the "no output" timeout - if the CLI doesn't produce any JSONL output
* for this duration, the process is killed. For reasoning models with high
* reasoning effort, this timeout is dynamically extended via calculateReasoningTimeout().
*
* For feature generation (which can generate 50+ features), we use a much longer
* base timeout (5 minutes) since Codex models are slower at generating large JSON responses.
*
* @see calculateReasoningTimeout from @automaker/types
*/
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
const CONTEXT_WINDOW_256K = 256000;
const MAX_OUTPUT_32K = 32000;
const MAX_OUTPUT_16K = 16000;
@@ -827,7 +832,14 @@ export class CodexProvider extends BaseProvider {
// Higher reasoning effort (e.g., 'xhigh' for "xtra thinking" mode) requires more time
// for the model to generate reasoning tokens before producing output.
// This fixes GitHub issue #530 where features would get stuck with reasoning models.
const timeout = calculateReasoningTimeout(options.reasoningEffort, CODEX_CLI_TIMEOUT_MS);
//
// For feature generation with 'xhigh', use the extended 5-minute base timeout
// since generating 50+ features takes significantly longer than normal operations.
const baseTimeout =
options.reasoningEffort === 'xhigh'
? CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS
: CODEX_CLI_TIMEOUT_MS;
const timeout = calculateReasoningTimeout(options.reasoningEffort, baseTimeout);
const stream = spawnJSONLProcess({
command: commandPath,

View File

@@ -0,0 +1,942 @@
/**
* Copilot Provider - Executes queries using the GitHub Copilot SDK
*
* Uses the official @github/copilot-sdk for:
* - Session management and streaming responses
* - GitHub OAuth authentication (via gh CLI)
* - Tool call handling and permission management
* - Runtime model discovery
*
* Based on https://github.com/github/copilot-sdk
*/
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,
} from './types.js';
// Note: validateBareModelId is not used because Copilot's bare model IDs
// legitimately contain prefixes like claude-, gemini-, gpt-
import {
COPILOT_MODEL_MAP,
type CopilotAuthStatus,
type CopilotRuntimeModel,
} from '@automaker/types';
import { createLogger, isAbortError } from '@automaker/utils';
import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk';
import {
normalizeTodos,
normalizeFilePathInput,
normalizeCommandInput,
normalizePatternInput,
} from './tool-normalization.js';
// Create logger for this module
const logger = createLogger('CopilotProvider');
// Default bare model (without copilot- prefix) for SDK calls
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.5';
// =============================================================================
// SDK Event Types (from @github/copilot-sdk)
// =============================================================================
/**
* SDK session event data types
*/
interface SdkEvent {
type: string;
data?: unknown;
}
interface SdkMessageEvent extends SdkEvent {
type: 'assistant.message';
data: {
content: string;
};
}
// Note: SdkMessageDeltaEvent is not used - we skip delta events to reduce noise
// The final assistant.message event contains the complete content
interface SdkToolExecutionStartEvent extends SdkEvent {
type: 'tool.execution_start';
data: {
toolName: string;
toolCallId: string;
input?: Record<string, unknown>;
};
}
interface SdkToolExecutionEndEvent extends SdkEvent {
type: 'tool.execution_end';
data: {
toolName: string;
toolCallId: string;
result?: string;
error?: string;
};
}
interface SdkSessionIdleEvent extends SdkEvent {
type: 'session.idle';
}
interface SdkSessionErrorEvent extends SdkEvent {
type: 'session.error';
data: {
message: string;
code?: string;
};
}
// =============================================================================
// Error Codes
// =============================================================================
export enum CopilotErrorCode {
NOT_INSTALLED = 'COPILOT_NOT_INSTALLED',
NOT_AUTHENTICATED = 'COPILOT_NOT_AUTHENTICATED',
RATE_LIMITED = 'COPILOT_RATE_LIMITED',
MODEL_UNAVAILABLE = 'COPILOT_MODEL_UNAVAILABLE',
NETWORK_ERROR = 'COPILOT_NETWORK_ERROR',
PROCESS_CRASHED = 'COPILOT_PROCESS_CRASHED',
TIMEOUT = 'COPILOT_TIMEOUT',
CLI_ERROR = 'COPILOT_CLI_ERROR',
SDK_ERROR = 'COPILOT_SDK_ERROR',
UNKNOWN = 'COPILOT_UNKNOWN_ERROR',
}
export interface CopilotError extends Error {
code: CopilotErrorCode;
recoverable: boolean;
suggestion?: string;
}
// =============================================================================
// Tool Name Normalization
// =============================================================================
/**
* Copilot SDK tool name to standard tool name mapping
*
* Maps Copilot CLI tool names to our standard tool names for consistent UI display.
* Tool names are case-insensitive (normalized to lowercase before lookup).
*/
const COPILOT_TOOL_NAME_MAP: Record<string, string> = {
// File operations
read_file: 'Read',
read: 'Read',
view: 'Read', // Copilot uses 'view' for reading files
read_many_files: 'Read',
write_file: 'Write',
write: 'Write',
create_file: 'Write',
edit_file: 'Edit',
edit: 'Edit',
replace: 'Edit',
patch: 'Edit',
// Shell operations
run_shell: 'Bash',
run_shell_command: 'Bash',
shell: 'Bash',
bash: 'Bash',
execute: 'Bash',
terminal: 'Bash',
// Search operations
search: 'Grep',
grep: 'Grep',
search_file_content: 'Grep',
find_files: 'Glob',
glob: 'Glob',
list_dir: 'Ls',
list_directory: 'Ls',
ls: 'Ls',
// Web operations
web_fetch: 'WebFetch',
fetch: 'WebFetch',
web_search: 'WebSearch',
search_web: 'WebSearch',
google_web_search: 'WebSearch',
// Todo operations
todo_write: 'TodoWrite',
write_todos: 'TodoWrite',
update_todos: 'TodoWrite',
// Planning/intent operations (Copilot-specific)
report_intent: 'ReportIntent', // Keep as-is, it's a planning tool
think: 'Think',
plan: 'Plan',
};
/**
* Normalize Copilot tool names to standard tool names
*/
function normalizeCopilotToolName(copilotToolName: string): string {
const lowerName = copilotToolName.toLowerCase();
return COPILOT_TOOL_NAME_MAP[lowerName] || copilotToolName;
}
/**
* Normalize Copilot tool input parameters to standard format
*
* Maps Copilot's parameter names to our standard parameter names.
* Uses shared utilities from tool-normalization.ts for common normalizations.
*/
function normalizeCopilotToolInput(
toolName: string,
input: Record<string, unknown>
): Record<string, unknown> {
const normalizedName = normalizeCopilotToolName(toolName);
// Normalize todo_write / write_todos: ensure proper format
if (normalizedName === 'TodoWrite' && Array.isArray(input.todos)) {
return { todos: normalizeTodos(input.todos) };
}
// Normalize file path parameters for Read/Write/Edit tools
if (normalizedName === 'Read' || normalizedName === 'Write' || normalizedName === 'Edit') {
return normalizeFilePathInput(input);
}
// Normalize shell command parameters for Bash tool
if (normalizedName === 'Bash') {
return normalizeCommandInput(input);
}
// Normalize search parameters for Grep tool
if (normalizedName === 'Grep') {
return normalizePatternInput(input);
}
return input;
}
/**
* CopilotProvider - Integrates GitHub Copilot SDK as an AI provider
*
* Features:
* - GitHub OAuth authentication
* - SDK-based session management
* - Runtime model discovery
* - Tool call normalization
* - Per-execution working directory support
*/
export class CopilotProvider extends CliProvider {
private runtimeModels: CopilotRuntimeModel[] | null = null;
constructor(config: ProviderConfig = {}) {
super(config);
// Trigger CLI detection on construction
this.ensureCliDetected();
}
// ==========================================================================
// CliProvider Abstract Method Implementations
// ==========================================================================
getName(): string {
return 'copilot';
}
getCliName(): string {
return 'copilot';
}
getSpawnConfig(): CliSpawnConfig {
return {
windowsStrategy: 'npx', // Copilot CLI can be run via npx
npxPackage: '@github/copilot', // Official GitHub Copilot CLI package
commonPaths: {
linux: [
path.join(os.homedir(), '.local/bin/copilot'),
'/usr/local/bin/copilot',
path.join(os.homedir(), '.npm-global/bin/copilot'),
],
darwin: [
path.join(os.homedir(), '.local/bin/copilot'),
'/usr/local/bin/copilot',
'/opt/homebrew/bin/copilot',
path.join(os.homedir(), '.npm-global/bin/copilot'),
],
win32: [
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'copilot.cmd'),
path.join(os.homedir(), '.npm-global', 'copilot.cmd'),
],
},
};
}
/**
* Extract prompt text from ExecuteOptions
*
* Note: CopilotProvider does not yet support vision/image inputs.
* If non-text content is provided, an error is thrown.
*/
private extractPromptText(options: ExecuteOptions): string {
if (typeof options.prompt === 'string') {
return options.prompt;
} else if (Array.isArray(options.prompt)) {
// Check for non-text content (images, etc.) which we don't support yet
const hasNonText = options.prompt.some((p) => p.type !== 'text');
if (hasNonText) {
throw new Error(
'CopilotProvider does not yet support non-text prompt parts (e.g., images). ' +
'Please use text-only prompts or switch to a provider that supports vision.'
);
}
return options.prompt
.filter((p) => p.type === 'text' && p.text)
.map((p) => p.text)
.join('\n');
} else {
throw new Error('Invalid prompt format');
}
}
/**
* Not used with SDK approach - kept for interface compatibility
*/
buildCliArgs(_options: ExecuteOptions): string[] {
return [];
}
/**
* Convert SDK event to AutoMaker ProviderMessage format
*/
normalizeEvent(event: unknown): ProviderMessage | null {
const sdkEvent = event as SdkEvent;
switch (sdkEvent.type) {
case 'assistant.message': {
const messageEvent = sdkEvent as SdkMessageEvent;
return {
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: messageEvent.data.content }],
},
};
}
case 'assistant.message_delta': {
// Skip delta events - they create too much noise
// The final assistant.message event has the complete content
return null;
}
case 'tool.execution_start': {
const toolEvent = sdkEvent as SdkToolExecutionStartEvent;
const normalizedName = normalizeCopilotToolName(toolEvent.data.toolName);
const normalizedInput = toolEvent.data.input
? normalizeCopilotToolInput(toolEvent.data.toolName, toolEvent.data.input)
: {};
return {
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
name: normalizedName,
tool_use_id: toolEvent.data.toolCallId,
input: normalizedInput,
},
],
},
};
}
case 'tool.execution_end': {
const toolResultEvent = sdkEvent as SdkToolExecutionEndEvent;
const isError = !!toolResultEvent.data.error;
const content = isError
? `[ERROR] ${toolResultEvent.data.error}`
: toolResultEvent.data.result || '';
return {
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_result',
tool_use_id: toolResultEvent.data.toolCallId,
content,
},
],
},
};
}
case 'session.idle': {
logger.debug('Copilot session idle');
return {
type: 'result',
subtype: 'success',
};
}
case 'session.error': {
const errorEvent = sdkEvent as SdkSessionErrorEvent;
return {
type: 'error',
error: errorEvent.data.message || 'Unknown error',
};
}
default:
logger.debug(`Unknown Copilot SDK event type: ${sdkEvent.type}`);
return null;
}
}
// ==========================================================================
// CliProvider Overrides
// ==========================================================================
/**
* Override error mapping for Copilot-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('authentication required') ||
lower.includes('github login')
) {
return {
code: CopilotErrorCode.NOT_AUTHENTICATED,
message: 'GitHub Copilot is not authenticated',
recoverable: true,
suggestion: 'Run "gh auth login" or "copilot auth login" to authenticate with GitHub',
};
}
if (
lower.includes('rate limit') ||
lower.includes('too many requests') ||
lower.includes('429') ||
lower.includes('quota exceeded')
) {
return {
code: CopilotErrorCode.RATE_LIMITED,
message: 'Copilot API rate limit exceeded',
recoverable: true,
suggestion: 'Wait a few minutes and try again',
};
}
if (
lower.includes('model not available') ||
lower.includes('invalid model') ||
lower.includes('unknown model') ||
lower.includes('model not found') ||
(lower.includes('not found') && lower.includes('404'))
) {
return {
code: CopilotErrorCode.MODEL_UNAVAILABLE,
message: 'Requested model is not available',
recoverable: true,
suggestion: `Try using "${DEFAULT_BARE_MODEL}" or select a different model`,
};
}
if (
lower.includes('network') ||
lower.includes('connection') ||
lower.includes('econnrefused') ||
lower.includes('timeout')
) {
return {
code: CopilotErrorCode.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: CopilotErrorCode.PROCESS_CRASHED,
message: 'Copilot CLI process was terminated',
recoverable: true,
suggestion: 'The process may have run out of memory. Try a simpler task.',
};
}
return {
code: CopilotErrorCode.UNKNOWN,
message: stderr || `Copilot CLI exited with code ${exitCode}`,
recoverable: false,
};
}
/**
* Override install instructions for Copilot-specific guidance
*/
protected getInstallInstructions(): string {
return 'Install with: npm install -g @github/copilot (or visit https://github.com/github/copilot)';
}
/**
* Execute a prompt using Copilot SDK with real-time streaming
*
* Creates a new CopilotClient for each execution with the correct working directory.
* Streams tool execution events in real-time for UI display.
*/
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
this.ensureCliDetected();
// Note: We don't use validateBareModelId here because Copilot's model IDs
// legitimately contain prefixes like claude-, gemini-, gpt- which are the
// actual model names from the Copilot CLI. We only need to ensure the
// copilot- prefix has been stripped by the ProviderFactory.
if (options.model?.startsWith('copilot-')) {
throw new Error(
`[CopilotProvider] Model ID should not have 'copilot-' prefix. Got: '${options.model}'. ` +
`The ProviderFactory should strip this prefix before passing to the provider.`
);
}
if (!this.cliPath) {
throw this.createError(
CopilotErrorCode.NOT_INSTALLED,
'Copilot CLI is not installed',
true,
this.getInstallInstructions()
);
}
const promptText = this.extractPromptText(options);
const bareModel = options.model || DEFAULT_BARE_MODEL;
const workingDirectory = options.cwd || process.cwd();
logger.debug(
`CopilotProvider.executeQuery called with model: "${bareModel}", cwd: "${workingDirectory}"`
);
logger.debug(`Prompt length: ${promptText.length} characters`);
// Create a client for this execution with the correct working directory
const client = new CopilotClient({
logLevel: 'warning',
autoRestart: false,
cwd: workingDirectory,
});
// Use an async queue to bridge callback-based SDK events to async generator
const eventQueue: SdkEvent[] = [];
let resolveWaiting: (() => void) | null = null;
let sessionComplete = false;
let sessionError: Error | null = null;
const pushEvent = (event: SdkEvent) => {
eventQueue.push(event);
if (resolveWaiting) {
resolveWaiting();
resolveWaiting = null;
}
};
const waitForEvent = (): Promise<void> => {
if (eventQueue.length > 0 || sessionComplete) {
return Promise.resolve();
}
return new Promise((resolve) => {
resolveWaiting = resolve;
});
};
try {
await client.start();
logger.debug(`CopilotClient started with cwd: ${workingDirectory}`);
// Create session with streaming enabled for real-time events
const session = await client.createSession({
model: bareModel,
streaming: true,
// AUTONOMOUS MODE: Auto-approve all permission requests.
// AutoMaker is designed for fully autonomous AI agent operation.
// Security boundary is provided by Docker containerization (see CLAUDE.md).
// User is warned about this at app startup.
onPermissionRequest: async (
request: PermissionRequest
): Promise<{ kind: 'approved' } | { kind: 'denied-interactively-by-user' }> => {
logger.debug(`Permission request: ${request.kind}`);
return { kind: 'approved' };
},
});
const sessionId = session.sessionId;
logger.debug(`Session created: ${sessionId}`);
// Set up event handler to push events to queue
session.on((event: SdkEvent) => {
logger.debug(`SDK event: ${event.type}`);
if (event.type === 'session.idle') {
sessionComplete = true;
pushEvent(event);
} else if (event.type === 'session.error') {
const errorEvent = event as SdkSessionErrorEvent;
sessionError = new Error(errorEvent.data.message);
sessionComplete = true;
pushEvent(event);
} else {
// Push all other events (tool.execution_start, tool.execution_end, assistant.message, etc.)
pushEvent(event);
}
});
// Send the prompt (non-blocking)
await session.send({ prompt: promptText });
// Process events as they arrive
while (!sessionComplete || eventQueue.length > 0) {
await waitForEvent();
// Check for errors first (before processing events to avoid race condition)
if (sessionError) {
await session.destroy();
await client.stop();
throw sessionError;
}
// Process all queued events
while (eventQueue.length > 0) {
const event = eventQueue.shift()!;
const normalized = this.normalizeEvent(event);
if (normalized) {
// Add session_id if not present
if (!normalized.session_id) {
normalized.session_id = sessionId;
}
yield normalized;
}
}
}
// Cleanup
await session.destroy();
await client.stop();
logger.debug('CopilotClient stopped successfully');
} catch (error) {
// Ensure client is stopped on error
try {
await client.stop();
} catch (cleanupError) {
// Log but don't throw cleanup errors - the original error is more important
logger.debug(`Failed to stop client during cleanup: ${cleanupError}`);
}
if (isAbortError(error)) {
logger.debug('Query aborted');
return;
}
// Map errors to CopilotError
if (error instanceof Error) {
logger.error(`Copilot SDK error: ${error.message}`);
const errorInfo = this.mapError(error.message, null);
throw this.createError(
errorInfo.code as CopilotErrorCode,
errorInfo.message,
errorInfo.recoverable,
errorInfo.suggestion
);
}
throw error;
}
}
// ==========================================================================
// Copilot-Specific Methods
// ==========================================================================
/**
* Create a CopilotError with details
*/
private createError(
code: CopilotErrorCode,
message: string,
recoverable: boolean = false,
suggestion?: string
): CopilotError {
const error = new Error(message) as CopilotError;
error.code = code;
error.recoverable = recoverable;
error.suggestion = suggestion;
error.name = 'CopilotError';
return error;
}
/**
* Get Copilot 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 GitHub CLI (gh) to check Copilot authentication status.
* The Copilot CLI relies on gh auth for authentication.
*/
async checkAuth(): Promise<CopilotAuthStatus> {
this.ensureCliDetected();
if (!this.cliPath) {
logger.debug('checkAuth: CLI not found');
return { authenticated: false, method: 'none' };
}
logger.debug('checkAuth: Starting credential check');
// Try to check GitHub CLI authentication status first
// The Copilot CLI uses gh auth for authentication
try {
const ghStatus = execSync('gh auth status --hostname github.com', {
encoding: 'utf8',
timeout: 10000,
stdio: 'pipe',
});
logger.debug(`checkAuth: gh auth status output: ${ghStatus.substring(0, 200)}`);
// Parse gh auth status output
const loggedInMatch = ghStatus.match(/Logged in to github\.com account (\S+)/);
if (loggedInMatch) {
return {
authenticated: true,
method: 'oauth',
login: loggedInMatch[1],
host: 'github.com',
};
}
// Check for token auth
if (ghStatus.includes('Logged in') || ghStatus.includes('Token:')) {
return {
authenticated: true,
method: 'oauth',
host: 'github.com',
};
}
} catch (ghError) {
logger.debug(`checkAuth: gh auth status failed: ${ghError}`);
}
// Try Copilot-specific auth check if gh is not available
try {
const result = execSync(`"${this.cliPath}" auth status`, {
encoding: 'utf8',
timeout: 10000,
stdio: 'pipe',
});
logger.debug(`checkAuth: copilot auth status output: ${result.substring(0, 200)}`);
if (result.includes('authenticated') || result.includes('logged in')) {
return {
authenticated: true,
method: 'cli',
};
}
} catch (copilotError) {
logger.debug(`checkAuth: copilot auth status failed: ${copilotError}`);
}
// Check for GITHUB_TOKEN environment variable
if (process.env.GITHUB_TOKEN) {
logger.debug('checkAuth: Found GITHUB_TOKEN environment variable');
return {
authenticated: true,
method: 'oauth',
statusMessage: 'Using GITHUB_TOKEN environment variable',
};
}
// Check for gh config file
const ghConfigPath = path.join(os.homedir(), '.config', 'gh', 'hosts.yml');
try {
await fs.access(ghConfigPath);
const content = await fs.readFile(ghConfigPath, 'utf8');
if (content.includes('github.com') && content.includes('oauth_token')) {
logger.debug('checkAuth: Found gh config with oauth_token');
return {
authenticated: true,
method: 'oauth',
host: 'github.com',
};
}
} catch {
logger.debug('checkAuth: No gh config found');
}
// No credentials found
logger.debug('checkAuth: No valid credentials found');
return {
authenticated: false,
method: 'none',
error:
'No authentication configured. Run "gh auth login" or install GitHub Copilot extension.',
};
}
/**
* Fetch available models from the CLI at runtime
*/
async fetchRuntimeModels(): Promise<CopilotRuntimeModel[]> {
this.ensureCliDetected();
if (!this.cliPath) {
return [];
}
try {
// Try to list models using the CLI
const result = execSync(`"${this.cliPath}" models list --format json`, {
encoding: 'utf8',
timeout: 15000,
stdio: 'pipe',
});
const models = JSON.parse(result) as CopilotRuntimeModel[];
this.runtimeModels = models;
logger.debug(`Fetched ${models.length} runtime models from Copilot CLI`);
return models;
} catch (error) {
// Clear cache on failure to avoid returning stale data
this.runtimeModels = null;
logger.debug(`Failed to fetch runtime models: ${error}`);
return [];
}
}
/**
* 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',
authenticated: auth.authenticated,
};
}
/**
* Get the detected CLI path (public accessor for status endpoints)
*/
getCliPath(): string | null {
this.ensureCliDetected();
return this.cliPath;
}
/**
* Get available Copilot models
*
* Returns both static model definitions and runtime-discovered models
*/
getAvailableModels(): ModelDefinition[] {
// Start with static model definitions - explicitly typed to allow runtime models
const staticModels: ModelDefinition[] = Object.entries(COPILOT_MODEL_MAP).map(
([id, config]) => ({
id, // Full model ID with copilot- prefix
name: config.label,
modelString: id.replace('copilot-', ''), // Bare model for CLI
provider: 'copilot',
description: config.description,
supportsTools: config.supportsTools,
supportsVision: config.supportsVision,
contextWindow: config.contextWindow,
})
);
// Add runtime models if available (discovered via CLI)
if (this.runtimeModels) {
for (const runtimeModel of this.runtimeModels) {
// Skip if already in static list
const staticId = `copilot-${runtimeModel.id}`;
if (staticModels.some((m) => m.id === staticId)) {
continue;
}
staticModels.push({
id: staticId,
name: runtimeModel.name || runtimeModel.id,
modelString: runtimeModel.id,
provider: 'copilot',
description: `Dynamic model: ${runtimeModel.name || runtimeModel.id}`,
supportsTools: true,
supportsVision: runtimeModel.capabilities?.supportsVision ?? false,
contextWindow: runtimeModel.capabilities?.maxInputTokens,
});
}
}
return staticModels;
}
/**
* Check if a feature is supported
*
* Note: Vision is NOT currently supported - the SDK doesn't handle image inputs yet.
* This may change in future versions of the Copilot SDK.
*/
supportsFeature(feature: string): boolean {
const supported = ['tools', 'text', 'streaming'];
return supported.includes(feature);
}
/**
* Check if runtime models have been cached
*/
hasCachedModels(): boolean {
return this.runtimeModels !== null && this.runtimeModels.length > 0;
}
/**
* Clear the runtime model cache
*/
clearModelCache(): void {
this.runtimeModels = null;
logger.debug('Cleared Copilot model cache');
}
/**
* Refresh models from CLI and return all available models
*/
async refreshModels(): Promise<ModelDefinition[]> {
logger.debug('Refreshing Copilot models from CLI');
await this.fetchRuntimeModels();
return this.getAvailableModels();
}
}

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,810 @@
/**
* 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';
import { normalizeTodos } from './tool-normalization.js';
// 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
*
* Uses shared normalizeTodos utility for consistent todo normalization.
*
* 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 using shared utility
if (toolName === 'write_todos' && Array.isArray(input.todos)) {
return { todos: normalizeTodos(input.todos) };
}
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
@@ -28,6 +38,12 @@ export { CursorConfigManager } from './cursor-config-manager.js';
// OpenCode provider
export { OpencodeProvider } from './opencode-provider.js';
// Gemini provider
export { GeminiProvider, GeminiErrorCode } from './gemini-provider.js';
// Copilot provider (GitHub Copilot SDK)
export { CopilotProvider, CopilotErrorCode } from './copilot-provider.js';
// Provider factory
export { ProviderFactory } from './provider-factory.js';

View File

@@ -7,7 +7,14 @@
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,
isCopilotModel,
type ModelProvider,
} from '@automaker/types';
import * as fs from 'fs';
import * as path from 'path';
@@ -16,6 +23,8 @@ const DISCONNECTED_MARKERS: Record<string, string> = {
codex: '.codex-disconnected',
cursor: '.cursor-disconnected',
opencode: '.opencode-disconnected',
gemini: '.gemini-disconnected',
copilot: '.copilot-disconnected',
};
/**
@@ -239,8 +248,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 +276,8 @@ 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';
import { CopilotProvider } from './copilot-provider.js';
// Register Claude provider
registerProvider('claude', {
@@ -301,3 +312,19 @@ 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)
});
// Register Copilot provider (GitHub Copilot SDK)
registerProvider('copilot', {
factory: () => new CopilotProvider(),
aliases: ['github-copilot', 'github'],
canHandleModel: (model: string) => isCopilotModel(model),
priority: 6, // High priority - check before Codex since both can handle GPT models
});

View File

@@ -0,0 +1,112 @@
/**
* Shared tool normalization utilities for AI providers
*
* These utilities help normalize tool inputs from various AI providers
* to the standard format expected by the application.
*/
/**
* Valid todo status values in the standard format
*/
type TodoStatus = 'pending' | 'in_progress' | 'completed';
/**
* Set of valid status values for validation
*/
const VALID_STATUSES = new Set<TodoStatus>(['pending', 'in_progress', 'completed']);
/**
* Todo item from various AI providers (Gemini, Copilot, etc.)
*/
interface ProviderTodo {
description?: string;
content?: string;
status?: string;
}
/**
* Standard todo format used by the application
*/
interface NormalizedTodo {
content: string;
status: TodoStatus;
activeForm: string;
}
/**
* Normalize a provider status value to a valid TodoStatus
*/
function normalizeStatus(status: string | undefined): TodoStatus {
if (!status) return 'pending';
if (status === 'cancelled' || status === 'canceled') return 'completed';
if (VALID_STATUSES.has(status as TodoStatus)) return status as TodoStatus;
return 'pending';
}
/**
* Normalize todos array from provider format to standard format
*
* Handles different formats from providers:
* - Gemini: { description, status } with 'cancelled' as possible status
* - Copilot: { content/description, status } with 'cancelled' as possible status
*
* Output format (Claude/Standard):
* - { content, status, activeForm } where status is 'pending'|'in_progress'|'completed'
*/
export function normalizeTodos(todos: ProviderTodo[] | null | undefined): NormalizedTodo[] {
if (!todos) return [];
return todos.map((todo) => ({
content: todo.content || todo.description || '',
status: normalizeStatus(todo.status),
// Use content/description as activeForm since providers may not have it
activeForm: todo.content || todo.description || '',
}));
}
/**
* Normalize file path parameters from various provider formats
*
* Different providers use different parameter names for file paths:
* - path, file, filename, filePath -> file_path
*/
export function normalizeFilePathInput(input: Record<string, unknown>): Record<string, unknown> {
const normalized = { ...input };
if (!normalized.file_path) {
if (input.path) normalized.file_path = input.path;
else if (input.file) normalized.file_path = input.file;
else if (input.filename) normalized.file_path = input.filename;
else if (input.filePath) normalized.file_path = input.filePath;
}
return normalized;
}
/**
* Normalize shell command parameters from various provider formats
*
* Different providers use different parameter names for commands:
* - cmd, script -> command
*/
export function normalizeCommandInput(input: Record<string, unknown>): Record<string, unknown> {
const normalized = { ...input };
if (!normalized.command) {
if (input.cmd) normalized.command = input.cmd;
else if (input.script) normalized.command = input.script;
}
return normalized;
}
/**
* Normalize search pattern parameters from various provider formats
*
* Different providers use different parameter names for search patterns:
* - query, search, regex -> pattern
*/
export function normalizePatternInput(input: Record<string, unknown>): Record<string, unknown> {
const normalized = { ...input };
if (!normalized.pattern) {
if (input.query) normalized.pattern = input.query;
else if (input.search) normalized.pattern = input.search;
else if (input.regex) normalized.pattern = input.regex;
}
return normalized;
}

View File

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

View File

@@ -8,10 +8,11 @@
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput, isCodexModel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import {
@@ -25,6 +26,64 @@ const logger = createLogger('SpecRegeneration');
const DEFAULT_MAX_FEATURES = 50;
/**
* Timeout for Codex models when generating features (5 minutes).
* Codex models are slower and need more time to generate 50+ features.
*/
const CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
/**
* Type for extracted features JSON response
*/
interface FeaturesExtractionResult {
features: Array<{
id: string;
category?: string;
title: string;
description: string;
priority?: number;
complexity?: 'simple' | 'moderate' | 'complex';
dependencies?: string[];
}>;
}
/**
* JSON schema for features output format (Claude/Codex structured output)
*/
const featuresOutputSchema = {
type: 'object',
properties: {
features: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Unique feature identifier (kebab-case)' },
category: { type: 'string', description: 'Feature category' },
title: { type: 'string', description: 'Short, descriptive title' },
description: { type: 'string', description: 'Detailed feature description' },
priority: {
type: 'number',
description: 'Priority level: 1 (highest) to 5 (lowest)',
},
complexity: {
type: 'string',
enum: ['simple', 'moderate', 'complex'],
description: 'Implementation complexity',
},
dependencies: {
type: 'array',
items: { type: 'string' },
description: 'IDs of features this depends on',
},
},
required: ['id', 'title', 'description'],
},
},
},
required: ['features'],
} as const;
export async function generateFeaturesFromSpec(
projectPath: string,
events: EventEmitter,
@@ -136,23 +195,80 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
provider: undefined,
credentials: undefined,
};
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
const { model, thinkingLevel, reasoningEffort } = resolvePhaseModel(phaseModelEntry);
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
// Codex models need extended timeout for generating many features.
// Use 'xhigh' reasoning effort to get 5-minute timeout (300s base * 1.0x = 300s).
// The Codex provider has a special 5-minute base timeout for feature generation.
const isCodex = isCodexModel(model);
const effectiveReasoningEffort = isCodex ? 'xhigh' : reasoningEffort;
if (isCodex) {
logger.info('Codex model detected - using extended timeout (5 minutes for feature generation)');
}
if (effectiveReasoningEffort) {
logger.info('Reasoning effort:', effectiveReasoningEffort);
}
// Determine if we should use structured output based on model type
const useStructuredOutput = supportsStructuredOutput(model);
logger.info(
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
);
// Build the final prompt - for non-Claude/Codex models, include explicit JSON 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 spec, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
3. The JSON must have this exact structure:
{
"features": [
{
"id": "unique-feature-id",
"category": "Category Name",
"title": "Short Feature Title",
"description": "Detailed description of the feature",
"priority": 1,
"complexity": "simple|moderate|complex",
"dependencies": ["other-feature-id"]
}
]
}
4. Feature IDs must be unique, lowercase, kebab-case (e.g., "user-authentication", "data-export")
5. Priority ranges from 1 (highest) to 5 (lowest)
6. Complexity must be one of: "simple", "moderate", "complex"
7. Dependencies is an array of feature IDs that must be completed first (can be empty)
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,
prompt: finalPrompt,
model,
cwd: projectPath,
maxTurns: 250,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
thinkingLevel,
reasoningEffort: effectiveReasoningEffort, // Extended timeout for Codex models
readOnly: true, // Feature generation 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: featuresOutputSchema,
}
: undefined,
onText: (text) => {
logger.debug(`Feature text block received (${text.length} chars)`);
events.emit('spec-regeneration:event', {
@@ -163,15 +279,51 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
},
});
const responseText = result.text;
// Get response content - prefer structured output if available
let contentForParsing: string;
logger.info(`Feature stream complete.`);
logger.info(`Feature response length: ${responseText.length} chars`);
logger.info('========== FULL RESPONSE TEXT ==========');
logger.info(responseText);
logger.info('========== END RESPONSE TEXT ==========');
if (result.structured_output) {
// Use structured output from Claude/Codex models
logger.info('✅ Received structured output from model');
contentForParsing = JSON.stringify(result.structured_output);
logger.debug('Structured output:', contentForParsing);
} else {
// Use text response (for non-Claude/Codex models or fallback)
// Pre-extract JSON to handle conversational text that may surround the JSON response
// This follows the same pattern used in generate-spec.ts and validate-issue.ts
const rawText = result.text;
logger.info(`Feature stream complete.`);
logger.info(`Feature response length: ${rawText.length} chars`);
logger.info('========== FULL RESPONSE TEXT ==========');
logger.info(rawText);
logger.info('========== END RESPONSE TEXT ==========');
await parseAndCreateFeatures(projectPath, responseText, events);
// Pre-extract JSON from response - handles conversational text around the JSON
const extracted = extractJsonWithArray<FeaturesExtractionResult>(rawText, 'features', {
logger,
});
if (extracted) {
contentForParsing = JSON.stringify(extracted);
logger.info('✅ Pre-extracted JSON from text response');
} else {
// If pre-extraction fails, we know the next step will also fail.
// Throw an error here to avoid redundant parsing and make the failure point clearer.
logger.error(
'❌ Could not extract features JSON from model response. Full response text was:\n' +
rawText
);
const errorMessage =
'Failed to parse features from model response: No valid JSON with a "features" array found.';
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
error: errorMessage,
projectPath: projectPath,
});
throw new Error(errorMessage);
}
}
await parseAndCreateFeatures(projectPath, contentForParsing, events);
logger.debug('========== generateFeaturesFromSpec() completed ==========');
}

View File

@@ -9,7 +9,7 @@ import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { specOutputSchema, specToXml, type SpecOutput } from '../../lib/app-spec-format.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { extractJson } from '../../lib/json-extractor.js';
import { streamingQuery } from '../../providers/simple-query-service.js';
@@ -120,10 +120,13 @@ ${prompts.appSpec.structuredSpecInstructions}`;
let responseText = '';
let structuredOutput: SpecOutput | null = null;
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
const useStructuredOutput = !isCursorModel(model);
// Determine if we should use structured output based on model type
const useStructuredOutput = supportsStructuredOutput(model);
logger.info(
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
);
// Build the final prompt - for Cursor, include JSON schema instructions
// Build the final prompt - for non-Claude/Codex models, include JSON schema instructions
let finalPrompt = prompt;
if (!useStructuredOutput) {
finalPrompt = `${prompt}

View File

@@ -10,9 +10,10 @@
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { extractJson } from '../../lib/json-extractor.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import {
@@ -34,6 +35,28 @@ import { getNotificationService } from '../../services/notification-service.js';
const logger = createLogger('SpecSync');
/**
* Type for extracted tech stack JSON response
*/
interface TechStackExtractionResult {
technologies: string[];
}
/**
* JSON schema for tech stack analysis output (Claude/Codex structured output)
*/
const techStackOutputSchema = {
type: 'object',
properties: {
technologies: {
type: 'array',
items: { type: 'string' },
description: 'List of technologies detected in the project',
},
},
required: ['technologies'],
} as const;
/**
* Result of a sync operation
*/
@@ -176,8 +199,14 @@ export async function syncSpec(
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
// Determine if we should use structured output based on model type
const useStructuredOutput = supportsStructuredOutput(model);
logger.info(
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
);
// Use AI to analyze tech stack
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
let techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
Current known technologies: ${currentTechStack.join(', ')}
@@ -193,6 +222,16 @@ Return ONLY this JSON format, no other text:
"technologies": ["Technology 1", "Technology 2", ...]
}`;
// Add explicit JSON instructions for non-Claude/Codex models
if (!useStructuredOutput) {
techAnalysisPrompt = `${techAnalysisPrompt}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. Return the JSON in your response only.
2. Your entire response should be valid JSON starting with { and ending with }.
3. No explanations, no markdown, no text before or after the JSON.`;
}
try {
const techResult = await streamingQuery({
prompt: techAnalysisPrompt,
@@ -206,44 +245,67 @@ Return ONLY this JSON format, no other text:
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: techStackOutputSchema,
}
: undefined,
onText: (text) => {
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
},
});
// Parse tech stack from response
const jsonMatch = techResult.text.match(/\{[\s\S]*"technologies"[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
if (Array.isArray(parsed.technologies)) {
const newTechStack = parsed.technologies as string[];
// Parse tech stack from response - prefer structured output if available
let parsedTechnologies: string[] | null = null;
// Calculate differences
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
if (techResult.structured_output) {
// Use structured output from Claude/Codex models
const structured = techResult.structured_output as unknown as TechStackExtractionResult;
if (Array.isArray(structured.technologies)) {
parsedTechnologies = structured.technologies;
logger.info('✅ Received structured output for tech analysis');
}
} else {
// Fall back to text parsing for non-Claude/Codex models
const extracted = extractJson<TechStackExtractionResult>(techResult.text, {
logger,
requiredKey: 'technologies',
requireArray: true,
});
if (extracted && Array.isArray(extracted.technologies)) {
parsedTechnologies = extracted.technologies;
logger.info('✅ Extracted tech stack from text response');
} else {
logger.warn('⚠️ Failed to extract tech stack JSON from response');
}
}
for (const tech of newTechStack) {
if (!currentSet.has(tech.toLowerCase())) {
result.techStackUpdates.added.push(tech);
}
if (parsedTechnologies) {
const newTechStack = parsedTechnologies;
// Calculate differences
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
for (const tech of newTechStack) {
if (!currentSet.has(tech.toLowerCase())) {
result.techStackUpdates.added.push(tech);
}
}
for (const tech of currentTechStack) {
if (!newSet.has(tech.toLowerCase())) {
result.techStackUpdates.removed.push(tech);
}
for (const tech of currentTechStack) {
if (!newSet.has(tech.toLowerCase())) {
result.techStackUpdates.removed.push(tech);
}
}
// Update spec with new tech stack if there are changes
if (
result.techStackUpdates.added.length > 0 ||
result.techStackUpdates.removed.length > 0
) {
specContent = updateTechnologyStack(specContent, newTechStack);
logger.info(
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
);
}
// Update spec with new tech stack if there are changes
if (result.techStackUpdates.added.length > 0 || result.techStackUpdates.removed.length > 0) {
specContent = updateTechnologyStack(specContent, newTechStack);
logger.info(
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
);
}
}
} catch (error) {

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

@@ -5,6 +5,7 @@
import { Router } from 'express';
import { FeatureLoader } from '../../services/feature-loader.js';
import type { SettingsService } from '../../services/settings-service.js';
import type { AutoModeService } from '../../services/auto-mode-service.js';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js';
@@ -16,15 +17,22 @@ 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,
settingsService?: SettingsService,
events?: EventEmitter
events?: EventEmitter,
autoModeService?: AutoModeService
): Router {
const router = Router();
router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader));
router.post(
'/list',
validatePathParams('projectPath'),
createListHandler(featureLoader, autoModeService)
);
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
router.post(
'/create',
@@ -46,6 +54,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

@@ -43,7 +43,7 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event
if (events) {
events.emit('feature:created', {
featureId: created.id,
featureName: created.name,
featureName: created.title || 'Untitled Feature',
projectPath,
});
}

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

@@ -1,12 +1,19 @@
/**
* POST /list endpoint - List all features for a project
*
* Also performs orphan detection when a project is loaded to identify
* features whose branches no longer exist. This runs on every project load/switch.
*/
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils';
export function createListHandler(featureLoader: FeatureLoader) {
const logger = createLogger('FeaturesListRoute');
export function createListHandler(featureLoader: FeatureLoader, autoModeService?: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
@@ -17,6 +24,26 @@ export function createListHandler(featureLoader: FeatureLoader) {
}
const features = await featureLoader.getAll(projectPath);
// Run orphan detection in background when project is loaded
// This detects features whose branches no longer exist (e.g., after merge/delete)
// We don't await this to keep the list response fast
// Note: detectOrphanedFeatures handles errors internally and always resolves
if (autoModeService) {
autoModeService.detectOrphanedFeatures(projectPath).then((orphanedFeatures) => {
if (orphanedFeatures.length > 0) {
logger.info(
`[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}`
);
for (const { feature, missingBranch } of orphanedFeatures) {
logger.info(
`[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists`
);
}
}
});
}
res.json({ success: true, features });
} catch (error) {
logError(error, 'List features failed');

View File

@@ -31,7 +31,9 @@ export function createSaveBoardBackgroundHandler() {
await secureFs.mkdir(boardDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present)
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
// Use a regex that handles all data URL formats including those with extra params
// e.g., data:image/gif;charset=utf-8;base64,R0lGOD...
const base64Data = data.replace(/^data:[^,]+,/, '');
const buffer = Buffer.from(base64Data, 'base64');
// Use a fixed filename for the board background (overwrite previous)

View File

@@ -31,7 +31,9 @@ export function createSaveImageHandler() {
await secureFs.mkdir(imagesDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present)
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
// Use a regex that handles all data URL formats including those with extra params
// e.g., data:image/gif;charset=utf-8;base64,R0lGOD...
const base64Data = data.replace(/^data:[^,]+,/, '');
const buffer = Buffer.from(base64Data, 'base64');
// Generate unique filename with timestamp

View File

@@ -23,6 +23,7 @@ import {
isCodexModel,
isCursorModel,
isOpencodeModel,
supportsStructuredOutput,
} from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { extractJson } from '../../../lib/json-extractor.js';
@@ -124,8 +125,9 @@ async function runValidation(
const prompts = await getPromptCustomization(settingsService, '[ValidateIssue]');
const issueValidationSystemPrompt = prompts.issueValidation.systemPrompt;
// Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't)
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
// Determine if we should use structured output based on model type
// Claude and Codex support it; Cursor, Gemini, OpenCode, Copilot don't
const useStructuredOutput = supportsStructuredOutput(model);
// Build the final prompt - for Cursor, include system prompt and JSON schema instructions
let finalPrompt = basePrompt;

View File

@@ -4,15 +4,21 @@
import type { Request, Response } from 'express';
import type { IdeationService } from '../../../services/ideation-service.js';
import type { IdeationContextSources } from '@automaker/types';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('ideation:suggestions-generate');
/**
* Creates an Express route handler for generating AI-powered ideation suggestions.
* Accepts a prompt, category, and optional context sources configuration,
* then returns structured suggestions that can be added to the board.
*/
export function createSuggestionsGenerateHandler(ideationService: IdeationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, promptId, category, count } = req.body;
const { projectPath, promptId, category, count, contextSources } = req.body;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
@@ -38,7 +44,8 @@ export function createSuggestionsGenerateHandler(ideationService: IdeationServic
projectPath,
promptId,
category,
suggestionCount
suggestionCount,
contextSources as IdeationContextSources | undefined
);
res.json({

View File

@@ -0,0 +1,12 @@
/**
* Common utilities for projects routes
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
const logger = createLogger('Projects');
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger);

View File

@@ -0,0 +1,27 @@
/**
* Projects routes - HTTP API for multi-project overview and management
*/
import { Router } from 'express';
import type { FeatureLoader } from '../../services/feature-loader.js';
import type { AutoModeService } from '../../services/auto-mode-service.js';
import type { SettingsService } from '../../services/settings-service.js';
import type { NotificationService } from '../../services/notification-service.js';
import { createOverviewHandler } from './routes/overview.js';
export function createProjectsRoutes(
featureLoader: FeatureLoader,
autoModeService: AutoModeService,
settingsService: SettingsService,
notificationService: NotificationService
): Router {
const router = Router();
// GET /overview - Get aggregate status for all projects
router.get(
'/overview',
createOverviewHandler(featureLoader, autoModeService, settingsService, notificationService)
);
return router;
}

View File

@@ -0,0 +1,317 @@
/**
* GET /overview endpoint - Get aggregate status for all projects
*
* Returns a complete overview of all projects including:
* - Individual project status (features, auto-mode state)
* - Aggregate metrics across all projects
* - Recent activity feed (placeholder for future implementation)
*/
import type { Request, Response } from 'express';
import type { FeatureLoader } from '../../../services/feature-loader.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import type { NotificationService } from '../../../services/notification-service.js';
import type {
ProjectStatus,
AggregateStatus,
MultiProjectOverview,
FeatureStatusCounts,
AggregateFeatureCounts,
AggregateProjectCounts,
ProjectHealthStatus,
Feature,
ProjectRef,
} from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
/**
* Compute feature status counts from a list of features
*/
function computeFeatureCounts(features: Feature[]): FeatureStatusCounts {
const counts: FeatureStatusCounts = {
pending: 0,
running: 0,
completed: 0,
failed: 0,
verified: 0,
};
for (const feature of features) {
switch (feature.status) {
case 'pending':
case 'ready':
counts.pending++;
break;
case 'running':
case 'generating_spec':
case 'in_progress':
counts.running++;
break;
case 'waiting_approval':
// waiting_approval means agent finished, needs human review - count as pending
counts.pending++;
break;
case 'completed':
counts.completed++;
break;
case 'failed':
counts.failed++;
break;
case 'verified':
counts.verified++;
break;
default:
// Unknown status, treat as pending
counts.pending++;
}
}
return counts;
}
/**
* Determine the overall health status of a project based on its feature statuses
*/
function computeHealthStatus(
featureCounts: FeatureStatusCounts,
isAutoModeRunning: boolean
): ProjectHealthStatus {
const totalFeatures =
featureCounts.pending +
featureCounts.running +
featureCounts.completed +
featureCounts.failed +
featureCounts.verified;
// If there are failed features, the project has errors
if (featureCounts.failed > 0) {
return 'error';
}
// If there are running features or auto mode is running with pending work
if (featureCounts.running > 0 || (isAutoModeRunning && featureCounts.pending > 0)) {
return 'active';
}
// Pending work but no active execution
if (featureCounts.pending > 0) {
return 'waiting';
}
// If all features are completed or verified
if (totalFeatures > 0 && featureCounts.pending === 0 && featureCounts.running === 0) {
return 'completed';
}
// Default to idle
return 'idle';
}
/**
* Get the most recent activity timestamp from features
*/
function getLastActivityAt(features: Feature[]): string | undefined {
if (features.length === 0) {
return undefined;
}
let latestTimestamp: number = 0;
for (const feature of features) {
// Check startedAt timestamp (the main timestamp available on Feature)
if (feature.startedAt) {
const timestamp = new Date(feature.startedAt).getTime();
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
latestTimestamp = timestamp;
}
}
// Also check planSpec timestamps if available
if (feature.planSpec?.generatedAt) {
const timestamp = new Date(feature.planSpec.generatedAt).getTime();
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
latestTimestamp = timestamp;
}
}
if (feature.planSpec?.approvedAt) {
const timestamp = new Date(feature.planSpec.approvedAt).getTime();
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
latestTimestamp = timestamp;
}
}
}
return latestTimestamp > 0 ? new Date(latestTimestamp).toISOString() : undefined;
}
export function createOverviewHandler(
featureLoader: FeatureLoader,
autoModeService: AutoModeService,
settingsService: SettingsService,
notificationService: NotificationService
) {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Get all projects from settings
const settings = await settingsService.getGlobalSettings();
const projectRefs: ProjectRef[] = settings.projects || [];
// Get all running agents once to count live running features per project
const allRunningAgents = await autoModeService.getRunningAgents();
// Collect project statuses in parallel
const projectStatusPromises = projectRefs.map(async (projectRef): Promise<ProjectStatus> => {
try {
// Load features for this project
const features = await featureLoader.getAll(projectRef.path);
const featureCounts = computeFeatureCounts(features);
const totalFeatures = features.length;
// Get auto-mode status for this project (main worktree, branchName = null)
const autoModeStatus = autoModeService.getStatusForProject(projectRef.path, null);
const isAutoModeRunning = autoModeStatus.isAutoLoopRunning;
// Count live running features for this project (across all branches)
// This ensures we only count features that are actually running in memory
const liveRunningCount = allRunningAgents.filter(
(agent) => agent.projectPath === projectRef.path
).length;
featureCounts.running = liveRunningCount;
// Get notification count for this project
let unreadNotificationCount = 0;
try {
const notifications = await notificationService.getNotifications(projectRef.path);
unreadNotificationCount = notifications.filter((n) => !n.read).length;
} catch {
// Ignore notification errors - project may not have any notifications yet
}
// Compute health status
const healthStatus = computeHealthStatus(featureCounts, isAutoModeRunning);
// Get last activity timestamp
const lastActivityAt = getLastActivityAt(features);
return {
projectId: projectRef.id,
projectName: projectRef.name,
projectPath: projectRef.path,
healthStatus,
featureCounts,
totalFeatures,
lastActivityAt,
isAutoModeRunning,
activeBranch: autoModeStatus.branchName ?? undefined,
unreadNotificationCount,
};
} catch (error) {
logError(error, `Failed to load project status: ${projectRef.name}`);
// Return a minimal status for projects that fail to load
return {
projectId: projectRef.id,
projectName: projectRef.name,
projectPath: projectRef.path,
healthStatus: 'error' as ProjectHealthStatus,
featureCounts: {
pending: 0,
running: 0,
completed: 0,
failed: 0,
verified: 0,
},
totalFeatures: 0,
isAutoModeRunning: false,
unreadNotificationCount: 0,
};
}
});
const projectStatuses = await Promise.all(projectStatusPromises);
// Compute aggregate metrics
const aggregateFeatureCounts: AggregateFeatureCounts = {
total: 0,
pending: 0,
running: 0,
completed: 0,
failed: 0,
verified: 0,
};
const aggregateProjectCounts: AggregateProjectCounts = {
total: projectStatuses.length,
active: 0,
idle: 0,
waiting: 0,
withErrors: 0,
allCompleted: 0,
};
let totalUnreadNotifications = 0;
let projectsWithAutoModeRunning = 0;
for (const status of projectStatuses) {
// Aggregate feature counts
aggregateFeatureCounts.total += status.totalFeatures;
aggregateFeatureCounts.pending += status.featureCounts.pending;
aggregateFeatureCounts.running += status.featureCounts.running;
aggregateFeatureCounts.completed += status.featureCounts.completed;
aggregateFeatureCounts.failed += status.featureCounts.failed;
aggregateFeatureCounts.verified += status.featureCounts.verified;
// Aggregate project counts by health status
switch (status.healthStatus) {
case 'active':
aggregateProjectCounts.active++;
break;
case 'idle':
aggregateProjectCounts.idle++;
break;
case 'waiting':
aggregateProjectCounts.waiting++;
break;
case 'error':
aggregateProjectCounts.withErrors++;
break;
case 'completed':
aggregateProjectCounts.allCompleted++;
break;
}
// Aggregate notifications
totalUnreadNotifications += status.unreadNotificationCount;
// Count projects with auto-mode running
if (status.isAutoModeRunning) {
projectsWithAutoModeRunning++;
}
}
const aggregateStatus: AggregateStatus = {
projectCounts: aggregateProjectCounts,
featureCounts: aggregateFeatureCounts,
totalUnreadNotifications,
projectsWithAutoModeRunning,
computedAt: new Date().toISOString(),
};
// Build the response (recentActivity is empty for now - can be populated later)
const overview: MultiProjectOverview = {
projects: projectStatuses,
aggregate: aggregateStatus,
recentActivity: [], // Placeholder for future activity feed implementation
generatedAt: new Date().toISOString(),
};
res.json({
success: true,
...overview,
});
} catch (error) {
logError(error, 'Get project overview failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -52,3 +52,8 @@ export async function persistApiKeyToEnv(key: string, value: string): Promise<vo
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger);
/**
* Marker file used to indicate a provider has been explicitly disconnected by user
*/
export const COPILOT_DISCONNECTED_MARKER_FILE = '.copilot-disconnected';

View File

@@ -24,6 +24,17 @@ 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 { createCopilotStatusHandler } from './routes/copilot-status.js';
import { createAuthCopilotHandler } from './routes/auth-copilot.js';
import { createDeauthCopilotHandler } from './routes/deauth-copilot.js';
import {
createGetCopilotModelsHandler,
createRefreshCopilotModelsHandler,
createClearCopilotCacheHandler,
} from './routes/copilot-models.js';
import {
createGetOpencodeModelsHandler,
createRefreshOpencodeModelsHandler,
@@ -72,6 +83,21 @@ 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());
// Copilot CLI routes
router.get('/copilot-status', createCopilotStatusHandler());
router.post('/auth-copilot', createAuthCopilotHandler());
router.post('/deauth-copilot', createDeauthCopilotHandler());
// Copilot Dynamic Model Discovery routes
router.get('/copilot/models', createGetCopilotModelsHandler());
router.post('/copilot/models/refresh', createRefreshCopilotModelsHandler());
router.post('/copilot/cache/clear', createClearCopilotCacheHandler());
// OpenCode Dynamic Model Discovery routes
router.get('/opencode/models', createGetOpencodeModelsHandler());
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());

View File

@@ -0,0 +1,30 @@
/**
* POST /auth-copilot endpoint - Connect Copilot CLI to the app
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { connectCopilot } from '../../../services/copilot-connection-service.js';
/**
* Creates handler for POST /api/setup/auth-copilot
* Removes the disconnection marker to allow Copilot CLI to be used
*/
export function createAuthCopilotHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
await connectCopilot();
res.json({
success: true,
message: 'Copilot CLI connected to app',
});
} catch (error) {
logError(error, 'Auth Copilot failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

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,139 @@
/**
* Copilot Dynamic Models API Routes
*
* Provides endpoints for:
* - GET /api/setup/copilot/models - Get available models (cached or refreshed)
* - POST /api/setup/copilot/models/refresh - Force refresh models from CLI
*/
import type { Request, Response } from 'express';
import { CopilotProvider } from '../../../providers/copilot-provider.js';
import { getErrorMessage, logError } from '../common.js';
import type { ModelDefinition } from '@automaker/types';
import { createLogger } from '@automaker/utils';
const logger = createLogger('CopilotModelsRoute');
// Singleton provider instance for caching
let providerInstance: CopilotProvider | null = null;
function getProvider(): CopilotProvider {
if (!providerInstance) {
providerInstance = new CopilotProvider();
}
return providerInstance;
}
/**
* Response type for models endpoint
*/
interface ModelsResponse {
success: boolean;
models?: ModelDefinition[];
count?: number;
cached?: boolean;
error?: string;
}
/**
* Creates handler for GET /api/setup/copilot/models
*
* Returns currently available models (from cache if available).
* Query params:
* - refresh=true: Force refresh from CLI before returning
*
* Note: If cache is empty, this will trigger a refresh to get dynamic models.
*/
export function createGetCopilotModelsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const forceRefresh = req.query.refresh === 'true';
let models: ModelDefinition[];
let cached = true;
if (forceRefresh) {
models = await provider.refreshModels();
cached = false;
} else {
// Check if we have cached models
if (!provider.hasCachedModels()) {
models = await provider.refreshModels();
cached = false;
} else {
models = provider.getAvailableModels();
}
}
const response: ModelsResponse = {
success: true,
models,
count: models.length,
cached,
};
res.json(response);
} catch (error) {
logError(error, 'Get Copilot models failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ModelsResponse);
}
};
}
/**
* Creates handler for POST /api/setup/copilot/models/refresh
*
* Forces a refresh of models from the Copilot CLI.
*/
export function createRefreshCopilotModelsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const models = await provider.refreshModels();
const response: ModelsResponse = {
success: true,
models,
count: models.length,
cached: false,
};
res.json(response);
} catch (error) {
logError(error, 'Refresh Copilot models failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ModelsResponse);
}
};
}
/**
* Creates handler for POST /api/setup/copilot/cache/clear
*
* Clears the model cache, forcing a fresh fetch on next access.
*/
export function createClearCopilotCacheHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
provider.clearModelCache();
res.json({
success: true,
message: 'Copilot model cache cleared',
});
} catch (error) {
logError(error, 'Clear Copilot cache failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,78 @@
/**
* GET /copilot-status endpoint - Get Copilot CLI installation and auth status
*/
import type { Request, Response } from 'express';
import { CopilotProvider } from '../../../providers/copilot-provider.js';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs/promises';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.copilot-disconnected';
async function isCopilotDisconnectedFromApp(): 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/copilot-status
* Returns Copilot CLI installation and authentication status
*/
export function createCopilotStatusHandler() {
const installCommand = 'npm install -g @github/copilot';
const loginCommand = 'gh auth login';
return async (_req: Request, res: Response): Promise<void> => {
try {
// Check if user has manually disconnected from the app
if (await isCopilotDisconnectedFromApp()) {
res.json({
success: true,
installed: true,
version: null,
path: null,
auth: {
authenticated: false,
method: 'none',
},
installCommand,
loginCommand,
});
return;
}
const provider = new CopilotProvider();
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,
login: auth.login,
host: auth.host,
error: auth.error,
},
installCommand,
loginCommand,
});
} catch (error) {
logError(error, 'Get Copilot status failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,30 @@
/**
* POST /deauth-copilot endpoint - Disconnect Copilot CLI from the app
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { disconnectCopilot } from '../../../services/copilot-connection-service.js';
/**
* Creates handler for POST /api/setup/deauth-copilot
* Creates a marker file to disconnect Copilot CLI from the app
*/
export function createDeauthCopilotHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
await disconnectCopilot();
res.json({
success: true,
message: 'Copilot CLI disconnected from app',
});
} catch (error) {
logError(error, 'Deauth Copilot 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

@@ -39,8 +39,15 @@ interface GitHubRemoteCacheEntry {
checkedAt: number;
}
interface GitHubPRCacheEntry {
prs: Map<string, WorktreePRInfo>;
fetchedAt: number;
}
const githubRemoteCache = new Map<string, GitHubRemoteCacheEntry>();
const githubPRCache = new Map<string, GitHubPRCacheEntry>();
const GITHUB_REMOTE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const GITHUB_PR_CACHE_TTL_MS = 2 * 60 * 1000; // 2 minutes - avoid hitting GitHub on every poll
interface WorktreeInfo {
path: string;
@@ -180,9 +187,21 @@ async function getGitHubRemoteStatus(projectPath: string): Promise<GitHubRemoteS
* This also allows detecting PRs that were created outside the app.
*
* Uses cached GitHub remote status to avoid repeated warnings when the
* project doesn't have a GitHub remote configured.
* project doesn't have a GitHub remote configured. Results are cached
* briefly to avoid hammering GitHub on frequent worktree polls.
*/
async function fetchGitHubPRs(projectPath: string): Promise<Map<string, WorktreePRInfo>> {
async function fetchGitHubPRs(
projectPath: string,
forceRefresh = false
): Promise<Map<string, WorktreePRInfo>> {
const now = Date.now();
const cached = githubPRCache.get(projectPath);
// Return cached result if valid and not forcing refresh
if (!forceRefresh && cached && now - cached.fetchedAt < GITHUB_PR_CACHE_TTL_MS) {
return cached.prs;
}
const prMap = new Map<string, WorktreePRInfo>();
try {
@@ -225,8 +244,22 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
createdAt: pr.createdAt,
});
}
// Only update cache on successful fetch
githubPRCache.set(projectPath, {
prs: prMap,
fetchedAt: Date.now(),
});
} catch (error) {
// Silently fail - PR detection is optional
// On fetch failure, return stale cached data if available to avoid
// repeated API calls during GitHub API flakiness or temporary outages
if (cached) {
logger.warn(`Failed to fetch GitHub PRs, returning stale cache: ${getErrorMessage(error)}`);
// Extend cache TTL to avoid repeated retries during outages
githubPRCache.set(projectPath, { prs: cached.prs, fetchedAt: Date.now() });
return cached.prs;
}
// No cache available, log warning and return empty map
logger.warn(`Failed to fetch GitHub PRs: ${getErrorMessage(error)}`);
}
@@ -364,7 +397,7 @@ export function createListHandler() {
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
const githubPRs = includeDetails
? await fetchGitHubPRs(projectPath)
? await fetchGitHubPRs(projectPath, forceRefreshGitHub)
: new Map<string, WorktreePRInfo>();
for (const worktree of worktrees) {

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) });
}
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
/**
* Copilot Connection Service
*
* Handles the connection and disconnection of Copilot CLI to the app.
* Uses a marker file to track the disconnected state.
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import { createLogger } from '@automaker/utils';
import { COPILOT_DISCONNECTED_MARKER_FILE } from '../routes/setup/common.js';
const logger = createLogger('CopilotConnectionService');
/**
* Get the path to the disconnected marker file
*/
function getMarkerPath(projectRoot?: string): string {
const root = projectRoot || process.cwd();
const automakerDir = path.join(root, '.automaker');
return path.join(automakerDir, COPILOT_DISCONNECTED_MARKER_FILE);
}
/**
* Connect Copilot CLI to the app by removing the disconnected marker
*
* @param projectRoot - Optional project root directory (defaults to cwd)
* @returns Promise that resolves when the connection is established
*/
export async function connectCopilot(projectRoot?: string): Promise<void> {
const markerPath = getMarkerPath(projectRoot);
try {
await fs.unlink(markerPath);
logger.info('Copilot CLI connected to app (marker removed)');
} catch (error) {
// File doesn't exist - that's fine, Copilot is already connected
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error('Failed to remove disconnected marker:', error);
throw error;
}
logger.debug('Copilot already connected (no marker file found)');
}
}
/**
* Disconnect Copilot CLI from the app by creating the disconnected marker
*
* @param projectRoot - Optional project root directory (defaults to cwd)
* @returns Promise that resolves when the disconnection is complete
*/
export async function disconnectCopilot(projectRoot?: string): Promise<void> {
const root = projectRoot || process.cwd();
const automakerDir = path.join(root, '.automaker');
const markerPath = path.join(automakerDir, COPILOT_DISCONNECTED_MARKER_FILE);
// Ensure .automaker directory exists
await fs.mkdir(automakerDir, { recursive: true });
// Create the disconnection marker
await fs.writeFile(markerPath, 'Copilot CLI disconnected from app');
logger.info('Copilot CLI disconnected from app (marker created)');
}
/**
* Check if Copilot CLI is connected (not disconnected)
*
* @param projectRoot - Optional project root directory (defaults to cwd)
* @returns Promise that resolves to true if connected, false if disconnected
*/
export async function isCopilotConnected(projectRoot?: string): Promise<boolean> {
const markerPath = getMarkerPath(projectRoot);
try {
await fs.access(markerPath);
return false; // Marker exists = disconnected
} catch {
return true; // Marker doesn't exist = connected
}
}

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

@@ -169,9 +169,10 @@ export class EventHookService {
}
// Build context for variable substitution
// Use loaded featureName (from feature.title) or fall back to payload.featureName
const context: HookContext = {
featureId: payload.featureId,
featureName: payload.featureName,
featureName: featureName || payload.featureName,
projectPath: payload.projectPath,
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
error: payload.error || payload.message,

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

@@ -23,7 +23,9 @@ import type {
SendMessageOptions,
PromptCategory,
IdeationPrompt,
IdeationContextSources,
} from '@automaker/types';
import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types';
import {
getIdeationDir,
getIdeasDir,
@@ -32,16 +34,22 @@ import {
getIdeationSessionsDir,
getIdeationSessionPath,
getIdeationAnalysisPath,
getAppSpecPath,
ensureIdeationDir,
} from '@automaker/platform';
import { extractXmlElements, extractImplementedFeatures } from '../lib/xml-extractor.js';
import { createLogger, loadContextFiles, isAbortError } from '@automaker/utils';
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');
@@ -634,8 +642,12 @@ export class IdeationService {
projectPath: string,
promptId: string,
category: IdeaCategory,
count: number = 10
count: number = 10,
contextSources?: IdeationContextSources
): Promise<AnalysisSuggestion[]> {
const suggestionCount = Math.min(Math.max(Math.floor(count ?? 10), 1), 20);
// Merge with defaults for backward compatibility
const sources = { ...DEFAULT_IDEATION_CONTEXT_SOURCES, ...contextSources };
validateWorkingDirectory(projectPath);
// Get the prompt
@@ -652,16 +664,26 @@ export class IdeationService {
});
try {
// Load context files
// Load context files (respecting toggle settings)
const contextResult = await loadContextFiles({
projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
includeContextFiles: sources.useContextFiles,
includeMemory: sources.useMemoryFiles,
});
// Build context from multiple sources
let contextPrompt = contextResult.formattedPrompt;
// If no context files, try to gather basic project info
// Add app spec context if enabled
if (sources.useAppSpec) {
const appSpecContext = await this.buildAppSpecContext(projectPath);
if (appSpecContext) {
contextPrompt = contextPrompt ? `${contextPrompt}\n\n${appSpecContext}` : appSpecContext;
}
}
// If no context was found, try to gather basic project info
if (!contextPrompt) {
const projectInfo = await this.gatherBasicProjectInfo(projectPath);
if (projectInfo) {
@@ -669,8 +691,11 @@ export class IdeationService {
}
}
// Gather existing features and ideas to prevent duplicates
const existingWorkContext = await this.gatherExistingWorkContext(projectPath);
// Gather existing features and ideas to prevent duplicates (respecting toggle settings)
const existingWorkContext = await this.gatherExistingWorkContext(projectPath, {
includeFeatures: sources.useExistingFeatures,
includeIdeas: sources.useExistingIdeas,
});
// Get customized prompts from settings
const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]');
@@ -680,12 +705,28 @@ export class IdeationService {
prompts.ideation.suggestionsSystemPrompt,
contextPrompt,
category,
count,
suggestionCount,
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 +741,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 +751,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
};
@@ -732,7 +772,11 @@ export class IdeationService {
}
// Parse the response into structured suggestions
const suggestions = this.parseSuggestionsFromResponse(responseText, category);
const suggestions = this.parseSuggestionsFromResponse(
responseText,
category,
suggestionCount
);
// Emit complete event
this.events.emit('ideation:suggestions', {
@@ -795,40 +839,47 @@ ${contextSection}${existingWorkSection}`;
*/
private parseSuggestionsFromResponse(
response: string,
category: IdeaCategory
category: IdeaCategory,
count: number
): AnalysisSuggestion[] {
try {
// Try to extract JSON from the response
const jsonMatch = response.match(/\[[\s\S]*\]/);
if (!jsonMatch) {
logger.warn('No JSON array found in response, falling back to text parsing');
return this.parseTextResponse(response, category);
return this.parseTextResponse(response, category, count);
}
const parsed = JSON.parse(jsonMatch[0]);
if (!Array.isArray(parsed)) {
return this.parseTextResponse(response, category);
return this.parseTextResponse(response, category, count);
}
return parsed.map((item: any, index: number) => ({
id: this.generateId('sug'),
category,
title: item.title || `Suggestion ${index + 1}`,
description: item.description || '',
rationale: item.rationale || '',
priority: item.priority || 'medium',
relatedFiles: item.relatedFiles || [],
}));
return parsed
.map((item: any, index: number) => ({
id: this.generateId('sug'),
category,
title: item.title || `Suggestion ${index + 1}`,
description: item.description || '',
rationale: item.rationale || '',
priority: item.priority || 'medium',
relatedFiles: item.relatedFiles || [],
}))
.slice(0, count);
} catch (error) {
logger.warn('Failed to parse JSON response:', error);
return this.parseTextResponse(response, category);
return this.parseTextResponse(response, category, count);
}
}
/**
* Fallback: parse text response into suggestions
*/
private parseTextResponse(response: string, category: IdeaCategory): AnalysisSuggestion[] {
private parseTextResponse(
response: string,
category: IdeaCategory,
count: number
): AnalysisSuggestion[] {
const suggestions: AnalysisSuggestion[] = [];
// Try to find numbered items or headers
@@ -888,7 +939,7 @@ ${contextSection}${existingWorkSection}`;
});
}
return suggestions.slice(0, 5); // Max 5 suggestions
return suggestions.slice(0, count);
}
// ============================================================================
@@ -1326,6 +1377,68 @@ ${contextSection}${existingWorkSection}`;
return descriptions[category] || '';
}
/**
* Build context from app_spec.txt for suggestion generation
* Extracts project name, overview, capabilities, and implemented features
*/
private async buildAppSpecContext(projectPath: string): Promise<string> {
try {
const specPath = getAppSpecPath(projectPath);
const specContent = (await secureFs.readFile(specPath, 'utf-8')) as string;
const parts: string[] = [];
parts.push('## App Specification');
// Extract project name
const projectNames = extractXmlElements(specContent, 'project_name');
if (projectNames.length > 0 && projectNames[0]) {
parts.push(`**Project:** ${projectNames[0]}`);
}
// Extract overview
const overviews = extractXmlElements(specContent, 'overview');
if (overviews.length > 0 && overviews[0]) {
parts.push(`**Overview:** ${overviews[0]}`);
}
// Extract core capabilities
const capabilities = extractXmlElements(specContent, 'capability');
if (capabilities.length > 0) {
parts.push('**Core Capabilities:**');
for (const cap of capabilities) {
parts.push(`- ${cap}`);
}
}
// Extract implemented features
const implementedFeatures = extractImplementedFeatures(specContent);
if (implementedFeatures.length > 0) {
parts.push('**Implemented Features:**');
for (const feature of implementedFeatures) {
if (feature.description) {
parts.push(`- ${feature.name}: ${feature.description}`);
} else {
parts.push(`- ${feature.name}`);
}
}
}
// Only return content if we extracted something meaningful
if (parts.length > 1) {
return parts.join('\n');
}
return '';
} catch (error) {
// If file doesn't exist, return empty string silently
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return '';
}
// For other errors, log and return empty string
logger.warn('Failed to build app spec context:', error);
return '';
}
}
/**
* Gather basic project information for context when no context files exist
*/
@@ -1421,11 +1534,15 @@ ${contextSection}${existingWorkSection}`;
* Gather existing features and ideas to prevent duplicate suggestions
* Returns a concise list of titles grouped by status to avoid polluting context
*/
private async gatherExistingWorkContext(projectPath: string): Promise<string> {
private async gatherExistingWorkContext(
projectPath: string,
options?: { includeFeatures?: boolean; includeIdeas?: boolean }
): Promise<string> {
const { includeFeatures = true, includeIdeas = true } = options ?? {};
const parts: string[] = [];
// Load existing features from the board
if (this.featureLoader) {
if (includeFeatures && this.featureLoader) {
try {
const features = await this.featureLoader.getAll(projectPath);
if (features.length > 0) {
@@ -1473,34 +1590,36 @@ ${contextSection}${existingWorkSection}`;
}
// Load existing ideas
try {
const ideas = await this.getIdeas(projectPath);
// Filter out archived ideas
const activeIdeas = ideas.filter((idea) => idea.status !== 'archived');
if (includeIdeas) {
try {
const ideas = await this.getIdeas(projectPath);
// Filter out archived ideas
const activeIdeas = ideas.filter((idea) => idea.status !== 'archived');
if (activeIdeas.length > 0) {
parts.push('## Existing Ideas (Do NOT regenerate these)');
parts.push(
'The following ideas have already been captured. Do NOT suggest similar ideas:\n'
);
if (activeIdeas.length > 0) {
parts.push('## Existing Ideas (Do NOT regenerate these)');
parts.push(
'The following ideas have already been captured. Do NOT suggest similar ideas:\n'
);
// Group by category for organization
const byCategory: Record<string, string[]> = {};
for (const idea of activeIdeas) {
const cat = idea.category || 'feature';
if (!byCategory[cat]) {
byCategory[cat] = [];
// Group by category for organization
const byCategory: Record<string, string[]> = {};
for (const idea of activeIdeas) {
const cat = idea.category || 'feature';
if (!byCategory[cat]) {
byCategory[cat] = [];
}
byCategory[cat].push(idea.title);
}
byCategory[cat].push(idea.title);
}
for (const [category, titles] of Object.entries(byCategory)) {
parts.push(`**${category}:** ${titles.join(', ')}`);
for (const [category, titles] of Object.entries(byCategory)) {
parts.push(`**${category}:** ${titles.join(', ')}`);
}
parts.push('');
}
parts.push('');
} catch (error) {
logger.warn('Failed to load existing ideas:', error);
}
} catch (error) {
logger.warn('Failed to load existing ideas:', error);
}
return parts.join('\n');

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

@@ -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

@@ -325,8 +325,12 @@ describe('codex-provider.ts', () => {
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
// xhigh reasoning effort should have 4x the default timeout (120000ms)
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh);
// xhigh reasoning effort uses 5-minute base timeout (300000ms) for feature generation
// then applies 4x multiplier: 300000 * 4.0 = 1200000ms (20 minutes)
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000;
expect(call.timeout).toBe(
CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh
);
});
it('uses default timeout when no reasoning effort is specified', async () => {

View File

@@ -0,0 +1,517 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { CopilotProvider, CopilotErrorCode } from '@/providers/copilot-provider.js';
// Mock the Copilot SDK
vi.mock('@github/copilot-sdk', () => ({
CopilotClient: vi.fn().mockImplementation(() => ({
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
createSession: vi.fn().mockResolvedValue({
sessionId: 'test-session',
send: vi.fn().mockResolvedValue(undefined),
destroy: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
}),
})),
}));
// Mock child_process with all needed exports
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
execSync: vi.fn(),
};
});
// Mock fs (synchronous) for CLI detection (existsSync)
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
existsSync: vi.fn().mockReturnValue(true),
};
});
// Mock fs/promises
vi.mock('fs/promises', () => ({
access: vi.fn().mockRejectedValue(new Error('Not found')),
readFile: vi.fn().mockRejectedValue(new Error('Not found')),
mkdir: vi.fn().mockResolvedValue(undefined),
}));
// Import execSync after mocking
import { execSync } from 'child_process';
import * as fs from 'fs';
describe('copilot-provider.ts', () => {
let provider: CopilotProvider;
beforeEach(() => {
vi.clearAllMocks();
// Mock fs.existsSync for CLI path validation
vi.mocked(fs.existsSync).mockReturnValue(true);
// Mock CLI detection to find the CLI
// The CliProvider base class uses 'which copilot' (Unix) or 'where copilot' (Windows)
// to find the CLI path, then validates with fs.existsSync
vi.mocked(execSync).mockImplementation((cmd: string) => {
// CLI path detection (which/where command)
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
return '/usr/local/bin/copilot';
}
if (cmd.includes('--version')) {
return '1.0.0';
}
if (cmd.includes('gh auth status')) {
return 'Logged in to github.com account testuser';
}
if (cmd.includes('models list')) {
return JSON.stringify([{ id: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5' }]);
}
return '';
});
provider = new CopilotProvider();
delete process.env.GITHUB_TOKEN;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('getName', () => {
it("should return 'copilot' as provider name", () => {
expect(provider.getName()).toBe('copilot');
});
});
describe('getCliName', () => {
it("should return 'copilot' as CLI name", () => {
expect(provider.getCliName()).toBe('copilot');
});
});
describe('supportsFeature', () => {
it('should support tools feature', () => {
expect(provider.supportsFeature('tools')).toBe(true);
});
it('should support text feature', () => {
expect(provider.supportsFeature('text')).toBe(true);
});
it('should support streaming feature', () => {
expect(provider.supportsFeature('streaming')).toBe(true);
});
it('should NOT support vision feature (not implemented yet)', () => {
expect(provider.supportsFeature('vision')).toBe(false);
});
it('should not support unknown feature', () => {
expect(provider.supportsFeature('unknown')).toBe(false);
});
});
describe('getAvailableModels', () => {
it('should return static model definitions', () => {
const models = provider.getAvailableModels();
expect(Array.isArray(models)).toBe(true);
expect(models.length).toBeGreaterThan(0);
// All models should have required fields
models.forEach((model) => {
expect(model.id).toBeDefined();
expect(model.name).toBeDefined();
expect(model.provider).toBe('copilot');
});
});
it('should include copilot- prefix in model IDs', () => {
const models = provider.getAvailableModels();
models.forEach((model) => {
expect(model.id).toMatch(/^copilot-/);
});
});
});
describe('checkAuth', () => {
it('should return authenticated status when gh CLI is logged in', async () => {
// Set up mocks BEFORE creating provider to ensure CLI detection succeeds
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(execSync).mockImplementation((cmd: string) => {
// CLI path detection (which/where command)
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
return '/usr/local/bin/copilot';
}
if (cmd.includes('--version')) {
return '1.0.0';
}
if (cmd.includes('gh auth status')) {
return 'Logged in to github.com account testuser';
}
return '';
});
// Create fresh provider with the mock in place
const freshProvider = new CopilotProvider();
const status = await freshProvider.checkAuth();
expect(status.authenticated).toBe(true);
expect(status.method).toBe('oauth');
expect(status.login).toBe('testuser');
});
it('should return unauthenticated when gh auth fails', async () => {
// Set up mocks BEFORE creating provider
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(execSync).mockImplementation((cmd: string) => {
// CLI path detection (which/where command)
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
return '/usr/local/bin/copilot';
}
if (cmd.includes('--version')) {
return '1.0.0';
}
if (cmd.includes('gh auth status')) {
throw new Error('Not logged in');
}
if (cmd.includes('copilot auth status')) {
throw new Error('Not logged in');
}
return '';
});
// Create fresh provider with the mock in place
const freshProvider = new CopilotProvider();
const status = await freshProvider.checkAuth();
expect(status.authenticated).toBe(false);
expect(status.method).toBe('none');
});
it('should detect GITHUB_TOKEN environment variable', async () => {
process.env.GITHUB_TOKEN = 'test-token';
// Set up mocks BEFORE creating provider
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(execSync).mockImplementation((cmd: string) => {
// CLI path detection (which/where command)
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
return '/usr/local/bin/copilot';
}
if (cmd.includes('--version')) {
return '1.0.0';
}
if (cmd.includes('gh auth status')) {
throw new Error('Not logged in');
}
if (cmd.includes('copilot auth status')) {
throw new Error('Not logged in');
}
return '';
});
// Create fresh provider with the mock in place
const freshProvider = new CopilotProvider();
const status = await freshProvider.checkAuth();
expect(status.authenticated).toBe(true);
expect(status.method).toBe('oauth');
delete process.env.GITHUB_TOKEN;
});
});
describe('detectInstallation', () => {
it('should detect installed CLI', async () => {
// Set up mocks BEFORE creating provider
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(execSync).mockImplementation((cmd: string) => {
// CLI path detection (which/where command)
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
return '/usr/local/bin/copilot';
}
if (cmd.includes('--version')) {
return '1.2.3';
}
if (cmd.includes('gh auth status')) {
return 'Logged in to github.com account testuser';
}
return '';
});
// Create fresh provider with the mock in place
const freshProvider = new CopilotProvider();
const status = await freshProvider.detectInstallation();
expect(status.installed).toBe(true);
expect(status.version).toBe('1.2.3');
expect(status.authenticated).toBe(true);
});
});
describe('normalizeEvent', () => {
it('should normalize assistant.message event', () => {
const event = {
type: 'assistant.message',
data: { content: 'Hello, world!' },
};
const result = provider.normalizeEvent(event);
expect(result).toEqual({
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Hello, world!' }],
},
});
});
it('should skip assistant.message_delta event', () => {
const event = {
type: 'assistant.message_delta',
data: { delta: 'partial' },
};
const result = provider.normalizeEvent(event);
expect(result).toBeNull();
});
it('should normalize tool.execution_start event', () => {
const event = {
type: 'tool.execution_start',
data: {
toolName: 'read_file',
toolCallId: 'call-123',
input: { path: '/test/file.txt' },
},
};
const result = provider.normalizeEvent(event);
expect(result).toEqual({
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
name: 'Read', // Normalized from read_file
tool_use_id: 'call-123',
input: { path: '/test/file.txt', file_path: '/test/file.txt' }, // Path normalized
},
],
},
});
});
it('should normalize tool.execution_end event', () => {
const event = {
type: 'tool.execution_end',
data: {
toolName: 'read_file',
toolCallId: 'call-123',
result: 'file content',
},
};
const result = provider.normalizeEvent(event);
expect(result).toEqual({
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_result',
tool_use_id: 'call-123',
content: 'file content',
},
],
},
});
});
it('should handle tool.execution_end with error', () => {
const event = {
type: 'tool.execution_end',
data: {
toolName: 'bash',
toolCallId: 'call-456',
error: 'Command failed',
},
};
const result = provider.normalizeEvent(event);
expect(result?.message?.content?.[0]).toMatchObject({
type: 'tool_result',
content: '[ERROR] Command failed',
});
});
it('should normalize session.idle to success result', () => {
const event = { type: 'session.idle' };
const result = provider.normalizeEvent(event);
expect(result).toEqual({
type: 'result',
subtype: 'success',
});
});
it('should normalize session.error to error event', () => {
const event = {
type: 'session.error',
data: { message: 'Something went wrong' },
};
const result = provider.normalizeEvent(event);
expect(result).toEqual({
type: 'error',
error: 'Something went wrong',
});
});
it('should return null for unknown event types', () => {
const event = { type: 'unknown.event' };
const result = provider.normalizeEvent(event);
expect(result).toBeNull();
});
});
describe('mapError', () => {
it('should map authentication errors', () => {
const errorInfo = (provider as any).mapError('not authenticated', null);
expect(errorInfo.code).toBe(CopilotErrorCode.NOT_AUTHENTICATED);
expect(errorInfo.recoverable).toBe(true);
});
it('should map rate limit errors', () => {
const errorInfo = (provider as any).mapError('rate limit exceeded', null);
expect(errorInfo.code).toBe(CopilotErrorCode.RATE_LIMITED);
expect(errorInfo.recoverable).toBe(true);
});
it('should map model unavailable errors', () => {
const errorInfo = (provider as any).mapError('model not available', null);
expect(errorInfo.code).toBe(CopilotErrorCode.MODEL_UNAVAILABLE);
expect(errorInfo.recoverable).toBe(true);
});
it('should map network errors', () => {
const errorInfo = (provider as any).mapError('connection refused', null);
expect(errorInfo.code).toBe(CopilotErrorCode.NETWORK_ERROR);
expect(errorInfo.recoverable).toBe(true);
});
it('should map process crash (exit code 137)', () => {
const errorInfo = (provider as any).mapError('', 137);
expect(errorInfo.code).toBe(CopilotErrorCode.PROCESS_CRASHED);
expect(errorInfo.recoverable).toBe(true);
});
it('should return unknown error for unrecognized errors', () => {
const errorInfo = (provider as any).mapError('some random error', 1);
expect(errorInfo.code).toBe(CopilotErrorCode.UNKNOWN);
expect(errorInfo.recoverable).toBe(false);
});
});
describe('model cache', () => {
it('should indicate when cache is empty', () => {
expect(provider.hasCachedModels()).toBe(false);
});
it('should clear model cache', () => {
provider.clearModelCache();
expect(provider.hasCachedModels()).toBe(false);
});
});
describe('tool name normalization', () => {
it('should normalize read_file to Read', () => {
const event = {
type: 'tool.execution_start',
data: { toolName: 'read_file', toolCallId: 'id', input: {} },
};
const result = provider.normalizeEvent(event);
expect(result?.message?.content?.[0]).toMatchObject({ name: 'Read' });
});
it('should normalize write_file to Write', () => {
const event = {
type: 'tool.execution_start',
data: { toolName: 'write_file', toolCallId: 'id', input: {} },
};
const result = provider.normalizeEvent(event);
expect(result?.message?.content?.[0]).toMatchObject({ name: 'Write' });
});
it('should normalize run_shell to Bash', () => {
const event = {
type: 'tool.execution_start',
data: { toolName: 'run_shell', toolCallId: 'id', input: {} },
};
const result = provider.normalizeEvent(event);
expect(result?.message?.content?.[0]).toMatchObject({ name: 'Bash' });
});
it('should normalize search to Grep', () => {
const event = {
type: 'tool.execution_start',
data: { toolName: 'search', toolCallId: 'id', input: {} },
};
const result = provider.normalizeEvent(event);
expect(result?.message?.content?.[0]).toMatchObject({ name: 'Grep' });
});
it('should normalize todo_write to TodoWrite', () => {
const event = {
type: 'tool.execution_start',
data: {
toolName: 'todo_write',
toolCallId: 'id',
input: {
todos: [{ description: 'Test task', status: 'pending' }],
},
},
};
const result = provider.normalizeEvent(event);
expect(result?.message?.content?.[0]).toMatchObject({ name: 'TodoWrite' });
});
it('should normalize todo content from description', () => {
const event = {
type: 'tool.execution_start',
data: {
toolName: 'todo_write',
toolCallId: 'id',
input: {
todos: [{ description: 'Test task', status: 'pending' }],
},
},
};
const result = provider.normalizeEvent(event);
const todoInput = (result?.message?.content?.[0] as any)?.input;
expect(todoInput.todos[0]).toMatchObject({
content: 'Test task',
status: 'pending',
activeForm: 'Test task',
});
});
it('should map cancelled status to completed', () => {
const event = {
type: 'tool.execution_start',
data: {
toolName: 'todo_write',
toolCallId: 'id',
input: {
todos: [{ description: 'Cancelled task', status: 'cancelled' }],
},
},
};
const result = provider.normalizeEvent(event);
const todoInput = (result?.message?.content?.[0] as any)?.input;
expect(todoInput.todos[0].status).toBe('completed');
});
});
});

View File

@@ -4,6 +4,8 @@ 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';
import { CopilotProvider } from '@/providers/copilot-provider.js';
describe('provider-factory.ts', () => {
let consoleSpy: any;
@@ -11,6 +13,8 @@ describe('provider-factory.ts', () => {
let detectCursorSpy: any;
let detectCodexSpy: any;
let detectOpencodeSpy: any;
let detectGeminiSpy: any;
let detectCopilotSpy: any;
beforeEach(() => {
consoleSpy = {
@@ -30,6 +34,12 @@ describe('provider-factory.ts', () => {
detectOpencodeSpy = vi
.spyOn(OpencodeProvider.prototype, 'detectInstallation')
.mockResolvedValue({ installed: true });
detectGeminiSpy = vi
.spyOn(GeminiProvider.prototype, 'detectInstallation')
.mockResolvedValue({ installed: true });
detectCopilotSpy = vi
.spyOn(CopilotProvider.prototype, 'detectInstallation')
.mockResolvedValue({ installed: true });
});
afterEach(() => {
@@ -38,6 +48,8 @@ describe('provider-factory.ts', () => {
detectCursorSpy.mockRestore();
detectCodexSpy.mockRestore();
detectOpencodeSpy.mockRestore();
detectGeminiSpy.mockRestore();
detectCopilotSpy.mockRestore();
});
describe('getProviderForModel', () => {
@@ -166,9 +178,21 @@ describe('provider-factory.ts', () => {
expect(hasClaudeProvider).toBe(true);
});
it('should return exactly 4 providers', () => {
it('should return exactly 6 providers', () => {
const providers = ProviderFactory.getAllProviders();
expect(providers).toHaveLength(4);
expect(providers).toHaveLength(6);
});
it('should include CopilotProvider', () => {
const providers = ProviderFactory.getAllProviders();
const hasCopilotProvider = providers.some((p) => p instanceof CopilotProvider);
expect(hasCopilotProvider).toBe(true);
});
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 +230,9 @@ 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).toContain('copilot');
expect(keys).toHaveLength(6);
});
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

@@ -315,4 +315,531 @@ describe('auto-mode-service.ts', () => {
expect(duration).toBeLessThan(40);
});
});
describe('detectOrphanedFeatures', () => {
// Helper to mock featureLoader.getAll
const mockFeatureLoaderGetAll = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
(svc as any).featureLoader = { getAll: mockFn };
};
// Helper to mock getExistingBranches
const mockGetExistingBranches = (svc: AutoModeService, branches: string[]) => {
(svc as any).getExistingBranches = vi.fn().mockResolvedValue(new Set(branches));
};
it('should return empty array when no features have branch names', async () => {
const getAllMock = vi.fn().mockResolvedValue([
{ id: 'f1', title: 'Feature 1', description: 'desc', category: 'test' },
{ id: 'f2', title: 'Feature 2', description: 'desc', category: 'test' },
] satisfies Feature[]);
mockFeatureLoaderGetAll(service, getAllMock);
mockGetExistingBranches(service, ['main', 'develop']);
const result = await service.detectOrphanedFeatures('/test/project');
expect(result).toEqual([]);
});
it('should return empty array when all feature branches exist', async () => {
const getAllMock = vi.fn().mockResolvedValue([
{
id: 'f1',
title: 'Feature 1',
description: 'desc',
category: 'test',
branchName: 'feature-1',
},
{
id: 'f2',
title: 'Feature 2',
description: 'desc',
category: 'test',
branchName: 'feature-2',
},
] satisfies Feature[]);
mockFeatureLoaderGetAll(service, getAllMock);
mockGetExistingBranches(service, ['main', 'feature-1', 'feature-2']);
const result = await service.detectOrphanedFeatures('/test/project');
expect(result).toEqual([]);
});
it('should detect orphaned features with missing branches', async () => {
const features: Feature[] = [
{
id: 'f1',
title: 'Feature 1',
description: 'desc',
category: 'test',
branchName: 'feature-1',
},
{
id: 'f2',
title: 'Feature 2',
description: 'desc',
category: 'test',
branchName: 'deleted-branch',
},
{ id: 'f3', title: 'Feature 3', description: 'desc', category: 'test' }, // No branch
];
const getAllMock = vi.fn().mockResolvedValue(features);
mockFeatureLoaderGetAll(service, getAllMock);
mockGetExistingBranches(service, ['main', 'feature-1']); // deleted-branch not in list
const result = await service.detectOrphanedFeatures('/test/project');
expect(result).toHaveLength(1);
expect(result[0].feature.id).toBe('f2');
expect(result[0].missingBranch).toBe('deleted-branch');
});
it('should detect multiple orphaned features', async () => {
const features: Feature[] = [
{
id: 'f1',
title: 'Feature 1',
description: 'desc',
category: 'test',
branchName: 'orphan-1',
},
{
id: 'f2',
title: 'Feature 2',
description: 'desc',
category: 'test',
branchName: 'orphan-2',
},
{
id: 'f3',
title: 'Feature 3',
description: 'desc',
category: 'test',
branchName: 'valid-branch',
},
];
const getAllMock = vi.fn().mockResolvedValue(features);
mockFeatureLoaderGetAll(service, getAllMock);
mockGetExistingBranches(service, ['main', 'valid-branch']);
const result = await service.detectOrphanedFeatures('/test/project');
expect(result).toHaveLength(2);
expect(result.map((r) => r.feature.id)).toContain('f1');
expect(result.map((r) => r.feature.id)).toContain('f2');
});
it('should return empty array when getAll throws error', async () => {
const getAllMock = vi.fn().mockRejectedValue(new Error('Failed to load features'));
mockFeatureLoaderGetAll(service, getAllMock);
const result = await service.detectOrphanedFeatures('/test/project');
expect(result).toEqual([]);
});
it('should ignore empty branchName strings', async () => {
const features: Feature[] = [
{ id: 'f1', title: 'Feature 1', description: 'desc', category: 'test', branchName: '' },
{ id: 'f2', title: 'Feature 2', description: 'desc', category: 'test', branchName: ' ' },
];
const getAllMock = vi.fn().mockResolvedValue(features);
mockFeatureLoaderGetAll(service, getAllMock);
mockGetExistingBranches(service, ['main']);
const result = await service.detectOrphanedFeatures('/test/project');
expect(result).toEqual([]);
});
it('should skip features whose branchName matches the primary branch', async () => {
const features: Feature[] = [
{ id: 'f1', title: 'Feature 1', description: 'desc', category: 'test', branchName: 'main' },
{
id: 'f2',
title: 'Feature 2',
description: 'desc',
category: 'test',
branchName: 'orphaned',
},
];
const getAllMock = vi.fn().mockResolvedValue(features);
mockFeatureLoaderGetAll(service, getAllMock);
mockGetExistingBranches(service, ['main', 'develop']);
// Mock getCurrentBranch to return 'main'
(service as any).getCurrentBranch = vi.fn().mockResolvedValue('main');
const result = await service.detectOrphanedFeatures('/test/project');
// Only f2 should be orphaned (orphaned branch doesn't exist)
expect(result).toHaveLength(1);
expect(result[0].feature.id).toBe('f2');
});
});
describe('markFeatureInterrupted', () => {
// Helper to mock updateFeatureStatus
const mockUpdateFeatureStatus = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
(svc as any).updateFeatureStatus = mockFn;
};
// Helper to mock loadFeature
const mockLoadFeature = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
(svc as any).loadFeature = mockFn;
};
it('should call updateFeatureStatus with interrupted status for non-pipeline features', async () => {
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'in_progress' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123');
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted');
});
it('should call updateFeatureStatus with reason when provided', async () => {
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'in_progress' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123', 'server shutdown');
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted');
});
it('should propagate errors from updateFeatureStatus', async () => {
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'in_progress' });
const updateMock = vi.fn().mockRejectedValue(new Error('Update failed'));
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await expect(service.markFeatureInterrupted('/test/project', 'feature-123')).rejects.toThrow(
'Update failed'
);
});
it('should preserve pipeline_implementation status instead of marking as interrupted', async () => {
const loadMock = vi
.fn()
.mockResolvedValue({ id: 'feature-123', status: 'pipeline_implementation' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123', 'server shutdown');
// updateFeatureStatus should NOT be called for pipeline statuses
expect(updateMock).not.toHaveBeenCalled();
});
it('should preserve pipeline_testing status instead of marking as interrupted', async () => {
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'pipeline_testing' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123');
expect(updateMock).not.toHaveBeenCalled();
});
it('should preserve pipeline_review status instead of marking as interrupted', async () => {
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'pipeline_review' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123');
expect(updateMock).not.toHaveBeenCalled();
});
it('should mark feature as interrupted when loadFeature returns null', async () => {
const loadMock = vi.fn().mockResolvedValue(null);
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123');
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted');
});
it('should mark feature as interrupted for pending status', async () => {
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'pending' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123');
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted');
});
});
describe('markAllRunningFeaturesInterrupted', () => {
// Helper to access private runningFeatures Map
const getRunningFeaturesMap = (svc: AutoModeService) =>
(svc as any).runningFeatures as Map<
string,
{ featureId: string; projectPath: string; isAutoMode: boolean }
>;
// Helper to mock updateFeatureStatus
const mockUpdateFeatureStatus = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
(svc as any).updateFeatureStatus = mockFn;
};
// Helper to mock loadFeature
const mockLoadFeature = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
(svc as any).loadFeature = mockFn;
};
it('should do nothing when no features are running', async () => {
const updateMock = vi.fn().mockResolvedValue(undefined);
mockUpdateFeatureStatus(service, updateMock);
await service.markAllRunningFeaturesInterrupted();
expect(updateMock).not.toHaveBeenCalled();
});
it('should mark a single running feature as interrupted', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1',
projectPath: '/project/path',
isAutoMode: true,
});
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-1', status: 'in_progress' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markAllRunningFeaturesInterrupted();
expect(updateMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'interrupted');
});
it('should mark multiple running features as interrupted', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1',
projectPath: '/project-a',
isAutoMode: true,
});
runningFeaturesMap.set('feature-2', {
featureId: 'feature-2',
projectPath: '/project-b',
isAutoMode: false,
});
runningFeaturesMap.set('feature-3', {
featureId: 'feature-3',
projectPath: '/project-a',
isAutoMode: true,
});
const loadMock = vi.fn().mockResolvedValue({ status: 'in_progress' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markAllRunningFeaturesInterrupted();
expect(updateMock).toHaveBeenCalledTimes(3);
expect(updateMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'interrupted');
expect(updateMock).toHaveBeenCalledWith('/project-b', 'feature-2', 'interrupted');
expect(updateMock).toHaveBeenCalledWith('/project-a', 'feature-3', 'interrupted');
});
it('should mark features in parallel', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
for (let i = 1; i <= 5; i++) {
runningFeaturesMap.set(`feature-${i}`, {
featureId: `feature-${i}`,
projectPath: `/project-${i}`,
isAutoMode: true,
});
}
const loadMock = vi.fn().mockResolvedValue({ status: 'in_progress' });
const callOrder: string[] = [];
const updateMock = vi.fn().mockImplementation(async (_path: string, featureId: string) => {
callOrder.push(featureId);
await new Promise((resolve) => setTimeout(resolve, 10));
});
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
const startTime = Date.now();
await service.markAllRunningFeaturesInterrupted();
const duration = Date.now() - startTime;
expect(updateMock).toHaveBeenCalledTimes(5);
// If executed in parallel, total time should be ~10ms
// If sequential, it would be ~50ms (5 * 10ms)
expect(duration).toBeLessThan(40);
});
it('should continue marking other features when one fails', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1',
projectPath: '/project-a',
isAutoMode: true,
});
runningFeaturesMap.set('feature-2', {
featureId: 'feature-2',
projectPath: '/project-b',
isAutoMode: false,
});
const loadMock = vi.fn().mockResolvedValue({ status: 'in_progress' });
const updateMock = vi
.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('Failed to update'));
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
// Should not throw even though one feature failed
await expect(service.markAllRunningFeaturesInterrupted()).resolves.not.toThrow();
expect(updateMock).toHaveBeenCalledTimes(2);
});
it('should use provided reason in logging', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1',
projectPath: '/project/path',
isAutoMode: true,
});
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-1', status: 'in_progress' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markAllRunningFeaturesInterrupted('manual stop');
expect(updateMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'interrupted');
});
it('should use default reason when none provided', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1',
projectPath: '/project/path',
isAutoMode: true,
});
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-1', status: 'in_progress' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markAllRunningFeaturesInterrupted();
expect(updateMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'interrupted');
});
it('should preserve pipeline statuses for running features', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1',
projectPath: '/project-a',
isAutoMode: true,
});
runningFeaturesMap.set('feature-2', {
featureId: 'feature-2',
projectPath: '/project-b',
isAutoMode: false,
});
runningFeaturesMap.set('feature-3', {
featureId: 'feature-3',
projectPath: '/project-c',
isAutoMode: true,
});
// feature-1 has in_progress (should be interrupted)
// feature-2 has pipeline_testing (should be preserved)
// feature-3 has pipeline_implementation (should be preserved)
const loadMock = vi
.fn()
.mockImplementation(async (_projectPath: string, featureId: string) => {
if (featureId === 'feature-1') return { id: 'feature-1', status: 'in_progress' };
if (featureId === 'feature-2') return { id: 'feature-2', status: 'pipeline_testing' };
if (featureId === 'feature-3')
return { id: 'feature-3', status: 'pipeline_implementation' };
return null;
});
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markAllRunningFeaturesInterrupted();
// Only feature-1 should be marked as interrupted
expect(updateMock).toHaveBeenCalledTimes(1);
expect(updateMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'interrupted');
});
});
describe('isFeatureRunning', () => {
// Helper to access private runningFeatures Map
const getRunningFeaturesMap = (svc: AutoModeService) =>
(svc as any).runningFeatures as Map<
string,
{ featureId: string; projectPath: string; isAutoMode: boolean }
>;
it('should return false when no features are running', () => {
expect(service.isFeatureRunning('feature-123')).toBe(false);
});
it('should return true when the feature is running', () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-123', {
featureId: 'feature-123',
projectPath: '/project/path',
isAutoMode: true,
});
expect(service.isFeatureRunning('feature-123')).toBe(true);
});
it('should return false for non-running feature when others are running', () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-other', {
featureId: 'feature-other',
projectPath: '/project/path',
isAutoMode: true,
});
expect(service.isFeatureRunning('feature-123')).toBe(false);
});
it('should correctly track multiple running features', () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1',
projectPath: '/project-a',
isAutoMode: true,
});
runningFeaturesMap.set('feature-2', {
featureId: 'feature-2',
projectPath: '/project-b',
isAutoMode: false,
});
expect(service.isFeatureRunning('feature-1')).toBe(true);
expect(service.isFeatureRunning('feature-2')).toBe(true);
expect(service.isFeatureRunning('feature-3')).toBe(false);
});
});
});

View File

@@ -1,18 +1,11 @@
import { describe, it, expect } from 'vitest';
import type { ParsedTask } from '@automaker/types';
/**
* Test the task parsing logic by reimplementing the parsing functions
* These mirror the logic in auto-mode-service.ts parseTasksFromSpec and parseTaskLine
*/
interface ParsedTask {
id: string;
description: string;
filePath?: string;
phase?: string;
status: 'pending' | 'in_progress' | 'completed';
}
function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
// Match pattern: - [ ] T###: Description | File: path
const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/);
@@ -342,4 +335,236 @@ Some other text
expect(fullModeOutput).toContain('[SPEC_GENERATED]');
});
});
describe('detectSpecFallback - non-Claude model support', () => {
/**
* Reimplementation of detectSpecFallback for testing
* This mirrors the logic in auto-mode-service.ts for detecting specs
* when the [SPEC_GENERATED] marker is missing (common with non-Claude models)
*/
function detectSpecFallback(text: string): boolean {
// Check for key structural elements of a spec
const hasTasksBlock = /```tasks[\s\S]*```/.test(text);
const hasTaskLines = /- \[ \] T\d{3}:/.test(text);
// Check for common spec sections (case-insensitive)
const hasAcceptanceCriteria = /acceptance criteria/i.test(text);
const hasTechnicalContext = /technical context/i.test(text);
const hasProblemStatement = /problem statement/i.test(text);
const hasUserStory = /user story/i.test(text);
// Additional patterns for different model outputs
const hasGoal = /\*\*Goal\*\*:/i.test(text);
const hasSolution = /\*\*Solution\*\*:/i.test(text);
const hasImplementation = /implementation\s*(plan|steps|approach)/i.test(text);
const hasOverview = /##\s*(overview|summary)/i.test(text);
// Spec is detected if we have task structure AND at least some spec content
const hasTaskStructure = hasTasksBlock || hasTaskLines;
const hasSpecContent =
hasAcceptanceCriteria ||
hasTechnicalContext ||
hasProblemStatement ||
hasUserStory ||
hasGoal ||
hasSolution ||
hasImplementation ||
hasOverview;
return hasTaskStructure && hasSpecContent;
}
it('should detect spec with tasks block and acceptance criteria', () => {
const content = `
## Acceptance Criteria
- GIVEN a user, WHEN they login, THEN they see the dashboard
\`\`\`tasks
- [ ] T001: Create login form | File: src/Login.tsx
\`\`\`
`;
expect(detectSpecFallback(content)).toBe(true);
});
it('should detect spec with task lines and problem statement', () => {
const content = `
## Problem Statement
Users cannot currently log in to the application.
## Implementation Plan
- [ ] T001: Add authentication endpoint
- [ ] T002: Create login UI
`;
expect(detectSpecFallback(content)).toBe(true);
});
it('should detect spec with Goal section (lite planning mode style)', () => {
const content = `
**Goal**: Implement user authentication
**Solution**: Use JWT tokens for session management
- [ ] T001: Setup auth middleware
- [ ] T002: Create token service
`;
expect(detectSpecFallback(content)).toBe(true);
});
it('should detect spec with User Story format', () => {
const content = `
## User Story
As a user, I want to reset my password, so that I can regain access.
## Technical Context
This will modify the auth module.
\`\`\`tasks
- [ ] T001: Add reset endpoint
\`\`\`
`;
expect(detectSpecFallback(content)).toBe(true);
});
it('should detect spec with Overview section', () => {
const content = `
## Overview
This feature adds dark mode support.
\`\`\`tasks
- [ ] T001: Add theme toggle
- [ ] T002: Update CSS variables
\`\`\`
`;
expect(detectSpecFallback(content)).toBe(true);
});
it('should detect spec with Summary section', () => {
const content = `
## Summary
Adding a new dashboard component.
- [ ] T001: Create dashboard layout
- [ ] T002: Add widgets
`;
expect(detectSpecFallback(content)).toBe(true);
});
it('should detect spec with implementation plan', () => {
const content = `
## Implementation Plan
We will add the feature in two phases.
- [ ] T001: Phase 1 setup
- [ ] T002: Phase 2 implementation
`;
expect(detectSpecFallback(content)).toBe(true);
});
it('should detect spec with implementation steps', () => {
const content = `
## Implementation Steps
Follow these steps:
- [ ] T001: Step one
- [ ] T002: Step two
`;
expect(detectSpecFallback(content)).toBe(true);
});
it('should detect spec with implementation approach', () => {
const content = `
## Implementation Approach
We will use a modular approach.
- [ ] T001: Create modules
`;
expect(detectSpecFallback(content)).toBe(true);
});
it('should NOT detect spec without task structure', () => {
const content = `
## Problem Statement
Users cannot log in.
## Acceptance Criteria
- GIVEN a user, WHEN they try to login, THEN it works
`;
expect(detectSpecFallback(content)).toBe(false);
});
it('should NOT detect spec without spec content sections', () => {
const content = `
Here are some tasks:
- [ ] T001: Do something
- [ ] T002: Do another thing
`;
expect(detectSpecFallback(content)).toBe(false);
});
it('should NOT detect random text as spec', () => {
const content = 'Just some random text without any structure';
expect(detectSpecFallback(content)).toBe(false);
});
it('should handle case-insensitive matching for spec sections', () => {
const content = `
## ACCEPTANCE CRITERIA
All caps section header
- [ ] T001: Task
`;
expect(detectSpecFallback(content)).toBe(true);
const content2 = `
## acceptance criteria
Lower case section header
- [ ] T001: Task
`;
expect(detectSpecFallback(content2)).toBe(true);
});
it('should detect OpenAI-style output without explicit marker', () => {
// Non-Claude models may format specs differently but still have the key elements
const openAIStyleOutput = `
# Feature Specification: User Authentication
**Goal**: Allow users to securely log into the application
**Solution**: Implement JWT-based authentication with refresh tokens
## Acceptance Criteria
1. Users can log in with email and password
2. Invalid credentials show error message
3. Sessions persist across page refreshes
## Implementation Tasks
\`\`\`tasks
- [ ] T001: Create auth service | File: src/services/auth.ts
- [ ] T002: Build login component | File: src/components/Login.tsx
- [ ] T003: Add protected routes | File: src/App.tsx
\`\`\`
`;
expect(detectSpecFallback(openAIStyleOutput)).toBe(true);
});
it('should detect Gemini-style output without explicit marker', () => {
const geminiStyleOutput = `
## Overview
This specification describes the implementation of a user profile page.
## Technical Context
- Framework: React
- State: Redux
## Tasks
- [ ] T001: Create ProfilePage component
- [ ] T002: Add profile API endpoint
- [ ] T003: Style the profile page
`;
expect(detectSpecFallback(geminiStyleOutput)).toBe(true);
});
});
});

View File

@@ -30,11 +30,16 @@ import net from 'net';
describe('dev-server-service.ts', () => {
let testDir: string;
let originalHostname: string | undefined;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
// Store and set HOSTNAME for consistent test behavior
originalHostname = process.env.HOSTNAME;
process.env.HOSTNAME = 'localhost';
testDir = path.join(os.tmpdir(), `dev-server-test-${Date.now()}`);
await fs.mkdir(testDir, { recursive: true });
@@ -56,6 +61,13 @@ describe('dev-server-service.ts', () => {
});
afterEach(async () => {
// Restore original HOSTNAME
if (originalHostname === undefined) {
delete process.env.HOSTNAME;
} else {
process.env.HOSTNAME = originalHostname;
}
try {
await fs.rm(testDir, { recursive: true, force: true });
} catch {

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

@@ -15,7 +15,7 @@ import type {
} from '@automaker/types';
import { ProviderFactory } from '@/providers/provider-factory.js';
// Create a shared mock logger instance for assertions using vi.hoisted
// Create shared mock instances for assertions using vi.hoisted
const mockLogger = vi.hoisted(() => ({
info: vi.fn(),
error: vi.fn(),
@@ -23,6 +23,13 @@ const mockLogger = vi.hoisted(() => ({
debug: vi.fn(),
}));
const mockCreateChatOptions = vi.hoisted(() =>
vi.fn(() => ({
model: 'claude-sonnet-4-20250514',
systemPrompt: 'test prompt',
}))
);
// Mock dependencies
vi.mock('@/lib/secure-fs.js');
vi.mock('@automaker/platform');
@@ -37,10 +44,7 @@ vi.mock('@automaker/utils', async () => {
});
vi.mock('@/providers/provider-factory.js');
vi.mock('@/lib/sdk-options.js', () => ({
createChatOptions: vi.fn(() => ({
model: 'claude-sonnet-4-20250514',
systemPrompt: 'test prompt',
})),
createChatOptions: mockCreateChatOptions,
validateWorkingDirectory: vi.fn(),
}));
@@ -786,6 +790,143 @@ describe('IdeationService', () => {
service.generateSuggestions(testProjectPath, 'non-existent', 'features', 5)
).rejects.toThrow('Prompt non-existent not found');
});
it('should include app spec context when useAppSpec is enabled', async () => {
const mockAppSpec = `
<project_specification>
<project_name>Test Project</project_name>
<overview>A test application for unit testing</overview>
<core_capabilities>
<capability>User authentication</capability>
<capability>Data visualization</capability>
</core_capabilities>
<implemented_features>
<feature>
<name>Login System</name>
<description>Basic auth with email/password</description>
</feature>
</implemented_features>
</project_specification>
`;
vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt');
// First call returns app spec, subsequent calls return empty JSON
vi.mocked(secureFs.readFile)
.mockResolvedValueOnce(mockAppSpec)
.mockResolvedValue(JSON.stringify({}));
const mockProvider = {
executeQuery: vi.fn().mockReturnValue({
async *[Symbol.asyncIterator]() {
yield {
type: 'result',
subtype: 'success',
result: JSON.stringify([{ title: 'Test', description: 'Test' }]),
};
},
}),
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
const prompts = service.getAllPrompts();
await service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, {
useAppSpec: true,
useContextFiles: false,
useMemoryFiles: false,
useExistingFeatures: false,
useExistingIdeas: false,
});
// Verify createChatOptions was called with systemPrompt containing app spec info
expect(mockCreateChatOptions).toHaveBeenCalled();
const chatOptionsCall = mockCreateChatOptions.mock.calls[0][0];
expect(chatOptionsCall.systemPrompt).toContain('Test Project');
expect(chatOptionsCall.systemPrompt).toContain('A test application for unit testing');
expect(chatOptionsCall.systemPrompt).toContain('User authentication');
expect(chatOptionsCall.systemPrompt).toContain('Login System');
});
it('should exclude app spec context when useAppSpec is disabled', async () => {
const mockAppSpec = `
<project_specification>
<project_name>Hidden Project</project_name>
<overview>This should not appear</overview>
</project_specification>
`;
vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt');
vi.mocked(secureFs.readFile).mockResolvedValue(mockAppSpec);
const mockProvider = {
executeQuery: vi.fn().mockReturnValue({
async *[Symbol.asyncIterator]() {
yield {
type: 'result',
subtype: 'success',
result: JSON.stringify([{ title: 'Test', description: 'Test' }]),
};
},
}),
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
const prompts = service.getAllPrompts();
await service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, {
useAppSpec: false,
useContextFiles: false,
useMemoryFiles: false,
useExistingFeatures: false,
useExistingIdeas: false,
});
// Verify createChatOptions was called with systemPrompt NOT containing app spec info
expect(mockCreateChatOptions).toHaveBeenCalled();
const chatOptionsCall = mockCreateChatOptions.mock.calls[0][0];
expect(chatOptionsCall.systemPrompt).not.toContain('Hidden Project');
expect(chatOptionsCall.systemPrompt).not.toContain('This should not appear');
});
it('should handle missing app spec file gracefully', async () => {
vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt');
const enoentError = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException;
enoentError.code = 'ENOENT';
// First call fails with ENOENT for app spec, subsequent calls return empty JSON
vi.mocked(secureFs.readFile)
.mockRejectedValueOnce(enoentError)
.mockResolvedValue(JSON.stringify({}));
const mockProvider = {
executeQuery: vi.fn().mockReturnValue({
async *[Symbol.asyncIterator]() {
yield {
type: 'result',
subtype: 'success',
result: JSON.stringify([{ title: 'Test', description: 'Test' }]),
};
},
}),
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
const prompts = service.getAllPrompts();
// Should not throw
await expect(
service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, {
useAppSpec: true,
useContextFiles: false,
useMemoryFiles: false,
useExistingFeatures: false,
useExistingIdeas: false,
})
).resolves.toBeDefined();
// Should not log warning for ENOENT
expect(mockLogger.warn).not.toHaveBeenCalled();
});
});
});
});

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

@@ -14,8 +14,13 @@ const eslintConfig = defineConfig([
require: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
},
},
rules: {
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
},
},
{
files: ['**/*.ts', '**/*.tsx'],
@@ -45,6 +50,8 @@ const eslintConfig = defineConfig([
confirm: 'readonly',
getComputedStyle: 'readonly',
requestAnimationFrame: 'readonly',
cancelAnimationFrame: 'readonly',
alert: 'readonly',
// DOM Element Types
HTMLElement: 'readonly',
HTMLInputElement: 'readonly',
@@ -56,6 +63,8 @@ const eslintConfig = defineConfig([
HTMLParagraphElement: 'readonly',
HTMLImageElement: 'readonly',
Element: 'readonly',
SVGElement: 'readonly',
SVGSVGElement: 'readonly',
// Event Types
Event: 'readonly',
KeyboardEvent: 'readonly',
@@ -64,14 +73,24 @@ const eslintConfig = defineConfig([
CustomEvent: 'readonly',
ClipboardEvent: 'readonly',
WheelEvent: 'readonly',
MouseEvent: 'readonly',
UIEvent: 'readonly',
MediaQueryListEvent: 'readonly',
DataTransfer: 'readonly',
// Web APIs
ResizeObserver: 'readonly',
AbortSignal: 'readonly',
AbortController: 'readonly',
IntersectionObserver: 'readonly',
Audio: 'readonly',
HTMLAudioElement: 'readonly',
ScrollBehavior: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
XMLHttpRequest: 'readonly',
Response: 'readonly',
RequestInit: 'readonly',
RequestCache: 'readonly',
// Timers
setTimeout: 'readonly',
setInterval: 'readonly',
@@ -90,6 +109,8 @@ const eslintConfig = defineConfig([
Electron: 'readonly',
// Console
console: 'readonly',
// Vite defines
__APP_VERSION__: 'readonly',
},
},
plugins: {
@@ -99,6 +120,13 @@ const eslintConfig = defineConfig([
...ts.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/ban-ts-comment': [
'error',
{
'ts-nocheck': 'allow-with-description',
minimumDescriptionLength: 10,
},
],
},
},
globalIgnores([

View File

@@ -102,9 +102,12 @@
"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",
"zod": "^3.24.1 || ^4.0.0",
"zustand": "5.0.9"
},
"optionalDependencies": {
@@ -167,6 +170,10 @@
"from": "server-bundle/node_modules",
"to": "server/node_modules"
},
{
"from": "server-bundle/libs",
"to": "server/libs"
},
{
"from": "server-bundle/package.json",
"to": "server/package.json"

View File

@@ -29,7 +29,7 @@ async function killProcessOnPort(port) {
try {
await execAsync(`kill -9 ${pid}`);
console.log(`[KillTestServers] Killed process ${pid}`);
} catch (error) {
} catch (_error) {
// Process might have already exited
}
}
@@ -47,7 +47,7 @@ async function killProcessOnPort(port) {
await new Promise((resolve) => setTimeout(resolve, 500));
return;
}
} catch (error) {
} catch (_error) {
// No process on port, which is fine
}
}

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

@@ -6,14 +6,26 @@ import { SplashScreen } from './components/splash-screen';
import { useSettingsSync } from './hooks/use-settings-sync';
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
import { useProviderAuthInit } from './hooks/use-provider-auth-init';
import { useAppStore } from './store/app-store';
import { TooltipProvider } from '@/components/ui/tooltip';
import './styles/global.css';
import './styles/theme-imports';
import './styles/font-imports';
const logger = createLogger('App');
// Key for localStorage to persist splash screen preference
const DISABLE_SPLASH_KEY = 'automaker-disable-splash';
export default function App() {
const disableSplashScreen = useAppStore((state) => state.disableSplashScreen);
const [showSplash, setShowSplash] = useState(() => {
// Check localStorage for user preference (available synchronously)
const savedPreference = localStorage.getItem(DISABLE_SPLASH_KEY);
if (savedPreference === 'true') {
return false;
}
// Only show splash once per session
if (sessionStorage.getItem('automaker-splash-shown')) {
return false;
@@ -21,6 +33,11 @@ export default function App() {
return true;
});
// Sync the disableSplashScreen setting to localStorage for fast access on next startup
useEffect(() => {
localStorage.setItem(DISABLE_SPLASH_KEY, String(disableSplashScreen));
}, [disableSplashScreen]);
// Clear accumulated PerformanceMeasure entries to prevent memory leak in dev mode
// React's internal scheduler creates performance marks/measures that accumulate without cleanup
useEffect(() => {
@@ -59,9 +76,9 @@ export default function App() {
}, []);
return (
<>
<TooltipProvider delayDuration={300}>
<RouterProvider router={router} />
{showSplash && <SplashScreen onComplete={handleSplashComplete} />}
</>
{showSplash && !disableSplashScreen && <SplashScreen onComplete={handleSplashComplete} />}
</TooltipProvider>
);
}

View File

@@ -0,0 +1,10 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 28C14 26.0633 13.6267 24.2433 12.88 22.54C12.1567 20.8367 11.165 19.355 9.905 18.095C8.645 16.835 7.16333 15.8433 5.46 15.12C3.75667 14.3733 1.93667 14 0 14C1.93667 14 3.75667 13.6383 5.46 12.915C7.16333 12.1683 8.645 11.165 9.905 9.905C11.165 8.645 12.1567 7.16333 12.88 5.46C13.6267 3.75667 14 1.93667 14 0C14 1.93667 14.3617 3.75667 15.085 5.46C15.8317 7.16333 16.835 8.645 18.095 9.905C19.355 11.165 20.8367 12.1683 22.54 12.915C24.2433 13.6383 26.0633 14 28 14C26.0633 14 24.2433 14.3733 22.54 15.12C20.8367 15.8433 19.355 16.835 18.095 18.095C16.835 19.355 15.8317 20.8367 15.085 22.54C14.3617 24.2433 14 26.0633 14 28Z" fill="url(#paint0_radial_16771_53212)"/>
<defs>
<radialGradient id="paint0_radial_16771_53212" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(2.77876 11.3795) rotate(18.6832) scale(29.8025 238.737)">
<stop offset="0.0671246" stop-color="#9168C0"/>
<stop offset="0.342551" stop-color="#5684D1"/>
<stop offset="0.672076" stop-color="#1BA1E3"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -68,7 +68,6 @@ export function CodexUsagePopover() {
// Use React Query for data fetching with automatic polling
const {
data: codexUsage,
isLoading,
isFetching,
error: queryError,
dataUpdatedAt,

View File

@@ -40,8 +40,6 @@ interface FileBrowserDialogProps {
initialPath?: string;
}
const MAX_RECENT_FOLDERS = 5;
export function FileBrowserDialog({
open,
onOpenChange,

View File

@@ -191,7 +191,7 @@ export function NewProjectModal({
// Use platform-specific path separator
const pathSep =
typeof window !== 'undefined' && (window as any).electronAPI
typeof window !== 'undefined' && window.electronAPI
? navigator.platform.indexOf('Win') !== -1
? '\\'
: '/'

View File

@@ -15,6 +15,7 @@ import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Project } from '@/lib/electron';
import { IconPicker } from './icon-picker';
import { toast } from 'sonner';
interface EditProjectDialogProps {
project: Project;
@@ -25,9 +26,9 @@ interface EditProjectDialogProps {
export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDialogProps) {
const { setProjectName, setProjectIcon, setProjectCustomIcon } = useAppStore();
const [name, setName] = useState(project.name);
const [icon, setIcon] = useState<string | null>((project as any).icon || null);
const [icon, setIcon] = useState<string | null>(project.icon || null);
const [customIconPath, setCustomIconPath] = useState<string | null>(
(project as any).customIconPath || null
project.customIconPath || null
);
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -36,10 +37,10 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
if (name.trim() !== project.name) {
setProjectName(project.id, name.trim());
}
if (icon !== (project as any).icon) {
if (icon !== project.icon) {
setProjectIcon(project.id, icon);
}
if (customIconPath !== (project as any).customIconPath) {
if (customIconPath !== project.customIconPath) {
setProjectCustomIcon(project.id, customIconPath);
}
onOpenChange(false);
@@ -52,11 +53,18 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
toast.error(
`Invalid file type: ${file.type || 'unknown'}. Please use JPG, PNG, GIF or WebP.`
);
return;
}
// Validate file size (max 2MB for icons)
if (file.size > 2 * 1024 * 1024) {
// Validate file size (max 5MB for icons - allows animated GIFs)
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
toast.error(
`File too large (${(file.size / 1024 / 1024).toFixed(2)} MB). Maximum size is 5 MB.`
);
return;
}
@@ -72,15 +80,24 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
file.type,
project.path
);
if (result.success && result.path) {
setCustomIconPath(result.path);
// Clear the Lucide icon when custom icon is set
setIcon(null);
toast.success('Icon uploaded successfully');
} else {
toast.error('Failed to upload icon');
}
setIsUploadingIcon(false);
};
reader.onerror = () => {
toast.error('Failed to read file');
setIsUploadingIcon(false);
};
reader.readAsDataURL(file);
} catch {
toast.error('Failed to upload icon');
setIsUploadingIcon(false);
}
};
@@ -162,7 +179,7 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
</Button>
<p className="text-xs text-muted-foreground mt-1">
PNG, JPG, GIF or WebP. Max 2MB.
PNG, JPG, GIF or WebP. Max 5MB.
</p>
</div>
</div>

View File

@@ -3,7 +3,7 @@
*/
import { useCallback } from 'react';
import { Bell, Check, Trash2, ExternalLink } from 'lucide-react';
import { Bell, Check, Trash2 } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import { useNotificationsStore } from '@/store/notifications-store';
import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';

View File

@@ -59,7 +59,7 @@ interface ThemeButtonProps {
/** Handler for pointer leave events (used to clear preview) */
onPointerLeave: (e: React.PointerEvent) => void;
/** Handler for click events (used to select theme) */
onClick: () => void;
onClick: (e: React.MouseEvent) => void;
}
/**
@@ -77,6 +77,7 @@ const ThemeButton = memo(function ThemeButton({
const Icon = option.icon;
return (
<button
type="button"
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onClick={onClick}
@@ -145,7 +146,10 @@ const ThemeColumn = memo(function ThemeColumn({
isSelected={selectedTheme === option.value}
onPointerEnter={() => onPreviewEnter(option.value)}
onPointerLeave={onPreviewLeave}
onClick={() => onSelect(option.value)}
onClick={(e) => {
e.stopPropagation();
onSelect(option.value);
}}
/>
))}
</div>
@@ -193,13 +197,11 @@ export function ProjectContextMenu({
const {
moveProjectToTrash,
theme: globalTheme,
setTheme,
setProjectTheme,
setPreviewTheme,
} = useAppStore();
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
const [removeConfirmed, setRemoveConfirmed] = useState(false);
const themeSubmenuRef = useRef<HTMLDivElement>(null);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -317,13 +319,24 @@ export function ProjectContextMenu({
const handleThemeSelect = useCallback(
(value: ThemeMode | typeof USE_GLOBAL_THEME) => {
// Clear any pending close timeout to prevent race conditions
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
// Close menu first
setShowThemeSubmenu(false);
onClose();
// Then apply theme changes
setPreviewTheme(null);
const isUsingGlobal = value === USE_GLOBAL_THEME;
setTheme(isUsingGlobal ? globalTheme : value);
// Only set project theme - don't change global theme
// The UI uses getEffectiveTheme() which handles: previewTheme ?? projectTheme ?? globalTheme
setProjectTheme(project.id, isUsingGlobal ? null : value);
setShowThemeSubmenu(false);
},
[globalTheme, project.id, setPreviewTheme, setProjectTheme, setTheme]
[onClose, project.id, setPreviewTheme, setProjectTheme]
);
const handleConfirmRemove = useCallback(() => {
@@ -331,7 +344,6 @@ export function ProjectContextMenu({
toast.success('Project removed', {
description: `${project.name} has been removed from your projects list`,
});
setRemoveConfirmed(true);
}, [moveProjectToTrash, project.id, project.name]);
const handleDialogClose = useCallback(
@@ -340,8 +352,6 @@ export function ProjectContextMenu({
// Close the context menu when dialog closes (whether confirmed or cancelled)
// This prevents the context menu from reappearing after dialog interaction
if (!isOpen) {
// Reset confirmation state
setRemoveConfirmed(false);
// Always close the context menu when dialog closes
onClose();
}
@@ -430,9 +440,13 @@ export function ProjectContextMenu({
<div className="p-2">
{/* Use Global Option */}
<button
type="button"
onPointerEnter={() => handlePreviewEnter(globalTheme)}
onPointerLeave={handlePreviewLeave}
onClick={() => handleThemeSelect(USE_GLOBAL_THEME)}
onClick={(e) => {
e.stopPropagation();
handleThemeSelect(USE_GLOBAL_THEME);
}}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { Folder, LucideIcon } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { cn, sanitizeForTestId } from '@/lib/utils';
@@ -19,6 +20,8 @@ export function ProjectSwitcherItem({
onClick,
onContextMenu,
}: ProjectSwitcherItemProps) {
const [imageError, setImageError] = useState(false);
// Convert index to hotkey label: 0 -> "1", 1 -> "2", ..., 8 -> "9", 9 -> "0"
const hotkeyLabel =
hotkeyIndex !== undefined && hotkeyIndex >= 0 && hotkeyIndex <= 9
@@ -35,7 +38,7 @@ export function ProjectSwitcherItem({
};
const IconComponent = getIconComponent();
const hasCustomIcon = !!project.customIconPath;
const hasCustomIcon = !!project.customIconPath && !imageError;
// Combine project.id with sanitized name for uniqueness and readability
// Format: project-switcher-{id}-{sanitizedName}
@@ -74,6 +77,7 @@ export function ProjectSwitcherItem({
'w-8 h-8 rounded-lg object-cover transition-all duration-200',
isActive ? 'ring-1 ring-brand-500/50' : 'group-hover:scale-110'
)}
onError={() => setImageError(true)}
/>
) : (
<IconComponent

View File

@@ -32,7 +32,7 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
'flex items-center gap-3 titlebar-no-drag cursor-pointer group',
!sidebarOpen && 'flex-col gap-1'
)}
onClick={() => navigate({ to: '/dashboard' })}
onClick={() => navigate({ to: '/overview' })}
data-testid="logo-button"
>
{/* Collapsed logo - only shown when sidebar is closed */}

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

@@ -100,14 +100,8 @@ export function ProjectSelectorWithOptions({
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
const {
globalTheme,
setTheme,
setProjectTheme,
setPreviewTheme,
handlePreviewEnter,
handlePreviewLeave,
} = useProjectTheme();
const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } =
useProjectTheme();
if (!sidebarOpen || projects.length === 0) {
return null;
@@ -281,11 +275,8 @@ export function ProjectSelectorWithOptions({
onValueChange={(value) => {
if (currentProject) {
setPreviewTheme(null);
if (value !== '') {
setTheme(value as ThemeMode);
} else {
setTheme(globalTheme);
}
// Only set project theme - don't change global theme
// The UI uses getEffectiveTheme() which handles: previewTheme ?? projectTheme ?? globalTheme
setProjectTheme(
currentProject.id,
value === '' ? null : (value as ThemeMode)

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, 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,9 +37,174 @@ 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 && (
<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>
)}
{/* Settings */}
<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>
{/* Documentation */}
{!hideWiki && (
<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>
)}
{/* Feedback */}
<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>
</div>
</div>
);
}
// Expanded state
return (
<div
className={cn(
@@ -34,71 +217,42 @@ export function SidebarFooter({
>
{/* 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 +260,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 +291,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,407 @@
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, 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: '/overview' });
}, [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]'
)}
>
<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>
{/* Collapsed project icon with dropdown */}
{currentProject && (
<>
<div className="w-full h-px bg-border/40 my-2" />
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<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>
<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,192 +1,390 @@
import { useCallback, useEffect, useRef } from 'react';
import type { NavigateOptions } from '@tanstack/react-router';
import { ChevronDown, Wrench, Github, Folder } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
import { formatShortcut, useAppStore } from '@/store/app-store';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import type { NavSection } from '../types';
import type { Project } from '@/lib/electron';
import type { SidebarStyle } from '@automaker/types';
import { Spinner } from '@/components/ui/spinner';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, 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;
sidebarOpen: boolean;
sidebarStyle: SidebarStyle;
navSections: NavSection[];
isActiveRoute: (id: string) => boolean;
navigate: (opts: NavigateOptions) => void;
onScrollStateChange?: (canScrollDown: boolean) => void;
}
export function SidebarNavigation({
currentProject,
sidebarOpen,
sidebarStyle,
navSections,
isActiveRoute,
navigate,
onScrollStateChange,
}: SidebarNavigationProps) {
const navRef = useRef<HTMLElement>(null);
// Get collapsed state from store (persisted across restarts)
const { collapsedNavSections, setCollapsedNavSections, toggleNavSection } = useAppStore();
// Initialize collapsed state when sections change (e.g., GitHub section appears)
// Only set defaults for sections that don't have a persisted state
useEffect(() => {
let hasNewSections = false;
const updated = { ...collapsedNavSections };
navSections.forEach((section) => {
if (section.collapsible && section.label && !(section.label in updated)) {
updated[section.label] = section.defaultCollapsed ?? false;
hasNewSections = true;
}
});
if (hasNewSections) {
setCollapsedNavSections(updated);
}
}, [navSections, collapsedNavSections, setCollapsedNavSections]);
// 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, collapsedNavSections]);
// 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 === 'overview')) {
return true;
}
// Show other sections only when project is selected
return !!currentProject;
});
// Get the icon component for the current project
const getProjectIcon = (): LucideIcon => {
if (currentProject?.icon && currentProject.icon in LucideIcons) {
return (LucideIcons as unknown as Record<string, LucideIcon>)[currentProject.icon];
}
return Folder;
};
const ProjectIcon = getProjectIcon();
const hasCustomIcon = !!currentProject?.customIconPath;
return (
<nav
ref={navRef}
className={cn(
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
sidebarOpen ? 'mt-1' : 'mt-1'
// Add top padding in discord mode since there's no header
sidebarStyle === 'discord' ? 'pt-3' : '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>
{/* Project name display for classic/discord mode */}
{sidebarStyle === 'discord' && currentProject && sidebarOpen && (
<div className="mb-3">
<div className="flex items-center gap-2.5 px-3 py-2">
{hasCustomIcon ? (
<img
src={getAuthenticatedImageUrl(currentProject.customIconPath!, currentProject.path)}
alt={currentProject.name}
className="w-5 h-5 rounded object-cover"
/>
) : (
<ProjectIcon className="w-5 h-5 text-brand-500 shrink-0" />
)}
<span className="text-sm font-medium text-foreground truncate">
{currentProject.name}
</span>
</div>
<div className="h-px bg-border/40 mx-1 mt-1" />
</div>
) : currentProject ? (
// Navigation sections when project is selected
navSections.map((section, sectionIdx) => (
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? 'mt-6' : ''}>
{/* Section Label */}
)}
{/* Navigation sections */}
{visibleSections.map((section, sectionIdx) => {
const isCollapsed = section.label ? collapsedNavSections[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">
<span className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
<button
onClick={() => isCollapsible && toggleNavSection(section.label!)}
className={cn(
'group flex items-center w-full px-3 py-1.5 mb-1 rounded-md',
'transition-all duration-200 ease-out',
isCollapsible
? [
'cursor-pointer',
'hover:bg-accent/50 hover:text-foreground',
'border border-transparent hover:border-border/40',
]
: 'cursor-default'
)}
disabled={!isCollapsible}
>
<span
className={cn(
'text-[10px] font-semibold uppercase tracking-widest transition-colors duration-200',
isCollapsible
? 'text-muted-foreground/70 group-hover:text-foreground'
: 'text-muted-foreground/70'
)}
>
{section.label}
</span>
</div>
{isCollapsible && (
<ChevronDown
className={cn(
'w-3 h-3 ml-auto transition-all duration-200',
isCollapsed
? '-rotate-90 text-muted-foreground/50 group-hover:text-muted-foreground'
: 'text-muted-foreground/50 group-hover:text-muted-foreground'
)}
/>
)}
</button>
)}
{/* Section icon with dropdown (collapsed sidebar) */}
{section.label && !sidebarOpen && SectionIcon && section.collapsible && isCollapsed && (
<DropdownMenu>
<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>
<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

@@ -9,19 +9,15 @@ export const ThemeMenuItem = memo(function ThemeMenuItem({
}: ThemeMenuItemProps) {
const Icon = option.icon;
return (
<div
key={option.value}
<DropdownMenuRadioItem
value={option.value}
data-testid={`project-theme-${option.value}`}
className="text-xs py-1.5"
onPointerEnter={() => onPreviewEnter(option.value)}
onPointerLeave={onPreviewLeave}
>
<DropdownMenuRadioItem
value={option.value}
data-testid={`project-theme-${option.value}`}
className="text-xs py-1.5"
>
<Icon className="w-3.5 h-3.5 mr-1.5" style={{ color: option.color }} />
<span>{option.label}</span>
</DropdownMenuRadioItem>
</div>
<Icon className="w-3.5 h-3.5 mr-1.5" style={{ color: option.color }} />
<span>{option.label}</span>
</DropdownMenuRadioItem>
);
});

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 (links to projects overview)
{
label: '',
items: [
{
id: 'overview',
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();
@@ -46,6 +53,7 @@ export function Sidebar() {
trashedProjects,
currentProject,
sidebarOpen,
sidebarStyle,
mobileSidebarHidden,
projectHistory,
upsertAndSetCurrentProject,
@@ -59,12 +67,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 +82,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 +146,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 +180,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 +209,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 +222,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 +240,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 +249,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 +277,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 +335,7 @@ export function Sidebar() {
data-testid="sidebar-backdrop"
/>
)}
<aside
className={cn(
'flex-shrink-0 flex flex-col z-30',
@@ -277,9 +347,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"
>
@@ -310,30 +382,45 @@ export function Sidebar() {
)}
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader
sidebarOpen={sidebarOpen}
currentProject={currentProject}
onClose={toggleSidebar}
onExpand={toggleSidebar}
/>
{/* Only show header in unified mode - in discord mode, ProjectSwitcher has the logo */}
{sidebarStyle === 'unified' && (
<SidebarHeader
sidebarOpen={sidebarOpen}
currentProject={currentProject}
onNewProject={handleNewProject}
onOpenFolder={handleOpenFolder}
onProjectContextMenu={handleContextMenu}
/>
)}
<SidebarNavigation
currentProject={currentProject}
sidebarOpen={sidebarOpen}
sidebarStyle={sidebarStyle}
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 +479,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

@@ -1,8 +1,4 @@
import * as React from 'react';
import { Settings2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useAppStore } from '@/store/app-store';
import type { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
@@ -74,12 +70,6 @@ export function ModelOverrideTrigger({
lg: 'h-10 w-10',
};
const iconSizes = {
sm: 'w-3.5 h-3.5',
md: 'w-4 h-4',
lg: 'w-5 h-5',
};
// For icon variant, wrap PhaseModelSelector and hide text/chevron with CSS
if (variant === 'icon') {
return (

View File

@@ -37,16 +37,6 @@ function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
return entry;
}
/**
* Extract model string from PhaseModelEntry or string
*/
function extractModel(entry: PhaseModelEntry | string): ModelId {
if (typeof entry === 'string') {
return entry as ModelId;
}
return entry.model;
}
/**
* Hook for managing model overrides per phase
*

View File

@@ -3,7 +3,7 @@ import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Spinner } from '@/components/ui/spinner';
import { Spinner, type SpinnerVariant } from '@/components/ui/spinner';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
@@ -37,9 +37,19 @@ const buttonVariants = cva(
}
);
// Loading spinner component
function ButtonSpinner({ className }: { className?: string }) {
return <Spinner size="sm" className={className} />;
/** Button variants that have colored backgrounds requiring foreground spinner color */
const COLORED_BACKGROUND_VARIANTS = new Set<string>(['default', 'destructive']);
/** Get spinner variant based on button variant - use foreground for colored backgrounds */
function getSpinnerVariant(
buttonVariant: VariantProps<typeof buttonVariants>['variant']
): SpinnerVariant {
const variant = buttonVariant ?? 'default';
if (COLORED_BACKGROUND_VARIANTS.has(variant)) {
return 'foreground';
}
// outline, secondary, ghost, link, animated-outline use standard backgrounds
return 'primary';
}
function Button({
@@ -57,6 +67,7 @@ function Button({
loading?: boolean;
}) {
const isDisabled = disabled || loading;
const spinnerVariant = getSpinnerVariant(variant);
// Special handling for animated-outline variant
if (variant === 'animated-outline' && !asChild) {
@@ -83,7 +94,7 @@ function Button({
size === 'icon' && 'p-0 gap-0'
)}
>
{loading && <ButtonSpinner />}
{loading && <Spinner size="sm" variant={spinnerVariant} />}
{children}
</span>
</button>
@@ -99,7 +110,7 @@ function Button({
disabled={isDisabled}
{...props}
>
{loading && <ButtonSpinner />}
{loading && <Spinner size="sm" variant={spinnerVariant} />}
{children}
</Comp>
);

View File

@@ -1,4 +1,3 @@
import * as React from 'react';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;

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