Compare commits

...

50 Commits

Author SHA1 Message Date
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
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
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
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
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
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
66 changed files with 3247 additions and 589 deletions

View File

@@ -25,6 +25,7 @@ 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/

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

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

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

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

@@ -233,6 +233,7 @@ interface RunningFeature {
abortController: AbortController;
isAutoMode: boolean;
startTime: number;
leaseCount: number;
model?: string;
provider?: ModelProvider;
}
@@ -334,6 +335,54 @@ export class AutoModeService {
this.settingsService = settingsService ?? null;
}
private acquireRunningFeature(params: {
featureId: string;
projectPath: string;
isAutoMode: boolean;
allowReuse?: boolean;
abortController?: AbortController;
}): RunningFeature {
const existing = this.runningFeatures.get(params.featureId);
if (existing) {
if (!params.allowReuse) {
throw new Error('already running');
}
existing.leaseCount = (existing.leaseCount ?? 1) + 1;
return existing;
}
const abortController = params.abortController ?? new AbortController();
const entry: RunningFeature = {
featureId: params.featureId,
projectPath: params.projectPath,
worktreePath: null,
branchName: null,
abortController,
isAutoMode: params.isAutoMode,
startTime: Date.now(),
leaseCount: 1,
};
this.runningFeatures.set(params.featureId, entry);
return entry;
}
private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void {
const entry = this.runningFeatures.get(featureId);
if (!entry) {
return;
}
if (options?.force) {
this.runningFeatures.delete(featureId);
return;
}
entry.leaseCount = (entry.leaseCount ?? 1) - 1;
if (entry.leaseCount <= 0) {
this.runningFeatures.delete(featureId);
}
}
/**
* Track a failure and check if we should pause due to consecutive failures.
* This handles cases where the SDK doesn't return useful error messages.
@@ -1076,24 +1125,17 @@ export class AutoModeService {
providedWorktreePath?: string,
options?: {
continuationPrompt?: string;
/** Internal flag: set to true when called from a method that already tracks the feature */
_calledInternally?: boolean;
}
): Promise<void> {
if (this.runningFeatures.has(featureId)) {
throw new Error('already running');
}
// Add to running features immediately to prevent race conditions
const abortController = new AbortController();
const tempRunningFeature: RunningFeature = {
const tempRunningFeature = this.acquireRunningFeature({
featureId,
projectPath,
worktreePath: null,
branchName: null,
abortController,
isAutoMode,
startTime: Date.now(),
};
this.runningFeatures.set(featureId, tempRunningFeature);
allowReuse: options?._calledInternally,
});
const abortController = tempRunningFeature.abortController;
// Save execution state when feature starts
if (isAutoMode) {
@@ -1130,9 +1172,8 @@ export class AutoModeService {
continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent);
// Recursively call executeFeature with the continuation prompt
// Remove from running features temporarily, it will be added back
this.runningFeatures.delete(featureId);
return this.executeFeature(
// Feature is already tracked, the recursive call will reuse the entry
return await this.executeFeature(
projectPath,
featureId,
useWorktrees,
@@ -1140,6 +1181,7 @@ export class AutoModeService {
providedWorktreePath,
{
continuationPrompt,
_calledInternally: true,
}
);
}
@@ -1149,9 +1191,8 @@ export class AutoModeService {
logger.info(
`Feature ${featureId} has existing context, resuming instead of starting fresh`
);
// Remove from running features temporarily, resumeFeature will add it back
this.runningFeatures.delete(featureId);
return this.resumeFeature(projectPath, featureId, useWorktrees);
// Feature is already tracked, resumeFeature will reuse the entry
return await this.resumeFeature(projectPath, featureId, useWorktrees, true);
}
}
@@ -1401,7 +1442,7 @@ export class AutoModeService {
logger.info(
`Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
);
this.runningFeatures.delete(featureId);
this.releaseRunningFeature(featureId);
// Update execution state after feature completes
if (this.autoLoopRunning && projectPath) {
@@ -1581,7 +1622,7 @@ Complete the pipeline step instructions above. Review the previous work and appl
// Remove from running features immediately to allow resume
// The abort signal will still propagate to stop any ongoing execution
this.runningFeatures.delete(featureId);
this.releaseRunningFeature(featureId, { force: true });
return true;
}
@@ -1589,50 +1630,67 @@ Complete the pipeline step instructions above. Review the previous work and appl
/**
* Resume a feature (continues from saved context)
*/
async resumeFeature(projectPath: string, featureId: string, useWorktrees = false): Promise<void> {
if (this.runningFeatures.has(featureId)) {
throw new Error('already running');
}
// Load feature to check status
const feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
// Check if feature is stuck in a pipeline step
const pipelineInfo = await this.detectPipelineStatus(
projectPath,
async resumeFeature(
projectPath: string,
featureId: string,
useWorktrees = false,
/** Internal flag: set to true when called from a method that already tracks the feature */
_calledInternally = false
): Promise<void> {
this.acquireRunningFeature({
featureId,
(feature.status || '') as FeatureStatusWithPipeline
);
projectPath,
isAutoMode: false,
allowReuse: _calledInternally,
});
if (pipelineInfo.isPipeline) {
// Feature stuck in pipeline - use pipeline resume
return this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo);
}
// Normal resume flow for non-pipeline features
// Check if context exists in .automaker directory
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, 'agent-output.md');
let hasContext = false;
try {
await secureFs.access(contextPath);
hasContext = true;
} catch {
// No context
}
// Load feature to check status
const feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
if (hasContext) {
// Load previous context and continue
const context = (await secureFs.readFile(contextPath, 'utf-8')) as string;
return this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees);
}
// Check if feature is stuck in a pipeline step
const pipelineInfo = await this.detectPipelineStatus(
projectPath,
featureId,
(feature.status || '') as FeatureStatusWithPipeline
);
// No context, start fresh - executeFeature will handle adding to runningFeatures
return this.executeFeature(projectPath, featureId, useWorktrees, false);
if (pipelineInfo.isPipeline) {
// Feature stuck in pipeline - use pipeline resume
// Pass _alreadyTracked to prevent double-tracking
return await this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo);
}
// Normal resume flow for non-pipeline features
// Check if context exists in .automaker directory
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, 'agent-output.md');
let hasContext = false;
try {
await secureFs.access(contextPath);
hasContext = true;
} catch {
// No context
}
if (hasContext) {
// Load previous context and continue
// executeFeatureWithContext -> executeFeature will see feature is already tracked
const context = (await secureFs.readFile(contextPath, 'utf-8')) as string;
return await this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees);
}
// No context, start fresh - executeFeature will see feature is already tracked
return await this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, {
_calledInternally: true,
});
} finally {
this.releaseRunningFeature(featureId);
}
}
/**
@@ -1682,7 +1740,9 @@ Complete the pipeline step instructions above. Review the previous work and appl
// Reset status to in_progress and start fresh
await this.updateFeatureStatus(projectPath, featureId, 'in_progress');
return this.executeFeature(projectPath, featureId, useWorktrees, false);
return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, {
_calledInternally: true,
});
}
// Edge Case 2: Step no longer exists in pipeline config
@@ -1828,17 +1888,14 @@ Complete the pipeline step instructions above. Review the previous work and appl
`[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}`
);
// Add to running features immediately
const abortController = new AbortController();
this.runningFeatures.set(featureId, {
const runningEntry = this.acquireRunningFeature({
featureId,
projectPath,
worktreePath: null, // Will be set below
branchName: feature.branchName ?? null,
abortController,
isAutoMode: false,
startTime: Date.now(),
allowReuse: true,
});
const abortController = runningEntry.abortController;
runningEntry.branchName = feature.branchName ?? null;
try {
// Validate project path
@@ -1863,11 +1920,8 @@ Complete the pipeline step instructions above. Review the previous work and appl
validateWorkingDirectory(workDir);
// Update running feature with worktree info
const runningFeature = this.runningFeatures.get(featureId);
if (runningFeature) {
runningFeature.worktreePath = worktreePath;
runningFeature.branchName = branchName ?? null;
}
runningEntry.worktreePath = worktreePath;
runningEntry.branchName = branchName ?? null;
// Emit resume event
this.emitAutoModeEvent('auto_mode_feature_start', {
@@ -1945,7 +1999,7 @@ Complete the pipeline step instructions above. Review the previous work and appl
});
}
} finally {
this.runningFeatures.delete(featureId);
this.releaseRunningFeature(featureId);
}
}
@@ -1962,11 +2016,12 @@ Complete the pipeline step instructions above. Review the previous work and appl
// Validate project path early for fast failure
validateWorkingDirectory(projectPath);
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
}
const abortController = new AbortController();
const runningEntry = this.acquireRunningFeature({
featureId,
projectPath,
isAutoMode: false,
});
const abortController = runningEntry.abortController;
// Load feature info for context FIRST to get branchName
const feature = await this.loadFeature(projectPath, featureId);
@@ -2048,17 +2103,10 @@ Address the follow-up instructions above. Review the previous work and make the
const provider = ProviderFactory.getProviderNameForModel(model);
logger.info(`Follow-up for feature ${featureId} using model: ${model}, provider: ${provider}`);
this.runningFeatures.set(featureId, {
featureId,
projectPath,
worktreePath,
branchName,
abortController,
isAutoMode: false,
startTime: Date.now(),
model,
provider,
});
runningEntry.worktreePath = worktreePath;
runningEntry.branchName = branchName;
runningEntry.model = model;
runningEntry.provider = provider;
try {
// Update feature status to in_progress BEFORE emitting event
@@ -2206,7 +2254,7 @@ Address the follow-up instructions above. Review the previous work and make the
}
}
} finally {
this.runningFeatures.delete(featureId);
this.releaseRunningFeature(featureId);
}
}
@@ -4225,6 +4273,7 @@ After generating the revised spec, output:
return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, {
continuationPrompt: prompt,
_calledInternally: true,
});
}

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,8 +34,10 @@ 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';
@@ -638,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
@@ -656,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) {
@@ -673,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]');
@@ -684,7 +705,7 @@ export class IdeationService {
prompts.ideation.suggestionsSystemPrompt,
contextPrompt,
category,
count,
suggestionCount,
existingWorkContext
);
@@ -751,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', {
@@ -814,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
@@ -907,7 +939,7 @@ ${contextSection}${existingWorkSection}`;
});
}
return suggestions.slice(0, 5); // Max 5 suggestions
return suggestions.slice(0, count);
}
// ============================================================================
@@ -1345,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
*/
@@ -1440,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) {
@@ -1492,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

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

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

@@ -107,6 +107,7 @@
"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": {

View File

@@ -6,14 +6,25 @@ 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 './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 +32,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(() => {
@@ -61,7 +77,7 @@ export default function App() {
return (
<>
<RouterProvider router={router} />
{showSplash && <SplashScreen onComplete={handleSplashComplete} />}
{showSplash && !disableSplashScreen && <SplashScreen onComplete={handleSplashComplete} />}
</>
);
}

View File

@@ -1,10 +1,11 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import type { NavigateOptions } from '@tanstack/react-router';
import { ChevronDown, Wrench, Github } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
import { formatShortcut, useAppStore } from '@/store/app-store';
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,
@@ -23,6 +24,7 @@ const sectionIcons: Record<string, React.ComponentType<{ className?: string }>>
interface SidebarNavigationProps {
currentProject: Project | null;
sidebarOpen: boolean;
sidebarStyle: SidebarStyle;
navSections: NavSection[];
isActiveRoute: (id: string) => boolean;
navigate: (opts: NavigateOptions) => void;
@@ -32,6 +34,7 @@ interface SidebarNavigationProps {
export function SidebarNavigation({
currentProject,
sidebarOpen,
sidebarStyle,
navSections,
isActiveRoute,
navigate,
@@ -39,21 +42,26 @@ export function SidebarNavigation({
}: SidebarNavigationProps) {
const navRef = useRef<HTMLElement>(null);
// Track collapsed state for each collapsible section
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
// 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(() => {
setCollapsedSections((prev) => {
const updated = { ...prev };
navSections.forEach((section) => {
if (section.collapsible && section.label && !(section.label in updated)) {
updated[section.label] = section.defaultCollapsed ?? false;
}
});
return updated;
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;
}
});
}, [navSections]);
if (hasNewSections) {
setCollapsedNavSections(updated);
}
}, [navSections, collapsedNavSections, setCollapsedNavSections]);
// Check scroll state
const checkScrollState = useCallback(() => {
@@ -77,14 +85,7 @@ export function SidebarNavigation({
nav.removeEventListener('scroll', checkScrollState);
resizeObserver.disconnect();
};
}, [checkScrollState, collapsedSections]);
const toggleSection = useCallback((label: string) => {
setCollapsedSections((prev) => ({
...prev,
[label]: !prev[label],
}));
}, []);
}, [checkScrollState, collapsedNavSections]);
// Filter sections: always show non-project sections, only show project sections when project exists
const visibleSections = navSections.filter((section) => {
@@ -97,10 +98,17 @@ export function SidebarNavigation({
});
return (
<nav ref={navRef} className={cn('flex-1 overflow-y-auto scrollbar-hide px-3 pb-2 mt-1')}>
<nav
ref={navRef}
className={cn(
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
// Add top padding in discord mode since there's no header
sidebarStyle === 'discord' ? 'pt-3' : 'mt-1'
)}
>
{/* Navigation sections */}
{visibleSections.map((section, sectionIdx) => {
const isCollapsed = section.label ? collapsedSections[section.label] : false;
const isCollapsed = section.label ? collapsedNavSections[section.label] : false;
const isCollapsible = section.collapsible && section.label && sidebarOpen;
const SectionIcon = section.label ? sectionIcons[section.label] : null;
@@ -110,21 +118,37 @@ export function SidebarNavigation({
{/* Section Label - clickable if collapsible (expanded sidebar) */}
{section.label && sidebarOpen && (
<button
onClick={() => isCollapsible && toggleSection(section.label!)}
onClick={() => isCollapsible && toggleNavSection(section.label!)}
className={cn(
'flex items-center w-full px-3 mb-1.5',
isCollapsible && 'cursor-pointer hover:text-foreground'
'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="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
<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>
{isCollapsible && (
<ChevronDown
className={cn(
'w-3 h-3 ml-auto text-muted-foreground/50 transition-transform duration-200',
isCollapsed && '-rotate-90'
'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'
)}
/>
)}

View File

@@ -53,6 +53,7 @@ export function Sidebar() {
trashedProjects,
currentProject,
sidebarOpen,
sidebarStyle,
mobileSidebarHidden,
projectHistory,
upsertAndSetCurrentProject,
@@ -381,17 +382,21 @@ export function Sidebar() {
)}
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader
sidebarOpen={sidebarOpen}
currentProject={currentProject}
onNewProject={handleNewProject}
onOpenFolder={handleOpenFolder}
onProjectContextMenu={handleContextMenu}
/>
{/* 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}

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,7 +1,8 @@
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export type SpinnerVariant = 'primary' | 'foreground' | 'muted';
const sizeClasses: Record<SpinnerSize, string> = {
xs: 'h-3 w-3',
@@ -11,9 +12,17 @@ const sizeClasses: Record<SpinnerSize, string> = {
xl: 'h-8 w-8',
};
const variantClasses: Record<SpinnerVariant, string> = {
primary: 'text-primary',
foreground: 'text-primary-foreground',
muted: 'text-muted-foreground',
};
interface SpinnerProps {
/** Size of the spinner */
size?: SpinnerSize;
/** Color variant - use 'foreground' when on primary backgrounds */
variant?: SpinnerVariant;
/** Additional class names */
className?: string;
}
@@ -21,11 +30,12 @@ interface SpinnerProps {
/**
* Themed spinner component using the primary brand color.
* Use this for all loading indicators throughout the app for consistency.
* Use variant='foreground' when placing on primary-colored backgrounds.
*/
export function Spinner({ size = 'md', className }: SpinnerProps) {
export function Spinner({ size = 'md', variant = 'primary', className }: SpinnerProps) {
return (
<Loader2
className={cn(sizeClasses[size], 'animate-spin text-primary', className)}
className={cn(sizeClasses[size], 'animate-spin', variantClasses[variant], className)}
aria-hidden="true"
/>
);

View File

@@ -261,7 +261,7 @@ export function TaskProgressPanel({
)}
>
{isCompleted && <Check className="h-3.5 w-3.5" />}
{isActive && <Spinner size="xs" />}
{isActive && <Spinner size="xs" variant="foreground" />}
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
</div>

View File

@@ -463,6 +463,16 @@ export function BoardView() {
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
// Aggregate running auto tasks across all worktrees for this project
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
const runningAutoTasksAllWorktrees = useMemo(() => {
if (!currentProject?.id) return [];
const prefix = `${currentProject.id}::`;
return Object.entries(autoModeByWorktree)
.filter(([key]) => key.startsWith(prefix))
.flatMap(([, state]) => state.runningTasks ?? []);
}, [autoModeByWorktree, currentProject?.id]);
// Get in-progress features for keyboard shortcuts (needed before actions hook)
// Must be after runningAutoTasks is defined
const inProgressFeaturesForShortcuts = useMemo(() => {
@@ -1372,7 +1382,7 @@ export function BoardView() {
setWorktreeRefreshKey((k) => k + 1);
}}
onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasks}
runningFeatureIds={runningAutoTasksAllWorktrees}
branchCardCounts={branchCardCounts}
features={hookFeatures.map((f) => ({
id: f.id,

View File

@@ -78,7 +78,9 @@ export const KanbanColumn = memo(function KanbanColumn({
)}
>
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', colorClass)} />
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight whitespace-nowrap">
{title}
</h3>
{headerAction}
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
{count}

View File

@@ -132,7 +132,7 @@ const SortableColumnHeader = memo(function SortableColumnHeader({
)}
data-testid={`list-header-${column.id}`}
>
<span>{column.label}</span>
<span className="whitespace-nowrap truncate">{column.label}</span>
<SortIcon column={column.id} sortConfig={sortConfig} />
</div>
);
@@ -156,7 +156,7 @@ const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column
)}
data-testid={`list-header-${column.id}`}
>
<span>{column.label}</span>
<span className="whitespace-nowrap truncate">{column.label}</span>
</div>
);
});

View File

@@ -330,7 +330,7 @@ export function MergeWorktreeDialog({
>
{isLoading ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Merging...
</>
) : (

View File

@@ -0,0 +1,150 @@
export const commitTemplate = {
id: 'commit',
name: 'Commit Changes',
colorClass: 'bg-purple-500/20',
instructions: `## Commit Changes Step
# ⚠️ CRITICAL REQUIREMENT: YOU MUST COMMIT ALL CHANGES USING CONVENTIONAL COMMIT FORMAT ⚠️
**THIS IS NOT OPTIONAL. YOU MUST CREATE AND EXECUTE A GIT COMMIT WITH ALL CHANGES.**
This step requires you to:
1. **REVIEW** all changes made in this feature
2. **CREATE** a conventional commit message
3. **EXECUTE** the git commit command
**You cannot complete this step by only reviewing changes. You MUST execute the git commit command.**
---
### Phase 1: Review Phase
Review all changes made in this feature:
- Review all modified files using \`git status\` and \`git diff\`
- Identify the scope and nature of changes
- Determine the appropriate conventional commit type
- Identify any breaking changes that need to be documented
---
### Phase 2: Commit Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW CREATE AND EXECUTE A GIT COMMIT WITH ALL CHANGES.**
**This is not optional. You must stage all changes and commit them using conventional commit format.**
#### Conventional Commit Format
Follow this format for your commit message:
\`\`\`
<type>(<scope>): <subject>
<body>
<footer>
\`\`\`
#### Commit Types (choose the most appropriate):
- **feat**: A new feature
- **fix**: A bug fix
- **docs**: Documentation only changes
- **style**: Code style changes (formatting, missing semicolons, etc.)
- **refactor**: Code refactoring without changing functionality
- **perf**: Performance improvements
- **test**: Adding or updating tests
- **chore**: Changes to build process, dependencies, or tooling
- **ci**: Changes to CI configuration
- **build**: Changes to build system or dependencies
#### Scope (optional but recommended):
- Component/module name (e.g., \`ui\`, \`server\`, \`auth\`)
- Feature area (e.g., \`board\`, \`pipeline\`, \`agent\`)
- Package name (e.g., \`@automaker/types\`)
#### Subject:
- Use imperative mood: "add" not "added" or "adds"
- First letter lowercase
- No period at the end
- Maximum 72 characters
#### Body (optional but recommended for significant changes):
- Explain the "what" and "why" of the change
- Reference related issues or PRs
- Separate from subject with blank line
- Wrap at 72 characters
#### Footer (optional):
- Breaking changes: \`BREAKING CHANGE: <description>\`
- Issue references: \`Closes #123\`, \`Fixes #456\`
#### Action Steps (You MUST complete these):
1. **Stage All Changes** - PREPARE FOR COMMIT:
- ✅ Run \`git add .\` or \`git add -A\` to stage all changes
- ✅ Verify staged changes with \`git status\`
- ✅ Ensure all relevant changes are staged
2. **Create Commit Message** - FOLLOW CONVENTIONAL COMMIT FORMAT:
- ✅ Determine the appropriate commit type based on changes
- ✅ Identify the scope (component/module/feature)
- ✅ Write a clear, imperative subject line
- ✅ Add a body explaining the changes (if significant)
- ✅ Include breaking changes in footer if applicable
- ✅ Reference related issues if applicable
3. **Execute Commit** - COMMIT THE CHANGES:
- ✅ Run \`git commit -m "<type>(<scope>): <subject>" -m "<body>"\` or use a multi-line commit message
- ✅ Verify the commit was created with \`git log -1\`
- ✅ **EXECUTE THE ACTUAL GIT COMMIT COMMAND**
#### Example Commit Messages:
\`\`\`
feat(ui): add pipeline step commit template
Add a new pipeline step template for committing changes using
conventional commit format. This ensures all commits follow
a consistent pattern for better changelog generation.
Closes #123
\`\`\`
\`\`\`
fix(server): resolve agent session timeout issue
The agent session was timing out prematurely due to incorrect
WebSocket heartbeat configuration. Updated heartbeat interval
to match server expectations.
Fixes #456
\`\`\`
\`\`\`
refactor(pipeline): extract step template logic
Extract step template loading and validation into separate
utility functions to improve code organization and testability.
\`\`\`
---
### Summary Required
After completing BOTH review AND commit phases, provide:
- A summary of all changes that were committed
- **The exact commit message that was used (this proves you executed the commit)**
- The commit hash (if available)
- Any notes about the commit (breaking changes, related issues, etc.)
---
# ⚠️ FINAL REMINDER ⚠️
**Reviewing changes without committing is INCOMPLETE and UNACCEPTABLE.**
**You MUST stage all changes and execute a git commit command.**
**You MUST use conventional commit format for the commit message.**
**You MUST show evidence of the commit execution in your summary.**
**This step is only complete when changes have been committed to git.**`,
};

View File

@@ -4,6 +4,7 @@ import { uxReviewTemplate } from './ux-review';
import { testingTemplate } from './testing';
import { documentationTemplate } from './documentation';
import { optimizationTemplate } from './optimization';
import { commitTemplate } from './commit';
export interface PipelineStepTemplate {
id: string;
@@ -19,6 +20,7 @@ export const STEP_TEMPLATES: PipelineStepTemplate[] = [
testingTemplate,
documentationTemplate,
optimizationTemplate,
commitTemplate,
];
// Helper to get template color class

View File

@@ -210,7 +210,7 @@ export function PlanApprovalDialog({
className="bg-green-600 hover:bg-green-700 text-white"
>
{isLoading ? (
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
) : (
<Check className="w-4 h-4 mr-2" />
)}

View File

@@ -12,7 +12,8 @@ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { Button } from '@/components/ui/button';
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
import { Feature, useAppStore, formatShortcut } from '@/store/app-store';
import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-react';
import { Archive, Settings2, CheckSquare, GripVertical, Plus, CheckCircle2 } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { getColumnsWithPipeline, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types';
@@ -357,35 +358,49 @@ export function KanbanBoard({
contentClassName="perf-contain"
headerAction={
column.id === 'verified' ? (
<div className="flex items-center gap-1">
{columnFeatures.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={onArchiveAllVerified}
data-testid="archive-all-verified-button"
>
<Archive className="w-3 h-3 mr-1" />
Complete All
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 relative"
onClick={onShowCompletedModal}
title={`Completed Features (${completedCount})`}
data-testid="completed-features-button"
>
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
{completedCount > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
{completedCount > 99 ? '99+' : completedCount}
</span>
<TooltipProvider>
<div className="flex items-center gap-1">
{columnFeatures.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={onArchiveAllVerified}
data-testid="archive-all-verified-button"
>
<CheckCircle2 className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Complete All</p>
</TooltipContent>
</Tooltip>
)}
</Button>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 relative"
onClick={onShowCompletedModal}
data-testid="completed-features-button"
>
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
{completedCount > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
{completedCount > 99 ? '99+' : completedCount}
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Completed Features ({completedCount})</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
) : column.id === 'backlog' ? (
<div className="flex items-center gap-1">
<Button

View File

@@ -1,5 +1,16 @@
export { BranchSwitchDropdown } from './branch-switch-dropdown';
export { DevServerLogsPanel } from './dev-server-logs-panel';
export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
export { WorktreeDropdown } from './worktree-dropdown';
export type { WorktreeDropdownProps } from './worktree-dropdown';
export { WorktreeDropdownItem } from './worktree-dropdown-item';
export type { WorktreeDropdownItemProps } from './worktree-dropdown-item';
export {
truncateBranchName,
getPRBadgeStyles,
getChangesBadgeStyles,
getTestStatusStyles,
} from './worktree-indicator-utils';
export type { TestStatus } from './worktree-indicator-utils';
export { WorktreeMobileDropdown } from './worktree-mobile-dropdown';
export { WorktreeTab } from './worktree-tab';

View File

@@ -319,7 +319,7 @@ export function WorktreeActionsDropdown({
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
<span className="flex items-center mr-2">
<Zap className="w-3.5 h-3.5 text-yellow-500" />
<span className="ml-0.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
<span className="ml-1.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
Stop Auto Mode
</DropdownMenuItem>

View File

@@ -0,0 +1,202 @@
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Check, CircleDot, Globe, GitPullRequest, FlaskConical } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, DevServerInfo, TestSessionInfo } from '../types';
import {
truncateBranchName,
getPRBadgeStyles,
getChangesBadgeStyles,
getTestStatusStyles,
} from './worktree-indicator-utils';
/**
* Maximum characters for branch name before truncation in dropdown items.
* Set to 28 to accommodate longer names in the wider dropdown menu while
* still fitting comfortably with all status indicators.
*/
const MAX_ITEM_BRANCH_NAME_LENGTH = 28;
export interface WorktreeDropdownItemProps {
/** The worktree to display */
worktree: WorktreeInfo;
/** Whether this worktree is currently selected */
isSelected: boolean;
/** Whether this worktree has running features/processes */
isRunning: boolean;
/** Number of cards associated with this worktree's branch */
cardCount?: number;
/** Whether the dev server is running for this worktree */
devServerRunning?: boolean;
/** Dev server information if running */
devServerInfo?: DevServerInfo;
/** Whether auto-mode is running for this worktree */
isAutoModeRunning?: boolean;
/** Whether tests are running for this worktree */
isTestRunning?: boolean;
/** Test session info for this worktree */
testSessionInfo?: TestSessionInfo;
/** Callback when the worktree is selected */
onSelect: () => void;
}
/**
* A dropdown menu item component for displaying an individual worktree entry.
*
* Features:
* - Selection indicator (checkmark when selected)
* - Running status indicator (spinner)
* - Branch name with tooltip for long names
* - Main branch badge
* - Dev server status indicator
* - Auto mode indicator
* - Test status indicator
* - Card count badge
* - Uncommitted changes indicator
* - PR status badge
*/
export function WorktreeDropdownItem({
worktree,
isSelected,
isRunning,
cardCount,
devServerRunning,
devServerInfo,
isAutoModeRunning = false,
isTestRunning = false,
testSessionInfo,
onSelect,
}: WorktreeDropdownItemProps) {
const { hasChanges, changedFilesCount, pr } = worktree;
// Truncate long branch names using shared utility
const { truncated: truncatedBranch, isTruncated: isBranchNameTruncated } = truncateBranchName(
worktree.branch,
MAX_ITEM_BRANCH_NAME_LENGTH
);
const branchNameElement = (
<span className={cn('font-mono text-xs truncate', isSelected && 'font-medium')}>
{truncatedBranch}
</span>
);
return (
<DropdownMenuItem
onSelect={onSelect}
className={cn('flex items-center gap-2 cursor-pointer pr-2', isSelected && 'bg-accent')}
aria-current={isSelected ? 'true' : undefined}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{/* Selection indicator */}
{isSelected ? (
<Check className="w-3.5 h-3.5 shrink-0 text-primary" />
) : (
<div className="w-3.5 h-3.5 shrink-0" />
)}
{/* Running indicator */}
{isRunning && <Spinner size="xs" className="shrink-0" />}
{/* Branch name with optional tooltip */}
{isBranchNameTruncated ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{branchNameElement}</TooltipTrigger>
<TooltipContent>
<p className="font-mono text-xs">{worktree.branch}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
branchNameElement
)}
{/* Main badge */}
{worktree.isMain && (
<span className="text-[10px] px-1 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
main
</span>
)}
</div>
{/* Right side indicators - ordered consistently with dropdown trigger */}
<div className="flex items-center gap-1.5 shrink-0">
{/* Card count badge */}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
{cardCount}
</span>
)}
{/* Uncommitted changes indicator */}
{hasChanges && (
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
getChangesBadgeStyles()
)}
title={`${changedFilesCount ?? 'Some'} uncommitted file${changedFilesCount !== 1 ? 's' : ''}`}
>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{changedFilesCount ?? '!'}
</span>
)}
{/* Dev server indicator */}
{devServerRunning && (
<span
className="inline-flex items-center justify-center h-4 w-4 text-green-500"
title={`Dev server running on port ${devServerInfo?.port}`}
>
<Globe className="w-3 h-3" />
</span>
)}
{/* Test running indicator */}
{isTestRunning && (
<span
className="inline-flex items-center justify-center h-4 w-4 text-blue-500"
title="Tests Running"
>
<FlaskConical className="w-3 h-3 animate-pulse" />
</span>
)}
{/* Last test result indicator (when not running) */}
{!isTestRunning && testSessionInfo && (
<span
className={cn(
'inline-flex items-center justify-center h-4 w-4',
getTestStatusStyles(testSessionInfo.status)
)}
title={`Last test: ${testSessionInfo.status}`}
>
<FlaskConical className="w-3 h-3" />
</span>
)}
{/* Auto mode indicator */}
{isAutoModeRunning && (
<span className="flex items-center justify-center h-4 px-0.5" title="Auto Mode Running">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
)}
{/* PR indicator */}
{pr && (
<span
className={cn(
'inline-flex items-center gap-0.5 h-4 px-1 text-[10px] font-medium rounded border',
getPRBadgeStyles(pr.state)
)}
title={`PR #${pr.number}: ${pr.title}`}
>
<GitPullRequest className="w-2.5 h-2.5" />#{pr.number}
</span>
)}
</div>
</DropdownMenuItem>
);
}

View File

@@ -0,0 +1,481 @@
import { useMemo } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuGroup,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
GitBranch,
ChevronDown,
CircleDot,
Globe,
GitPullRequest,
FlaskConical,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type {
WorktreeInfo,
BranchInfo,
DevServerInfo,
PRInfo,
GitRepoStatus,
TestSessionInfo,
} from '../types';
import { WorktreeDropdownItem } from './worktree-dropdown-item';
import { BranchSwitchDropdown } from './branch-switch-dropdown';
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
import {
truncateBranchName,
getPRBadgeStyles,
getChangesBadgeStyles,
getTestStatusStyles,
} from './worktree-indicator-utils';
export interface WorktreeDropdownProps {
/** List of all worktrees to display in the dropdown */
worktrees: WorktreeInfo[];
/** Function to check if a worktree is currently selected */
isWorktreeSelected: (worktree: WorktreeInfo) => boolean;
/** Function to check if a worktree has running features/processes */
hasRunningFeatures: (worktree: WorktreeInfo) => boolean;
/** Whether worktree activation is in progress */
isActivating: boolean;
/** Map of branch names to card counts */
branchCardCounts?: Record<string, number>;
/** Function to check if dev server is running for a worktree */
isDevServerRunning: (worktree: WorktreeInfo) => boolean;
/** Function to get dev server info for a worktree */
getDevServerInfo: (worktree: WorktreeInfo) => DevServerInfo | undefined;
/** Function to check if auto-mode is running for a worktree */
isAutoModeRunningForWorktree: (worktree: WorktreeInfo) => boolean;
/** Function to check if tests are running for a worktree */
isTestRunningForWorktree: (worktree: WorktreeInfo) => boolean;
/** Function to get test session info for a worktree */
getTestSessionInfo: (worktree: WorktreeInfo) => TestSessionInfo | undefined;
/** Callback when a worktree is selected */
onSelectWorktree: (worktree: WorktreeInfo) => void;
// Branch switching props
branches: BranchInfo[];
filteredBranches: BranchInfo[];
branchFilter: string;
isLoadingBranches: boolean;
isSwitching: boolean;
onBranchDropdownOpenChange: (worktree: WorktreeInfo) => (open: boolean) => void;
onBranchFilterChange: (value: string) => void;
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
// Action dropdown props
isPulling: boolean;
isPushing: boolean;
isStartingDevServer: boolean;
aheadCount: number;
behindCount: number;
hasRemoteBranch: boolean;
gitRepoStatus: GitRepoStatus;
hasTestCommand: boolean;
isStartingTests: boolean;
hasInitScript: boolean;
onActionsDropdownOpenChange: (worktree: WorktreeInfo) => (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onPushNewBranch: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onViewChanges: (worktree: WorktreeInfo) => void;
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
onToggleAutoMode: (worktree: WorktreeInfo) => void;
onStartTests: (worktree: WorktreeInfo) => void;
onStopTests: (worktree: WorktreeInfo) => void;
onViewTestLogs: (worktree: WorktreeInfo) => void;
}
/**
* Maximum characters for branch name before truncation in the dropdown trigger.
* Set to 24 to keep the trigger compact while showing enough context for identification.
*/
const MAX_TRIGGER_BRANCH_NAME_LENGTH = 24;
/**
* A dropdown component for displaying and switching between worktrees.
* Used when there are 3+ worktrees to avoid horizontal tab wrapping.
*
* Features:
* - Compact dropdown trigger showing current worktree with indicators
* - Grouped display (main branch + worktrees)
* - Full status indicators (PR, dev server, auto mode, changes)
* - Branch switch dropdown integration
* - Actions dropdown integration
* - Tooltip for truncated branch names
*/
export function WorktreeDropdown({
worktrees,
isWorktreeSelected,
hasRunningFeatures,
isActivating,
branchCardCounts,
isDevServerRunning,
getDevServerInfo,
isAutoModeRunningForWorktree,
isTestRunningForWorktree,
getTestSessionInfo,
onSelectWorktree,
// Branch switching props
branches,
filteredBranches,
branchFilter,
isLoadingBranches,
isSwitching,
onBranchDropdownOpenChange,
onBranchFilterChange,
onSwitchBranch,
onCreateBranch,
// Action dropdown props
isPulling,
isPushing,
isStartingDevServer,
aheadCount,
behindCount,
hasRemoteBranch,
gitRepoStatus,
hasTestCommand,
isStartingTests,
hasInitScript,
onActionsDropdownOpenChange,
onPull,
onPush,
onPushNewBranch,
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onViewChanges,
onDiscardChanges,
onCommit,
onCreatePR,
onAddressPRComments,
onResolveConflicts,
onMerge,
onDeleteWorktree,
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
onViewDevServerLogs,
onRunInitScript,
onToggleAutoMode,
onStartTests,
onStopTests,
onViewTestLogs,
}: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
const displayBranch = selectedWorktree?.branch || 'Select worktree';
const { truncated: truncatedBranch, isTruncated: isBranchNameTruncated } = truncateBranchName(
displayBranch,
MAX_TRIGGER_BRANCH_NAME_LENGTH
);
// Separate main worktree from others for grouping
const mainWorktree = worktrees.find((w) => w.isMain);
const otherWorktrees = worktrees.filter((w) => !w.isMain);
// Get status info for selected worktree - memoized to prevent unnecessary recalculations
const selectedStatus = useMemo(() => {
if (!selectedWorktree) {
return {
devServerRunning: false,
devServerInfo: undefined,
autoModeRunning: false,
isRunning: false,
testRunning: false,
testSessionInfo: undefined,
};
}
return {
devServerRunning: isDevServerRunning(selectedWorktree),
devServerInfo: getDevServerInfo(selectedWorktree),
autoModeRunning: isAutoModeRunningForWorktree(selectedWorktree),
isRunning: hasRunningFeatures(selectedWorktree),
testRunning: isTestRunningForWorktree(selectedWorktree),
testSessionInfo: getTestSessionInfo(selectedWorktree),
};
}, [
selectedWorktree,
isDevServerRunning,
getDevServerInfo,
isAutoModeRunningForWorktree,
hasRunningFeatures,
isTestRunningForWorktree,
getTestSessionInfo,
]);
// Build trigger button with all indicators - memoized for performance
const triggerButton = useMemo(
() => (
<Button
variant="outline"
size="sm"
className={cn(
'h-7 px-3 gap-1.5 font-mono text-xs bg-secondary/50 hover:bg-secondary min-w-0 border-r-0 rounded-r-none'
)}
disabled={isActivating}
>
{/* Running/Activating indicator */}
{(selectedStatus.isRunning || isActivating) && <Spinner size="xs" className="shrink-0" />}
{/* Branch icon */}
<GitBranch className="w-3.5 h-3.5 shrink-0" />
{/* Branch name with optional tooltip */}
<span className="truncate max-w-[150px]">{truncatedBranch}</span>
{/* Card count badge */}
{selectedWorktree &&
branchCardCounts?.[selectedWorktree.branch] !== undefined &&
branchCardCounts[selectedWorktree.branch] > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-4 px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border shrink-0">
{branchCardCounts[selectedWorktree.branch]}
</span>
)}
{/* Uncommitted changes indicator */}
{selectedWorktree?.hasChanges && (
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-4 px-1 text-[10px] font-medium rounded border shrink-0',
getChangesBadgeStyles()
)}
>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{selectedWorktree.changedFilesCount ?? '!'}
</span>
)}
{/* Dev server indicator */}
{selectedStatus.devServerRunning && (
<span
className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0"
title={`Dev server running on port ${selectedStatus.devServerInfo?.port}`}
>
<Globe className="w-3 h-3" />
</span>
)}
{/* Test running indicator */}
{selectedStatus.testRunning && (
<span
className="inline-flex items-center justify-center h-4 w-4 text-blue-500 shrink-0"
title="Tests Running"
>
<FlaskConical className="w-3 h-3 animate-pulse" />
</span>
)}
{/* Last test result indicator (when not running) */}
{!selectedStatus.testRunning && selectedStatus.testSessionInfo && (
<span
className={cn(
'inline-flex items-center justify-center h-4 w-4 shrink-0',
getTestStatusStyles(selectedStatus.testSessionInfo.status)
)}
title={`Last test: ${selectedStatus.testSessionInfo.status}`}
>
<FlaskConical className="w-3 h-3" />
</span>
)}
{/* Auto mode indicator */}
{selectedStatus.autoModeRunning && (
<span
className="flex items-center justify-center h-4 px-0.5 shrink-0"
title="Auto Mode Running"
>
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
)}
{/* PR badge */}
{selectedWorktree?.pr && (
<span
className={cn(
'inline-flex items-center gap-0.5 h-4 px-1 text-[10px] font-medium rounded border shrink-0',
getPRBadgeStyles(selectedWorktree.pr.state)
)}
>
<GitPullRequest className="w-2.5 h-2.5" />#{selectedWorktree.pr.number}
</span>
)}
{/* Dropdown chevron */}
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
</Button>
),
[isActivating, selectedStatus, truncatedBranch, selectedWorktree, branchCardCounts]
);
// Wrap trigger button with dropdown trigger first to ensure ref is passed correctly
const dropdownTrigger = <DropdownMenuTrigger asChild>{triggerButton}</DropdownMenuTrigger>;
const triggerWithTooltip = isBranchNameTruncated ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{dropdownTrigger}</TooltipTrigger>
<TooltipContent>
<p className="font-mono text-xs">{displayBranch}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
dropdownTrigger
);
return (
<div className="flex items-center">
<DropdownMenu>
{triggerWithTooltip}
<DropdownMenuContent
align="start"
className="w-80 max-h-96 overflow-y-auto"
aria-label="Worktree selection"
>
{/* Main worktree section */}
{mainWorktree && (
<>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Main Branch
</DropdownMenuLabel>
<WorktreeDropdownItem
worktree={mainWorktree}
isSelected={isWorktreeSelected(mainWorktree)}
isRunning={hasRunningFeatures(mainWorktree)}
cardCount={branchCardCounts?.[mainWorktree.branch]}
devServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
isTestRunning={isTestRunningForWorktree(mainWorktree)}
testSessionInfo={getTestSessionInfo(mainWorktree)}
onSelect={() => onSelectWorktree(mainWorktree)}
/>
</>
)}
{/* Other worktrees section */}
{otherWorktrees.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Worktrees ({otherWorktrees.length})
</DropdownMenuLabel>
<DropdownMenuGroup>
{otherWorktrees.map((worktree) => (
<WorktreeDropdownItem
key={worktree.path}
worktree={worktree}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
cardCount={branchCardCounts?.[worktree.branch]}
devServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
isTestRunning={isTestRunningForWorktree(worktree)}
testSessionInfo={getTestSessionInfo(worktree)}
onSelect={() => onSelectWorktree(worktree)}
/>
))}
</DropdownMenuGroup>
</>
)}
{/* Empty state */}
{worktrees.length === 0 && (
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
No worktrees available
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Branch switch dropdown for main branch (only when main is selected) */}
{selectedWorktree?.isMain && (
<BranchSwitchDropdown
worktree={selectedWorktree}
isSelected={true}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
onOpenChange={onBranchDropdownOpenChange(selectedWorktree)}
onFilterChange={onBranchFilterChange}
onSwitchBranch={onSwitchBranch}
onCreateBranch={onCreateBranch}
/>
)}
{/* Actions dropdown for the selected worktree */}
{selectedWorktree && (
<WorktreeActionsDropdown
worktree={selectedWorktree}
isSelected={true}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
isDevServerRunning={isDevServerRunning(selectedWorktree)}
devServerInfo={getDevServerInfo(selectedWorktree)}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
testSessionInfo={getTestSessionInfo(selectedWorktree)}
onOpenChange={onActionsDropdownOpenChange(selectedWorktree)}
onPull={onPull}
onPush={onPush}
onPushNewBranch={onPushNewBranch}
onOpenInEditor={onOpenInEditor}
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}
onViewChanges={onViewChanges}
onDiscardChanges={onDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={onMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={onStartDevServer}
onStopDevServer={onStopDevServer}
onOpenDevServerUrl={onOpenDevServerUrl}
onViewDevServerLogs={onViewDevServerLogs}
onRunInitScript={onRunInitScript}
onToggleAutoMode={onToggleAutoMode}
onStartTests={onStartTests}
onStopTests={onStopTests}
onViewTestLogs={onViewTestLogs}
hasInitScript={hasInitScript}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,70 @@
/**
* Shared utility functions for worktree indicator styling and formatting.
* These utilities ensure consistent appearance across WorktreeTab, WorktreeDropdown,
* and WorktreeDropdownItem components.
*/
import type { PRInfo } from '../types';
/**
* Truncates a branch name if it exceeds the maximum length.
* @param branchName - The full branch name
* @param maxLength - Maximum characters before truncation
* @returns Object with truncated name and whether truncation occurred
*/
export function truncateBranchName(
branchName: string,
maxLength: number
): { truncated: string; isTruncated: boolean } {
const isTruncated = branchName.length > maxLength;
const truncated = isTruncated ? `${branchName.slice(0, maxLength)}...` : branchName;
return { truncated, isTruncated };
}
/**
* Returns the appropriate CSS classes for a PR badge based on PR state.
* @param state - The PR state (OPEN, MERGED, or CLOSED)
* @returns CSS class string for the badge
*/
export function getPRBadgeStyles(state: PRInfo['state']): string {
switch (state) {
case 'OPEN':
return 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30';
case 'MERGED':
return 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border-purple-500/30';
case 'CLOSED':
default:
return 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30';
}
}
/**
* Returns the CSS classes for the uncommitted changes badge.
* This is a constant style used across all worktree components.
*/
export function getChangesBadgeStyles(): string {
return 'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30';
}
/** Possible test session status values */
export type TestStatus = 'pending' | 'running' | 'passed' | 'failed' | 'cancelled';
/**
* Returns the CSS classes for a test status indicator based on test result.
* @param status - The test session status
* @returns CSS class string for the indicator color
*/
export function getTestStatusStyles(status: TestStatus): string {
switch (status) {
case 'passed':
return 'text-green-500';
case 'failed':
return 'text-red-500';
case 'running':
return 'text-blue-500';
case 'pending':
case 'cancelled':
default:
return 'text-muted-foreground';
}
}

View File

@@ -260,8 +260,10 @@ export function WorktreeTab({
aria-label={worktree.branch}
data-testid={`worktree-branch-${worktree.branch}`}
>
{isRunning && <Spinner size="xs" />}
{isActivating && !isRunning && <Spinner size="xs" />}
{isRunning && <Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />}
{isActivating && !isRunning && (
<Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />
)}
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
@@ -327,8 +329,10 @@ export function WorktreeTab({
: 'Click to switch to this branch'
}
>
{isRunning && <Spinner size="xs" />}
{isActivating && !isRunning && <Spinner size="xs" />}
{isRunning && <Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />}
{isActivating && !isRunning && (
<Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />
)}
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">

View File

@@ -95,12 +95,20 @@ export function useWorktrees({
);
// fetchWorktrees for backward compatibility - now just triggers a refetch
const fetchWorktrees = useCallback(async () => {
await queryClient.invalidateQueries({
queryKey: queryKeys.worktrees.all(projectPath),
});
return refetch();
}, [projectPath, queryClient, refetch]);
// The silent option is accepted but not used (React Query handles loading states)
// Returns removed worktrees array if any were detected, undefined otherwise
const fetchWorktrees = useCallback(
async (_options?: {
silent?: boolean;
}): Promise<Array<{ path: string; branch: string }> | undefined> => {
await queryClient.invalidateQueries({
queryKey: queryKeys.worktrees.all(projectPath),
});
const result = await refetch();
return result.data?.removedWorktrees;
},
[projectPath, queryClient, refetch]
);
const currentWorktreePath = currentWorktree?.path ?? null;
const selectedWorktree = currentWorktreePath

View File

@@ -28,6 +28,7 @@ import {
WorktreeMobileDropdown,
WorktreeActionsDropdown,
BranchSwitchDropdown,
WorktreeDropdown,
} from './components';
import { useAppStore } from '@/store/app-store';
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
@@ -36,6 +37,9 @@ import { TestLogsPanel } from '@/components/ui/test-logs-panel';
import { Undo2 } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
/** Threshold for switching from tabs to dropdown layout (number of worktrees) */
const WORKTREE_DROPDOWN_THRESHOLD = 3;
export function WorktreePanel({
projectPath,
onCreateWorktree,
@@ -379,13 +383,13 @@ export function WorktreePanel({
const isMobile = useIsMobile();
// Periodic interval check (5 seconds) to detect branch changes on disk
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
// Periodic interval check (30 seconds) to detect branch changes on disk
// Reduced polling to lessen repeated worktree list calls while keeping UI reasonably fresh
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
intervalRef.current = setInterval(() => {
fetchWorktrees({ silent: true });
}, 5000);
}, 30000);
return () => {
if (intervalRef.current) {
@@ -712,30 +716,43 @@ export function WorktreePanel({
);
}
// Desktop view: full tabs layout
// Use dropdown layout when worktree count meets or exceeds the threshold
const useDropdownLayout = worktrees.length >= WORKTREE_DROPDOWN_THRESHOLD;
// Desktop view: full tabs layout or dropdown layout depending on worktree count
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
<span className="text-sm text-muted-foreground mr-2">
{useDropdownLayout ? 'Worktree:' : 'Branch:'}
</span>
<div className="flex items-center gap-2">
{mainWorktree && (
<WorktreeTab
key={mainWorktree.path}
worktree={mainWorktree}
cardCount={branchCardCounts?.[mainWorktree.branch]}
hasChanges={mainWorktree.hasChanges}
changedFilesCount={mainWorktree.changedFilesCount}
isSelected={isWorktreeSelected(mainWorktree)}
isRunning={hasRunningFeatures(mainWorktree)}
{/* Dropdown layout for 3+ worktrees */}
{useDropdownLayout ? (
<>
<WorktreeDropdown
worktrees={worktrees}
isWorktreeSelected={isWorktreeSelected}
hasRunningFeatures={hasRunningFeatures}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
branchCardCounts={branchCardCounts}
isDevServerRunning={isDevServerRunning}
getDevServerInfo={getDevServerInfo}
isAutoModeRunningForWorktree={isAutoModeRunningForWorktree}
isTestRunningForWorktree={isTestRunningForWorktree}
getTestSessionInfo={getTestSessionInfo}
onSelectWorktree={handleSelectWorktree}
// Branch switching props
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
// Action dropdown props
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
@@ -743,16 +760,10 @@ export function WorktreePanel({
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(mainWorktree)}
testSessionInfo={getTestSessionInfo(mainWorktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
hasInitScript={hasInitScript}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
onPull={handlePull}
onPush={handlePush}
onPushNewBranch={handlePushNewBranch}
@@ -776,111 +787,206 @@ export function WorktreePanel({
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
)}
</div>
{/* Worktrees section - only show if enabled */}
{useWorktreesEnabled && (
{useWorktreesEnabled && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</>
)}
</>
) : (
/* Standard tabs layout for 1-2 worktrees */
<>
<div className="w-px h-5 bg-border mx-2" />
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
<div className="flex items-center gap-2 flex-wrap">
{nonMainWorktrees.map((worktree) => {
const cardCount = branchCardCounts?.[worktree.branch];
return (
<WorktreeTab
key={worktree.path}
worktree={worktree}
cardCount={cardCount}
hasChanges={worktree.hasChanges}
changedFilesCount={worktree.changedFilesCount}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(worktree)}
testSessionInfo={getTestSessionInfo(worktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
);
})}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
<div className="flex items-center gap-2">
{mainWorktree && (
<WorktreeTab
key={mainWorktree.path}
worktree={mainWorktree}
cardCount={branchCardCounts?.[mainWorktree.branch]}
hasChanges={mainWorktree.hasChanges}
changedFilesCount={mainWorktree.changedFilesCount}
isSelected={isWorktreeSelected(mainWorktree)}
isRunning={hasRunningFeatures(mainWorktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(mainWorktree)}
testSessionInfo={getTestSessionInfo(mainWorktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
)}
</div>
{/* Worktrees section - only show if enabled and not using dropdown layout */}
{useWorktreesEnabled && (
<>
<div className="w-px h-5 bg-border mx-2" />
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
<div className="flex items-center gap-2 flex-wrap">
{nonMainWorktrees.map((worktree) => {
const cardCount = branchCardCounts?.[worktree.branch];
return (
<WorktreeTab
key={worktree.path}
worktree={worktree}
cardCount={cardCount}
hasChanges={worktree.hasChanges}
changedFilesCount={worktree.changedFilesCount}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(worktree)}
testSessionInfo={getTestSessionInfo(worktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
);
})}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</div>
</>
)}
</>
)}

View File

@@ -0,0 +1,136 @@
/**
* IdeationSettingsPopover - Configure context sources for idea generation
*/
import { useMemo } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Settings2, FileText, Brain, LayoutGrid, Lightbulb, ScrollText } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { useIdeationStore } from '@/store/ideation-store';
import { DEFAULT_IDEATION_CONTEXT_SOURCES, type IdeationContextSources } from '@automaker/types';
interface IdeationSettingsPopoverProps {
projectPath: string;
}
const IDEATION_CONTEXT_OPTIONS: Array<{
key: keyof IdeationContextSources;
label: string;
description: string;
icon: typeof FileText;
}> = [
{
key: 'useAppSpec',
label: 'App Specification',
description: 'Overview, capabilities, features',
icon: ScrollText,
},
{
key: 'useContextFiles',
label: 'Context Files',
description: '.automaker/context/*.md|.txt',
icon: FileText,
},
{
key: 'useMemoryFiles',
label: 'Memory Files',
description: '.automaker/memory/*.md',
icon: Brain,
},
{
key: 'useExistingFeatures',
label: 'Existing Features',
description: 'Board features list',
icon: LayoutGrid,
},
{
key: 'useExistingIdeas',
label: 'Existing Ideas',
description: 'Ideation ideas list',
icon: Lightbulb,
},
];
/**
* Renders a settings popover to toggle per-project ideation context sources.
* Merges defaults with stored overrides and persists changes via the ideation store.
*/
export function IdeationSettingsPopover({ projectPath }: IdeationSettingsPopoverProps) {
const { projectOverrides, setContextSource } = useIdeationStore(
useShallow((state) => ({
projectOverrides: state.contextSourcesByProject[projectPath],
setContextSource: state.setContextSource,
}))
);
const contextSources = useMemo(
() => ({ ...DEFAULT_IDEATION_CONTEXT_SOURCES, ...projectOverrides }),
[projectOverrides]
);
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="p-1 border rounded hover:bg-accent/50 transition-colors"
title="Generation Settings"
aria-label="Generation settings"
data-testid="ideation-context-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end" sideOffset={8}>
<div className="space-y-3">
<div>
<h4 className="font-medium text-sm mb-1">Generation Settings</h4>
<p className="text-xs text-muted-foreground">
Configure which context sources are included when generating ideas.
</p>
</div>
<div className="space-y-2">
{IDEATION_CONTEXT_OPTIONS.map((option) => {
const Icon = option.icon;
return (
<div
key={option.key}
className="flex items-center justify-between gap-3 p-2 rounded-md bg-secondary/50"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<Icon className="w-4 h-4 text-brand-500 shrink-0" />
<div className="min-w-0">
<Label
htmlFor={`ideation-context-toggle-${option.key}`}
className="text-xs font-medium cursor-pointer block"
>
{option.label}
</Label>
<span className="text-[10px] text-muted-foreground truncate block">
{option.description}
</span>
</div>
</div>
<Switch
id={`ideation-context-toggle-${option.key}`}
checked={contextSources[option.key]}
onCheckedChange={(checked) =>
setContextSource(projectPath, option.key, checked)
}
data-testid={`ideation-context-toggle-${option.key}`}
/>
</div>
);
})}
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
Disable sources to generate more focused ideas or reduce context size.
</p>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -13,6 +13,7 @@ import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
import { Button } from '@/components/ui/button';
import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Trash2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { IdeationSettingsPopover } from './components/ideation-settings-popover';
import type { IdeaCategory } from '@automaker/types';
import type { IdeationMode } from '@/store/ideation-store';
@@ -61,7 +62,10 @@ function IdeationBreadcrumbs({
);
}
// Header shown on all pages - matches other view headers
/**
* Header component for the ideation view with navigation, bulk actions, and settings.
* Displays breadcrumbs, accept/discard all buttons, and the generate ideas button with settings popover.
*/
function IdeationHeader({
currentMode,
selectedCategory,
@@ -75,6 +79,7 @@ function IdeationHeader({
discardAllReady,
discardAllCount,
onDiscardAll,
projectPath,
}: {
currentMode: IdeationMode;
selectedCategory: IdeaCategory | null;
@@ -88,6 +93,7 @@ function IdeationHeader({
discardAllReady: boolean;
discardAllCount: number;
onDiscardAll: () => void;
projectPath: string;
}) {
const { getCategoryById } = useGuidedPrompts();
const showBackButton = currentMode === 'prompts';
@@ -157,15 +163,23 @@ function IdeationHeader({
Accept All ({acceptAllCount})
</Button>
)}
<Button onClick={onGenerateIdeas} className="gap-2">
<Lightbulb className="w-4 h-4" />
Generate Ideas
</Button>
<div className="flex items-center gap-3">
<Button onClick={onGenerateIdeas} className="gap-2">
<Lightbulb className="w-4 h-4" />
Generate Ideas
</Button>
<IdeationSettingsPopover projectPath={projectPath} />
</div>
</div>
</div>
);
}
/**
* Main view for brainstorming and idea management.
* Provides a dashboard for reviewing generated ideas and a prompt selection flow
* for generating new ideas using AI-powered suggestions.
*/
export function IdeationView() {
const currentProject = useAppStore((s) => s.currentProject);
const { currentMode, selectedCategory, setMode, setCategory } = useIdeationStore();
@@ -282,6 +296,7 @@ export function IdeationView() {
discardAllReady={discardAllReady}
discardAllCount={discardAllCount}
onDiscardAll={handleDiscardAll}
projectPath={currentProject.path}
/>
{/* Dashboard - main view */}

View File

@@ -572,7 +572,7 @@ export function InterviewView() {
>
{isGenerating ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Creating...
</>
) : (

View File

@@ -448,7 +448,7 @@ export function LoginView() {
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Authenticating...
</>
) : (

View File

@@ -104,7 +104,10 @@ function FeatureDefaultModelOverrideSection({ project }: { project: Project }) {
const hasOverride = !!projectOverride;
const effectiveValue = projectOverride || globalValue;
// Get display name for a model
/**
* Formats a user-friendly model label using provider metadata when available,
* falling back to known Claude aliases or the raw model id.
*/
const getModelDisplayName = (entry: PhaseModelEntry): string => {
if (entry.providerId) {
const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId);
@@ -127,10 +130,16 @@ function FeatureDefaultModelOverrideSection({ project }: { project: Project }) {
return modelMap[entry.model] || entry.model;
};
/**
* Clears the project-level model override for this scope.
*/
const handleClearOverride = () => {
setProjectDefaultFeatureModel(project.id, null);
};
/**
* Sets the project-level model override for this scope.
*/
const handleSetOverride = (entry: PhaseModelEntry) => {
setProjectDefaultFeatureModel(project.id, entry);
};
@@ -209,6 +218,10 @@ function FeatureDefaultModelOverrideSection({ project }: { project: Project }) {
);
}
/**
* Renders a single phase override row, showing the effective model
* (project override or global default) and wiring selector/reset actions.
*/
function PhaseOverrideItem({
phase,
project,
@@ -225,7 +238,10 @@ function PhaseOverrideItem({
const hasOverride = !!projectOverride;
const effectiveValue = projectOverride || globalValue;
// Get display name for a model
/**
* Formats a user-friendly model label using provider metadata when available,
* falling back to known Claude aliases or the raw model id.
*/
const getModelDisplayName = (entry: PhaseModelEntry): string => {
if (entry.providerId) {
const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId);
@@ -248,10 +264,16 @@ function PhaseOverrideItem({
return modelMap[entry.model] || entry.model;
};
/**
* Clears the project-level model override for this scope.
*/
const handleClearOverride = () => {
setProjectPhaseModelOverride(project.id, phase.key, null);
};
/**
* Sets the project-level model override for this scope.
*/
const handleSetOverride = (entry: PhaseModelEntry) => {
setProjectPhaseModelOverride(project.id, phase.key, entry);
};
@@ -315,6 +337,10 @@ function PhaseOverrideItem({
);
}
/**
* Renders a titled group of phase override rows and resolves each phase's
* global default model with a fallback to DEFAULT_PHASE_MODELS.
*/
function PhaseGroup({
title,
subtitle,
@@ -350,9 +376,11 @@ function PhaseGroup({
);
}
/**
* Renders the per-project model overrides UI for all phase models.
*/
export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
const { clearAllProjectPhaseModelOverrides, disabledProviders, claudeCompatibleProviders } =
useAppStore();
const { clearAllProjectPhaseModelOverrides, claudeCompatibleProviders } = useAppStore();
const [showBulkReplace, setShowBulkReplace] = useState(false);
// Count how many overrides are set (including defaultFeatureModel)
@@ -360,25 +388,13 @@ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
const hasDefaultFeatureModelOverride = !!project.defaultFeatureModel;
const overrideCount = phaseOverrideCount + (hasDefaultFeatureModelOverride ? 1 : 0);
// Check if Claude is available
const isClaudeDisabled = disabledProviders.includes('claude');
// Check if there are any enabled ClaudeCompatibleProviders
const hasEnabledProviders =
claudeCompatibleProviders && claudeCompatibleProviders.some((p) => p.enabled !== false);
if (isClaudeDisabled) {
return (
<div className="text-center py-12 text-muted-foreground">
<Workflow className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">Claude not configured</p>
<p className="text-xs mt-1">
Enable Claude in global settings to configure per-project model overrides.
</p>
</div>
);
}
/**
* Clears all project-level phase model overrides for this project.
*/
const handleClearAll = () => {
clearAllProjectPhaseModelOverrides(project.id);
};

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { Label } from '@/components/ui/label';
import { Palette, Moon, Sun, Type } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Palette, Moon, Sun, Type, Sparkles, PanelLeft, Columns2 } from 'lucide-react';
import { darkThemes, lightThemes } from '@/config/theme-options';
import {
UI_SANS_FONT_OPTIONS,
@@ -11,6 +12,7 @@ import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { FontSelector } from '@/components/shared';
import type { Theme } from '../shared/types';
import type { SidebarStyle } from '@automaker/types';
interface AppearanceSectionProps {
effectiveTheme: Theme;
@@ -18,7 +20,16 @@ interface AppearanceSectionProps {
}
export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceSectionProps) {
const { fontFamilySans, fontFamilyMono, setFontSans, setFontMono } = useAppStore();
const {
fontFamilySans,
fontFamilyMono,
setFontSans,
setFontMono,
disableSplashScreen,
setDisableSplashScreen,
sidebarStyle,
setSidebarStyle,
} = useAppStore();
// Determine if current theme is light or dark
const isLightTheme = lightThemes.some((t) => t.value === effectiveTheme);
@@ -189,6 +200,118 @@ export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceS
</div>
</div>
</div>
{/* Splash Screen Section */}
<div className="space-y-4 pt-6 border-t border-border/50">
<div className="flex items-center gap-2 mb-4">
<Sparkles className="w-4 h-4 text-muted-foreground" />
<Label className="text-foreground font-medium">Startup</Label>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="disable-splash-screen" className="text-sm">
Disable Splash Screen
</Label>
<p className="text-xs text-muted-foreground">
Skip the animated splash screen when the app starts
</p>
</div>
<Switch
id="disable-splash-screen"
checked={disableSplashScreen}
onCheckedChange={setDisableSplashScreen}
/>
</div>
</div>
{/* Sidebar Style Section */}
<div className="space-y-4 pt-6 border-t border-border/50">
<div className="flex items-center gap-2 mb-4">
<PanelLeft className="w-4 h-4 text-muted-foreground" />
<Label className="text-foreground font-medium">Sidebar Layout</Label>
</div>
<p className="text-xs text-muted-foreground -mt-2 mb-4">
Choose between a modern unified sidebar or classic Discord-style layout with a separate
project switcher.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Unified Sidebar Option */}
<button
onClick={() => setSidebarStyle('unified')}
className={cn(
'group flex flex-col items-center gap-3 p-4 rounded-xl',
'text-sm font-medium transition-all duration-200 ease-out',
sidebarStyle === 'unified'
? [
'bg-gradient-to-br from-brand-500/15 to-brand-600/10',
'border-2 border-brand-500/40',
'text-foreground',
'shadow-md shadow-brand-500/10',
]
: [
'bg-accent/30 hover:bg-accent/50',
'border border-border/50 hover:border-border',
'text-muted-foreground hover:text-foreground',
'hover:shadow-sm',
],
'hover:scale-[1.02] active:scale-[0.98]'
)}
data-testid="sidebar-style-unified"
>
<PanelLeft
className={cn(
'w-8 h-8 transition-all duration-200',
sidebarStyle === 'unified' ? 'text-brand-500' : 'text-muted-foreground'
)}
/>
<div className="text-center">
<div className="font-medium">Unified</div>
<div className="text-xs text-muted-foreground mt-1">
Single sidebar with project dropdown
</div>
</div>
</button>
{/* Discord-style Sidebar Option */}
<button
onClick={() => setSidebarStyle('discord')}
className={cn(
'group flex flex-col items-center gap-3 p-4 rounded-xl',
'text-sm font-medium transition-all duration-200 ease-out',
sidebarStyle === 'discord'
? [
'bg-gradient-to-br from-brand-500/15 to-brand-600/10',
'border-2 border-brand-500/40',
'text-foreground',
'shadow-md shadow-brand-500/10',
]
: [
'bg-accent/30 hover:bg-accent/50',
'border border-border/50 hover:border-border',
'text-muted-foreground hover:text-foreground',
'hover:shadow-sm',
],
'hover:scale-[1.02] active:scale-[0.98]'
)}
data-testid="sidebar-style-discord"
>
<Columns2
className={cn(
'w-8 h-8 transition-all duration-200',
sidebarStyle === 'discord' ? 'text-brand-500' : 'text-muted-foreground'
)}
/>
<div className="text-center">
<div className="font-medium">Classic</div>
<div className="text-xs text-muted-foreground mt-1">
Separate project switcher + sidebar
</div>
</div>
</button>
</div>
</div>
</div>
</div>
);

View File

@@ -60,7 +60,7 @@ export function CliInstallationCard({
>
{isInstalling ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Installing...
</>
) : (

View File

@@ -412,7 +412,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
>
{isInstalling ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Installing...
</>
) : (
@@ -574,7 +574,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
>
{isSavingApiKey ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Saving...
</>
) : (

View File

@@ -408,7 +408,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup
>
{isInstalling ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Installing...
</>
) : (
@@ -681,7 +681,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup
>
{isSavingApiKey ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Saving...
</>
) : (

View File

@@ -318,7 +318,7 @@ export function CursorSetupStep({ onNext, onBack, onSkip }: CursorSetupStepProps
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Waiting for login...
</>
) : (

View File

@@ -316,7 +316,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Waiting for login...
</>
) : (

View File

@@ -329,7 +329,7 @@ function ClaudeContent() {
>
{isInstalling ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Installing...
</>
) : (
@@ -424,7 +424,11 @@ function ClaudeContent() {
disabled={isSavingApiKey || !apiKey.trim()}
className="flex-1 bg-brand-500 hover:bg-brand-600 text-white"
>
{isSavingApiKey ? <Spinner size="sm" /> : 'Save API Key'}
{isSavingApiKey ? (
<Spinner size="sm" variant="foreground" />
) : (
'Save API Key'
)}
</Button>
{hasApiKey && (
<Button
@@ -661,7 +665,7 @@ function CursorContent() {
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Waiting for login...
</>
) : (
@@ -918,7 +922,7 @@ function CodexContent() {
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Waiting for login...
</>
) : (
@@ -961,7 +965,7 @@ function CodexContent() {
disabled={isSaving || !apiKey.trim()}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
>
{isSaving ? <Spinner size="sm" /> : 'Save API Key'}
{isSaving ? <Spinner size="sm" variant="foreground" /> : 'Save API Key'}
</Button>
</AccordionContent>
</AccordionItem>
@@ -1194,7 +1198,7 @@ function OpencodeContent() {
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Waiting for login...
</>
) : (
@@ -1466,7 +1470,7 @@ function GeminiContent() {
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Waiting for login...
</>
) : (
@@ -1509,7 +1513,7 @@ function GeminiContent() {
disabled={isSaving || !apiKey.trim()}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
>
{isSaving ? <Spinner size="sm" /> : 'Save API Key'}
{isSaving ? <Spinner size="sm" variant="foreground" /> : 'Save API Key'}
</Button>
</AccordionContent>
</AccordionItem>
@@ -1745,7 +1749,7 @@ function CopilotContent() {
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Waiting for login...
</>
) : (

View File

@@ -68,7 +68,16 @@ export function useGenerateIdeationSuggestions(projectPath: string) {
throw new Error('Ideation API not available');
}
const result = await api.ideation.generateSuggestions(projectPath, promptId, category);
// Get context sources from store
const contextSources = useIdeationStore.getState().getContextSources(projectPath);
const result = await api.ideation.generateSuggestions(
projectPath,
promptId,
category,
undefined, // count - use default
contextSources
);
if (!result.success) {
throw new Error(result.error || 'Failed to generate suggestions');

View File

@@ -22,6 +22,57 @@ import { useEventRecencyStore } from './use-event-recency';
const PROGRESS_DEBOUNCE_WAIT = 150;
const PROGRESS_DEBOUNCE_MAX_WAIT = 2000;
/**
* Events that should invalidate the feature list (features.all query)
* Note: pipeline_step_started is included to ensure Kanban board immediately reflects
* feature moving to custom pipeline columns (fixes GitHub issue #668)
*/
const FEATURE_LIST_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
'auto_mode_feature_complete',
'auto_mode_error',
'plan_approval_required',
'plan_approved',
'plan_rejected',
'pipeline_step_started',
'pipeline_step_complete',
];
/**
* Events that should invalidate a specific feature (features.single query)
* Note: pipeline_step_started is NOT included here because it already invalidates
* features.all() above, which also invalidates child queries (features.single)
*/
const SINGLE_FEATURE_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
'auto_mode_feature_start',
'auto_mode_phase',
'auto_mode_phase_complete',
];
/**
* Events that should invalidate running agents status
*/
const RUNNING_AGENTS_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
'auto_mode_feature_start',
'auto_mode_feature_complete',
'auto_mode_error',
'auto_mode_resuming_features',
];
/**
* Events that signal a feature is done and debounce cleanup should occur
*/
const FEATURE_CLEANUP_EVENTS: AutoModeEvent['type'][] = [
'auto_mode_feature_complete',
'auto_mode_error',
];
/**
* Type guard to check if an event has a featureId property
*/
function hasFeatureId(event: AutoModeEvent): event is AutoModeEvent & { featureId: string } {
return 'featureId' in event && typeof event.featureId === 'string';
}
/**
* Creates a unique key for per-feature debounce tracking
*/
@@ -115,40 +166,22 @@ export function useAutoModeQueryInvalidation(projectPath: string | undefined) {
// This allows polling to be disabled when WebSocket events are flowing
recordGlobalEvent();
// Invalidate features when agent completes, errors, or receives plan approval
if (
event.type === 'auto_mode_feature_complete' ||
event.type === 'auto_mode_error' ||
event.type === 'plan_approval_required' ||
event.type === 'plan_approved' ||
event.type === 'plan_rejected' ||
event.type === 'pipeline_step_complete'
) {
// Invalidate feature list for lifecycle events
if (FEATURE_LIST_INVALIDATION_EVENTS.includes(event.type)) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProjectPath),
});
}
// Invalidate running agents on any status change
if (
event.type === 'auto_mode_feature_start' ||
event.type === 'auto_mode_feature_complete' ||
event.type === 'auto_mode_error' ||
event.type === 'auto_mode_resuming_features'
) {
// Invalidate running agents on status changes
if (RUNNING_AGENTS_INVALIDATION_EVENTS.includes(event.type)) {
queryClient.invalidateQueries({
queryKey: queryKeys.runningAgents.all(),
});
}
// Invalidate specific feature when it starts or has phase changes
if (
(event.type === 'auto_mode_feature_start' ||
event.type === 'auto_mode_phase' ||
event.type === 'auto_mode_phase_complete' ||
event.type === 'pipeline_step_started') &&
'featureId' in event
) {
// Invalidate specific feature for phase changes
if (SINGLE_FEATURE_INVALIDATION_EVENTS.includes(event.type) && hasFeatureId(event)) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.single(currentProjectPath, event.featureId),
});
@@ -156,23 +189,19 @@ export function useAutoModeQueryInvalidation(projectPath: string | undefined) {
// Invalidate agent output during progress updates (DEBOUNCED)
// Uses per-feature debouncing to batch rapid progress events during streaming
if (event.type === 'auto_mode_progress' && 'featureId' in event) {
if (event.type === 'auto_mode_progress' && hasFeatureId(event)) {
const debouncedInvalidation = getDebouncedInvalidation(event.featureId);
debouncedInvalidation();
}
// Clean up debounced functions when feature completes or errors
// This ensures we flush any pending invalidations and free memory
if (
(event.type === 'auto_mode_feature_complete' || event.type === 'auto_mode_error') &&
'featureId' in event &&
event.featureId
) {
if (FEATURE_CLEANUP_EVENTS.includes(event.type) && hasFeatureId(event)) {
cleanupFeatureDebounce(event.featureId);
}
// Invalidate worktree queries when feature completes (may have created worktree)
if (event.type === 'auto_mode_feature_complete' && 'featureId' in event) {
if (event.type === 'auto_mode_feature_complete' && hasFeatureId(event)) {
queryClient.invalidateQueries({
queryKey: queryKeys.worktrees.all(currentProjectPath),
});

View File

@@ -14,8 +14,8 @@ export interface ResponsiveKanbanConfig {
* Default configuration for responsive Kanban columns
*/
const DEFAULT_CONFIG: ResponsiveKanbanConfig = {
columnWidth: 288, // 18rem = 288px (w-72)
columnMinWidth: 280, // Minimum column width - ensures usability
columnWidth: 320, // Increased from 288px to accommodate longer column titles
columnMinWidth: 320, // Increased from 280px to prevent title overflow
columnMaxWidth: Infinity, // No max width - columns scale evenly to fill viewport
gap: 20, // gap-5 = 20px
padding: 40, // px-5 on both sides = 40px (matches gap between columns)

View File

@@ -181,6 +181,7 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
defaultPlanningMode: state.defaultPlanningMode as GlobalSettings['defaultPlanningMode'],
defaultRequirePlanApproval: state.defaultRequirePlanApproval as boolean,
muteDoneSound: state.muteDoneSound as boolean,
disableSplashScreen: state.disableSplashScreen as boolean,
enhancementModel: state.enhancementModel as GlobalSettings['enhancementModel'],
validationModel: state.validationModel as GlobalSettings['validationModel'],
phaseModels: state.phaseModels as GlobalSettings['phaseModels'],
@@ -698,6 +699,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
fontFamilySans: settings.fontFamilySans ?? null,
fontFamilyMono: settings.fontFamilyMono ?? null,
sidebarOpen: settings.sidebarOpen ?? true,
sidebarStyle: settings.sidebarStyle ?? 'unified',
collapsedNavSections: settings.collapsedNavSections ?? {},
chatHistoryOpen: settings.chatHistoryOpen ?? false,
maxConcurrency: settings.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
autoModeByWorktree: restoredAutoModeByWorktree,
@@ -711,6 +714,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
model: 'claude-opus',
},
muteDoneSound: settings.muteDoneSound ?? false,
disableSplashScreen: settings.disableSplashScreen ?? false,
serverLogLevel: settings.serverLogLevel ?? 'info',
enableRequestLogging: settings.enableRequestLogging ?? true,
showQueryDevtools: settings.showQueryDevtools ?? true,
@@ -798,6 +802,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
defaultPlanningMode: state.defaultPlanningMode,
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
muteDoneSound: state.muteDoneSound,
disableSplashScreen: state.disableSplashScreen,
serverLogLevel: state.serverLogLevel,
enableRequestLogging: state.enableRequestLogging,
enhancementModel: state.enhancementModel,

View File

@@ -53,6 +53,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
'terminalFontFamily', // Maps to terminalState.fontFamily
'openTerminalMode', // Maps to terminalState.openTerminalMode
'sidebarOpen',
'sidebarStyle',
'collapsedNavSections',
'chatHistoryOpen',
'maxConcurrency',
'autoModeByWorktree', // Per-worktree auto mode settings (only maxConcurrency is persisted)
@@ -64,6 +66,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
'defaultRequirePlanApproval',
'defaultFeatureModel',
'muteDoneSound',
'disableSplashScreen',
'serverLogLevel',
'enableRequestLogging',
'showQueryDevtools',
@@ -697,6 +700,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
useAppStore.setState({
theme: serverSettings.theme as unknown as ThemeMode,
sidebarOpen: serverSettings.sidebarOpen,
sidebarStyle: serverSettings.sidebarStyle ?? 'unified',
collapsedNavSections: serverSettings.collapsedNavSections ?? {},
chatHistoryOpen: serverSettings.chatHistoryOpen,
maxConcurrency: serverSettings.maxConcurrency,
autoModeByWorktree: restoredAutoModeByWorktree,
@@ -710,6 +715,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
? migratePhaseModelEntry(serverSettings.defaultFeatureModel)
: { model: 'claude-opus' },
muteDoneSound: serverSettings.muteDoneSound,
disableSplashScreen: serverSettings.disableSplashScreen ?? false,
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
enhancementModel: serverSettings.enhancementModel,

View File

@@ -237,39 +237,34 @@ function cleanFragmentedText(content: string): string {
/**
* Extracts a summary from completed feature context
* Looks for content between <summary> and </summary> tags
* Returns the LAST summary found to ensure we get the most recent/updated one
*/
function extractSummary(content: string): string | undefined {
// First, clean up any fragmented text from streaming
const cleanedContent = cleanFragmentedText(content);
// Look for <summary> tags - capture everything between opening and closing tags
const summaryTagMatch = cleanedContent.match(/<summary>([\s\S]*?)<\/summary>/i);
if (summaryTagMatch) {
// Clean up the extracted summary content as well
return cleanFragmentedText(summaryTagMatch[1]).trim();
}
// Define regex patterns to try in order of priority
// Each pattern specifies which capture group contains the summary content
const regexesToTry = [
{ regex: /<summary>([\s\S]*?)<\/summary>/gi, group: 1 },
{ regex: /## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/gi, group: 1 },
{
regex:
/✓ (?:Feature|Verification|Task) (?:successfully|completed|verified)[^\n]*(?:\n[^\n]{1,200})?/gi,
group: 0,
},
{
regex: /(?:What was done|Changes made|Implemented)[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/gi,
group: 1,
},
];
// Fallback: Look for summary sections - capture everything including subsections (###)
// Stop at same-level ## sections (but not ###), or tool markers, or end
const summaryMatch = cleanedContent.match(/## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i);
if (summaryMatch) {
return cleanFragmentedText(summaryMatch[1]).trim();
}
// Look for completion markers and extract surrounding text
const completionMatch = cleanedContent.match(
/✓ (?:Feature|Verification|Task) (?:successfully|completed|verified)[^\n]*(?:\n[^\n]{1,200})?/i
);
if (completionMatch) {
return cleanFragmentedText(completionMatch[0]).trim();
}
// Look for "What was done" type sections
const whatWasDoneMatch = cleanedContent.match(
/(?:What was done|Changes made|Implemented)[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i
);
if (whatWasDoneMatch) {
return cleanFragmentedText(whatWasDoneMatch[1]).trim();
for (const { regex, group } of regexesToTry) {
const matches = [...cleanedContent.matchAll(regex)];
if (matches.length > 0) {
const lastMatch = matches[matches.length - 1];
return cleanFragmentedText(lastMatch[group]).trim();
}
}
return undefined;

View File

@@ -27,6 +27,7 @@ import type {
CreateIdeaInput,
UpdateIdeaInput,
ConvertToFeatureOptions,
IdeationContextSources,
} from '@automaker/types';
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
import { getJSON, setJSON, removeItem } from './storage';
@@ -114,7 +115,8 @@ export interface IdeationAPI {
projectPath: string,
promptId: string,
category: IdeaCategory,
count?: number
count?: number,
contextSources?: IdeationContextSources
) => Promise<{ success: boolean; suggestions?: AnalysisSuggestion[]; error?: string }>;
// Convert to feature

View File

@@ -32,6 +32,7 @@ import type {
NotificationsAPI,
EventHistoryAPI,
} from './electron';
import type { IdeationContextSources } from '@automaker/types';
import type { EventHistoryFilter } from '@automaker/types';
import type { Message, SessionListItem } from '@/types/electron';
import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
@@ -2739,9 +2740,16 @@ export class HttpApiClient implements ElectronAPI {
projectPath: string,
promptId: string,
category: IdeaCategory,
count?: number
count?: number,
contextSources?: IdeationContextSources
) =>
this.post('/api/ideation/suggestions/generate', { projectPath, promptId, category, count }),
this.post('/api/ideation/suggestions/generate', {
projectPath,
promptId,
category,
count,
contextSources,
}),
convertToFeature: (projectPath: string, ideaId: string, options?: ConvertToFeatureOptions) =>
this.post('/api/ideation/convert', { projectPath, ideaId, ...options }),

View File

@@ -1198,46 +1198,48 @@ function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] {
/**
* Extracts summary content from raw log output
* Returns the summary text if found, or null if no summary exists
* Returns the LAST summary text if found, or null if no summary exists
* This ensures we get the most recent/updated summary when multiple exist
*/
export function extractSummary(rawOutput: string): string | null {
if (!rawOutput || !rawOutput.trim()) {
return null;
}
// Try to find <summary> tags first (preferred format)
const summaryTagMatch = rawOutput.match(/<summary>([\s\S]*?)<\/summary>/);
if (summaryTagMatch) {
return summaryTagMatch[1].trim();
}
// First, clean up any fragmented text from streaming
// This handles cases where streaming providers send partial text chunks
// that got separated by newlines during accumulation (e.g., "<sum\n\nmary>")
const cleanedOutput = cleanFragmentedText(rawOutput);
// Try to find markdown ## Summary section
const summaryHeaderMatch = rawOutput.match(/^##\s+Summary\s*\n([\s\S]*?)(?=\n##\s+|$)/m);
if (summaryHeaderMatch) {
return summaryHeaderMatch[1].trim();
}
// Define regex patterns to try in order of priority
// Each pattern specifies a processor function to extract the summary from the match
const regexesToTry: Array<{
regex: RegExp;
processor: (m: RegExpMatchArray) => string;
}> = [
{ regex: /<summary>([\s\S]*?)<\/summary>/gi, processor: (m) => m[1] },
{ regex: /^##\s+Summary[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, processor: (m) => m[1] },
{
regex: /^##\s+(Feature|Changes|Implementation)[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm,
processor: (m) => `## ${m[1]}\n${m[2]}`,
},
{
regex: /(^|\n)(All tasks completed[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g,
processor: (m) => m[2],
},
{
regex:
/(^|\n)((I've|I have) (successfully |now )?(completed|finished|implemented)[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g,
processor: (m) => m[2],
},
];
// Try other summary formats (Feature, Changes, Implementation)
const otherHeaderMatch = rawOutput.match(
/^##\s+(Feature|Changes|Implementation)\s*\n([\s\S]*?)(?=\n##\s+|$)/m
);
if (otherHeaderMatch) {
return `## ${otherHeaderMatch[1]}\n${otherHeaderMatch[2].trim()}`;
}
// Try to find summary introduction lines
const introMatch = rawOutput.match(
/(^|\n)(All tasks completed[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/
);
if (introMatch) {
return introMatch[2].trim();
}
const completionMatch = rawOutput.match(
/(^|\n)((I've|I have) (successfully |now )?(completed|finished|implemented)[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/
);
if (completionMatch) {
return completionMatch[2].trim();
for (const { regex, processor } of regexesToTry) {
const matches = [...cleanedOutput.matchAll(regex)];
if (matches.length > 0) {
const lastMatch = matches[matches.length - 1];
return cleanFragmentedText(processor(lastMatch)).trim();
}
}
return null;

View File

@@ -4,6 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { createLogger } from '@automaker/utils/logger';
import { Sidebar } from '@/components/layout/sidebar';
import { ProjectSwitcher } from '@/components/layout/project-switcher';
import {
FileBrowserProvider,
useFileBrowser,
@@ -167,6 +168,7 @@ function RootLayoutContent() {
theme,
fontFamilySans,
fontFamilyMono,
sidebarStyle,
skipSandboxWarning,
setSkipSandboxWarning,
fetchCodexModels,
@@ -860,6 +862,8 @@ function RootLayoutContent() {
aria-hidden="true"
/>
)}
{/* Discord-style layout: narrow project switcher + expandable sidebar */}
{sidebarStyle === 'discord' && <ProjectSwitcher />}
<Sidebar />
<div
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"

View File

@@ -36,6 +36,7 @@ import type {
EventHook,
ClaudeApiProfile,
ClaudeCompatibleProvider,
SidebarStyle,
} from '@automaker/types';
import {
getAllCursorModelIds,
@@ -610,6 +611,8 @@ export interface AppState {
// View state
currentView: ViewMode;
sidebarOpen: boolean;
sidebarStyle: SidebarStyle; // 'unified' (modern) or 'discord' (classic two-sidebar layout)
collapsedNavSections: Record<string, boolean>; // Collapsed state of nav sections (key: section label)
mobileSidebarHidden: boolean; // Completely hides sidebar on mobile
// Agent Session state (per-project, keyed by project path)
@@ -686,6 +689,9 @@ export interface AppState {
// Audio Settings
muteDoneSound: boolean; // When true, mute the notification sound when agents complete (default: false)
// Splash Screen Settings
disableSplashScreen: boolean; // When true, skip showing the splash screen overlay on startup
// Server Log Level Settings
serverLogLevel: ServerLogLevel; // Log level for the API server (error, warn, info, debug)
enableRequestLogging: boolean; // Enable HTTP request logging (Morgan)
@@ -1046,6 +1052,9 @@ export interface AppActions {
setCurrentView: (view: ViewMode) => void;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setSidebarStyle: (style: SidebarStyle) => void;
setCollapsedNavSections: (sections: Record<string, boolean>) => void;
toggleNavSection: (sectionLabel: string) => void;
toggleMobileSidebarHidden: () => void;
setMobileSidebarHidden: (hidden: boolean) => void;
@@ -1183,6 +1192,9 @@ export interface AppActions {
// Audio Settings actions
setMuteDoneSound: (muted: boolean) => void;
// Splash Screen actions
setDisableSplashScreen: (disabled: boolean) => void;
// Server Log Level actions
setServerLogLevel: (level: ServerLogLevel) => void;
setEnableRequestLogging: (enabled: boolean) => void;
@@ -1471,6 +1483,8 @@ const initialState: AppState = {
projectHistoryIndex: -1,
currentView: 'welcome',
sidebarOpen: true,
sidebarStyle: 'unified', // Default to modern unified sidebar
collapsedNavSections: {}, // Nav sections expanded by default (sections set their own defaults)
mobileSidebarHidden: false, // Sidebar visible by default on mobile
lastSelectedSessionByProject: {},
theme: getStoredTheme() || 'dark', // Use localStorage theme as initial value, fallback to 'dark'
@@ -1502,6 +1516,7 @@ const initialState: AppState = {
worktreesByProject: {},
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
muteDoneSound: false, // Default to sound enabled (not muted)
disableSplashScreen: false, // Default to showing splash screen
serverLogLevel: 'info', // Default to info level for server logs
enableRequestLogging: true, // Default to enabled for HTTP request logging
showQueryDevtools: true, // Default to enabled (only shown in dev mode anyway)
@@ -1929,6 +1944,15 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
setCurrentView: (view) => set({ currentView: view }),
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setSidebarStyle: (style) => set({ sidebarStyle: style }),
setCollapsedNavSections: (sections) => set({ collapsedNavSections: sections }),
toggleNavSection: (sectionLabel) =>
set((state) => ({
collapsedNavSections: {
...state.collapsedNavSections,
[sectionLabel]: !state.collapsedNavSections[sectionLabel],
},
})),
toggleMobileSidebarHidden: () => set({ mobileSidebarHidden: !get().mobileSidebarHidden }),
setMobileSidebarHidden: (hidden) => set({ mobileSidebarHidden: hidden }),
@@ -2626,6 +2650,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
// Audio Settings actions
setMuteDoneSound: (muted) => set({ muteDoneSound: muted }),
// Splash Screen actions
setDisableSplashScreen: (disabled) => set({ disableSplashScreen: disabled }),
// Server Log Level actions
setServerLogLevel: (level) => set({ serverLogLevel: level }),
setEnableRequestLogging: (enabled) => set({ enableRequestLogging: enabled }),

View File

@@ -11,7 +11,9 @@ import type {
IdeationPrompt,
AnalysisSuggestion,
ProjectAnalysisResult,
IdeationContextSources,
} from '@automaker/types';
import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types';
// ============================================================================
// Generation Job Types
@@ -61,6 +63,9 @@ interface IdeationState {
currentMode: IdeationMode;
selectedCategory: IdeaCategory | null;
filterStatus: IdeaStatus | 'all';
// Context sources per project
contextSourcesByProject: Record<string, Partial<IdeationContextSources>>;
}
// ============================================================================
@@ -110,6 +115,21 @@ interface IdeationActions {
setCategory: (category: IdeaCategory | null) => void;
setFilterStatus: (status: IdeaStatus | 'all') => void;
// Context sources
/**
* Returns the effective context-source settings for a project,
* merging defaults with any stored overrides.
*/
getContextSources: (projectPath: string) => IdeationContextSources;
/**
* Updates a single context-source flag for a project.
*/
setContextSource: (
projectPath: string,
key: keyof IdeationContextSources,
value: boolean
) => void;
// Reset
reset: () => void;
resetSuggestions: () => void;
@@ -135,6 +155,7 @@ const initialState: IdeationState = {
currentMode: 'dashboard',
selectedCategory: null,
filterStatus: 'all',
contextSourcesByProject: {},
};
// ============================================================================
@@ -300,6 +321,24 @@ export const useIdeationStore = create<IdeationState & IdeationActions>()(
setFilterStatus: (status) => set({ filterStatus: status }),
// Context sources
getContextSources: (projectPath) => {
const state = get();
const projectOverrides = state.contextSourcesByProject[projectPath] ?? {};
return { ...DEFAULT_IDEATION_CONTEXT_SOURCES, ...projectOverrides };
},
setContextSource: (projectPath, key, value) =>
set((state) => ({
contextSourcesByProject: {
...state.contextSourcesByProject,
[projectPath]: {
...state.contextSourcesByProject[projectPath],
[key]: value,
},
},
})),
// Reset
reset: () => set(initialState),
@@ -313,13 +352,14 @@ export const useIdeationStore = create<IdeationState & IdeationActions>()(
}),
{
name: 'automaker-ideation-store',
version: 4,
version: 5,
partialize: (state) => ({
// Only persist these fields
ideas: state.ideas,
generationJobs: state.generationJobs,
analysisResult: state.analysisResult,
filterStatus: state.filterStatus,
contextSourcesByProject: state.contextSourcesByProject,
}),
migrate: (persistedState: unknown, version: number) => {
const state = persistedState as Record<string, unknown>;
@@ -331,6 +371,13 @@ export const useIdeationStore = create<IdeationState & IdeationActions>()(
generationJobs: jobs.filter((job) => job.projectPath !== undefined),
};
}
if (version < 5) {
// Initialize contextSourcesByProject if not present
return {
...state,
contextSourcesByProject: state.contextSourcesByProject ?? {},
};
}
return state;
},
}

View File

@@ -0,0 +1,431 @@
# PRD to Automaker Features Guide
This guide helps Claude generate properly structured Automaker features from a Product Requirements Document (PRD). Use this in new projects to create feature folders that Automaker can execute.
## Quick Start
1. Place your PRD file in the project (e.g., `PRD.md` or `.automaker/context/PRD.md`)
2. Create `.automaker/features/` directory
3. Use this guide to generate `feature.json` files for each feature phase
4. Run features in Automaker sequentially or in parallel based on dependencies
---
## Feature JSON Schema
### Minimal Required Fields
```json
{
"id": "feature-unique-identifier",
"category": "Core",
"title": "Feature Title",
"description": "Detailed description of what needs to be implemented",
"status": "backlog",
"priority": 1,
"imagePaths": [],
"textFilePaths": []
}
```
### Complete Feature Schema
```json
{
"id": "feature-unique-identifier",
"category": "Core | UI/UX | AI Agent | Infrastructure | Testing | From GitHub",
"title": "Short descriptive title",
"description": "Detailed implementation description with requirements",
"status": "backlog | pending | running | completed | failed | verified | waiting_approval",
"priority": 1,
"complexity": "simple | moderate | complex",
"dependencies": ["feature-id-1", "feature-id-2"],
"createdAt": "2026-01-23T00:00:00.000Z",
"updatedAt": "2026-01-23T00:00:00.000Z",
"branchName": null,
"descriptionHistory": [],
"skipTests": false,
"model": "claude-sonnet | claude-opus | claude-haiku",
"thinkingLevel": "none | low | medium | high | ultrathink",
"reasoningEffort": "none | low | medium | high",
"imagePaths": [],
"textFilePaths": [],
"planningMode": "none | spec | full",
"requirePlanApproval": false,
"workMode": "auto | custom"
}
```
---
## Field Descriptions
### Core Fields
| Field | Type | Required | Description |
| --------------- | ------ | -------- | --------------------------------------------------------------------------------- |
| `id` | string | Yes | Unique identifier. Use format: `feature-{descriptive-name}` or `phase-{n}-{name}` |
| `category` | string | Yes | Grouping category for the feature |
| `title` | string | Yes | Short, descriptive title (3-8 words) |
| `description` | string | Yes | Detailed implementation requirements |
| `status` | string | Yes | Current state. **Must be `backlog` for Automaker to execute** |
| `priority` | number | Yes | Execution priority (1 = highest, higher numbers = lower priority) |
| `imagePaths` | array | Yes | Must be empty `[]` - Automaker populates this automatically |
| `textFilePaths` | array | Yes | Must be empty `[]` - Automaker populates this automatically |
### Optional Fields
| Field | Type | Default | Description |
| --------------------- | -------- | -------- | -------------------------------------------------------------------------------------- |
| `complexity` | string | moderate | `simple` (< 1 hour), `moderate` (1-4 hours), `complex` (> 4 hours) |
| `dependencies` | string[] | [] | Array of feature IDs that must complete first |
| `skipTests` | boolean | false | Skip test execution during verification |
| `model` | string | - | AI model: `claude-sonnet` (balanced), `claude-opus` (complex), `claude-haiku` (simple) |
| `thinkingLevel` | string | none | Extended thinking: `none`, `low`, `medium`, `high`, `ultrathink` |
| `planningMode` | string | none | `none` (direct), `spec` (generate spec first), `full` (spec + tool exploration) |
| `requirePlanApproval` | boolean | false | Pause for human approval before execution |
| `workMode` | string | auto | `auto` (continuous), `custom` (step-by-step) |
---
## Writing Effective Descriptions
### Structure for Complex Features
```markdown
## Overview
Brief summary of what this feature accomplishes.
## Requirements
- Requirement 1: Specific implementation detail
- Requirement 2: Another specific detail
- Requirement 3: Edge case to handle
## Technical Approach
- Use existing pattern from X
- Modify files A, B, C
- Follow the Y architectural pattern
## Acceptance Criteria
- GIVEN condition, WHEN action, THEN expected result
- GIVEN another condition, WHEN action, THEN expected result
## Files to Modify
- `path/to/file1.ts` - Purpose
- `path/to/file2.tsx` - Purpose
## Edge Cases
- Handle empty state
- Handle error conditions
- Handle concurrent operations
```
### Structure for Simple Features
```markdown
Add [feature] to [location].
Requirements:
- Specific requirement 1
- Specific requirement 2
Files: `path/to/main/file.ts`
```
---
## Phasing Strategy
### When to Create Phases
Create separate features (phases) when:
1. Features have clear dependencies (Phase 2 needs Phase 1's types)
2. Different complexity levels (separate simple setup from complex logic)
3. Different areas of codebase (backend vs frontend)
4. Risk isolation (core changes separate from UI changes)
### Recommended Phase Structure
```
Phase 1: Foundation / Types / Schema
Phase 2: Backend / Service Layer
Phase 3: API Routes / Endpoints
Phase 4: Frontend / UI Components
Phase 5: Integration / Testing
Phase 6: Polish / Documentation
```
### Phase Naming Convention
```
phase-1-foundation
phase-2-backend-service
phase-3-api-routes
phase-4-frontend-ui
phase-5-integration
```
---
## Example: Converting PRD to Features
### Input PRD Section
```markdown
## User Authentication Feature
Users should be able to log in with email/password and OAuth providers.
The system should support session management and secure token storage.
```
### Output Feature Files
**Phase 1: Types and Schema**
```json
{
"id": "phase-1-auth-types",
"category": "Core",
"title": "Authentication Types and Schema",
"description": "Define TypeScript types and database schema for authentication.\n\nRequirements:\n- Add User, Session, and AuthToken types to @automaker/types\n- Create database migration for users and sessions tables\n- Define AuthProvider enum (email, google, github)\n\nFiles:\n- libs/types/src/auth.ts\n- libs/types/src/index.ts\n- apps/server/src/db/migrations/",
"status": "backlog",
"priority": 1,
"complexity": "simple",
"dependencies": [],
"model": "claude-sonnet",
"planningMode": "none"
}
```
**Phase 2: Backend Service**
```json
{
"id": "phase-2-auth-service",
"category": "Core",
"title": "Authentication Service Layer",
"description": "Implement authentication service with email/password and OAuth support.\n\nRequirements:\n- Create AuthService class with login, logout, register methods\n- Implement password hashing with bcrypt\n- Add OAuth provider integration (Google, GitHub)\n- Session management with secure token generation\n\nAcceptance Criteria:\n- GIVEN valid credentials, WHEN user logs in, THEN session token is returned\n- GIVEN invalid credentials, WHEN user logs in, THEN appropriate error is returned\n- GIVEN OAuth callback, WHEN user authenticates, THEN user is created/updated and session started\n\nFiles:\n- apps/server/src/services/auth-service.ts\n- apps/server/src/services/oauth-service.ts\n- apps/server/src/lib/password.ts",
"status": "backlog",
"priority": 1,
"complexity": "complex",
"dependencies": ["phase-1-auth-types"],
"model": "claude-opus",
"thinkingLevel": "medium",
"planningMode": "spec"
}
```
**Phase 3: API Routes**
```json
{
"id": "phase-3-auth-routes",
"category": "Core",
"title": "Authentication API Endpoints",
"description": "Create REST API endpoints for authentication.\n\nEndpoints:\n- POST /api/auth/login - Email/password login\n- POST /api/auth/register - New user registration\n- POST /api/auth/logout - End session\n- GET /api/auth/me - Get current user\n- GET /api/auth/oauth/:provider - OAuth initiation\n- GET /api/auth/oauth/:provider/callback - OAuth callback\n\nFiles:\n- apps/server/src/routes/auth/index.ts\n- apps/server/src/routes/auth/routes/*.ts",
"status": "backlog",
"priority": 1,
"complexity": "moderate",
"dependencies": ["phase-2-auth-service"],
"model": "claude-sonnet",
"planningMode": "spec"
}
```
**Phase 4: Frontend UI**
```json
{
"id": "phase-4-auth-ui",
"category": "UI/UX",
"title": "Authentication UI Components",
"description": "Create login, register, and profile UI components.\n\nComponents:\n- LoginForm with email/password fields and OAuth buttons\n- RegisterForm with validation\n- UserMenu dropdown showing logged-in user\n- AuthProvider context for app-wide auth state\n\nRoutes:\n- /login - Login page\n- /register - Registration page\n- /profile - User profile page\n\nFiles:\n- apps/ui/src/components/auth/*.tsx\n- apps/ui/src/routes/login.tsx\n- apps/ui/src/routes/register.tsx\n- apps/ui/src/hooks/use-auth.ts\n- apps/ui/src/store/auth-store.ts",
"status": "backlog",
"priority": 1,
"complexity": "moderate",
"dependencies": ["phase-3-auth-routes"],
"model": "claude-sonnet",
"planningMode": "spec"
}
```
---
## Parallel vs Sequential Execution
### Features that CAN run in parallel
- Different areas of codebase with no shared files
- Independent bug fixes
- Documentation updates
- UI components that don't share state
- Separate service implementations
### Features that MUST run sequentially
- Type definitions before implementations
- Backend before frontend (if frontend calls backend)
- Database schema before data access
- Shared utilities before consumers
### Expressing Dependencies
```json
{
"id": "feature-frontend",
"dependencies": ["feature-types", "feature-backend"]
}
```
Features with dependencies won't start until all dependencies are completed.
---
## Model Selection Guide
| Complexity | Recommended Model | Thinking Level | Planning Mode |
| --------------------- | ----------------------------- | -------------- | ------------- |
| Simple (< 1 hour) | claude-haiku or claude-sonnet | none | none |
| Moderate (1-4 hours) | claude-sonnet | none or low | spec |
| Complex (> 4 hours) | claude-opus | medium or high | spec or full |
| Critical/Architecture | claude-opus | ultrathink | full |
---
## Directory Structure
```
.automaker/
└── features/
├── phase-1-foundation/
│ └── feature.json
├── phase-2-backend/
│ └── feature.json
├── phase-3-api/
│ └── feature.json
└── phase-4-frontend/
└── feature.json
```
Each feature gets its own directory. The directory name should match the feature ID.
---
## Automation Script
Create features programmatically with this pattern:
```bash
#!/bin/bash
# create-feature.sh
FEATURE_ID=$1
TITLE=$2
DESCRIPTION=$3
PRIORITY=${4:-1}
mkdir -p ".automaker/features/$FEATURE_ID"
cat > ".automaker/features/$FEATURE_ID/feature.json" << EOF
{
"id": "$FEATURE_ID",
"category": "Core",
"title": "$TITLE",
"description": "$DESCRIPTION",
"status": "backlog",
"priority": $PRIORITY,
"complexity": "moderate",
"dependencies": [],
"createdAt": "$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")",
"updatedAt": "$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")",
"model": "claude-sonnet",
"planningMode": "spec"
}
EOF
echo "Created feature: $FEATURE_ID"
```
---
## Best Practices
### DO
- Keep descriptions detailed but focused
- Include specific file paths when known
- Use GIVEN/WHEN/THEN format for acceptance criteria
- Set realistic complexity estimates
- Define clear dependencies between phases
- Use `spec` planning mode for moderate+ complexity
- Include edge cases in descriptions
### DON'T
- Create features that are too large (> 8 hours)
- Leave descriptions vague ("make it better")
- Skip dependency definitions
- Use `ultrathink` for simple tasks (wastes tokens)
- Create circular dependencies
- Put multiple unrelated changes in one feature
- Put values in `imagePaths` or `textFilePaths` (must be empty `[]`, Automaker populates them)
---
## Validation Checklist
Before running features, verify:
- [ ] Each feature has a unique ID
- [ ] All dependencies exist and are spelled correctly
- [ ] No circular dependencies
- [ ] Priorities are assigned meaningfully
- [ ] Complex features have appropriate model/thinking level
- [ ] Descriptions include enough context for implementation
- [ ] File paths match actual project structure
---
## Quick Reference: Status Flow
```
backlog → pending → running → completed → verified
↘ failed
↘ waiting_approval → completed
```
**Important:** Features must start in `backlog` status to be executable by Automaker. The system moves them through the pipeline automatically.
---
## Template: New Feature
Copy and customize:
```json
{
"id": "feature-CHANGE-ME",
"category": "Core",
"title": "CHANGE ME: Feature Title",
"description": "## Overview\nBrief description.\n\n## Requirements\n- Requirement 1\n- Requirement 2\n\n## Files\n- path/to/file.ts",
"status": "backlog",
"priority": 1,
"complexity": "moderate",
"dependencies": [],
"createdAt": "2026-01-23T00:00:00.000Z",
"updatedAt": "2026-01-23T00:00:00.000Z",
"imagePaths": [],
"textFilePaths": [],
"model": "claude-sonnet",
"planningMode": "spec",
"skipTests": false,
"workMode": "auto"
}
```

View File

@@ -32,6 +32,7 @@ import {
migrateModelId,
type PhaseModelEntry,
type ThinkingLevel,
type ReasoningEffort,
} from '@automaker/types';
// Pattern definitions for Codex/OpenAI models
@@ -162,8 +163,10 @@ export function getEffectiveModel(
export interface ResolvedPhaseModel {
/** Resolved model string (full model ID) */
model: string;
/** Optional thinking level for extended thinking */
/** Optional thinking level for extended thinking (Claude models) */
thinkingLevel?: ThinkingLevel;
/** Optional reasoning effort for timeout calculation (Codex models) */
reasoningEffort?: ReasoningEffort;
/** Provider ID if using a ClaudeCompatibleProvider */
providerId?: string;
}
@@ -205,6 +208,7 @@ export function resolvePhaseModel(
return {
model: resolveModelString(undefined, defaultModel),
thinkingLevel: undefined,
reasoningEffort: undefined,
};
}
@@ -214,12 +218,13 @@ export function resolvePhaseModel(
return {
model: resolveModelString(phaseModel, defaultModel),
thinkingLevel: undefined,
reasoningEffort: undefined,
};
}
// Handle new PhaseModelEntry object format
console.log(
`[ModelResolver] phaseModel is object format: model="${phaseModel.model}", thinkingLevel="${phaseModel.thinkingLevel}", providerId="${phaseModel.providerId}"`
`[ModelResolver] phaseModel is object format: model="${phaseModel.model}", thinkingLevel="${phaseModel.thinkingLevel}", reasoningEffort="${phaseModel.reasoningEffort}", providerId="${phaseModel.providerId}"`
);
// If providerId is set, pass through the model string unchanged
@@ -231,6 +236,7 @@ export function resolvePhaseModel(
return {
model: phaseModel.model, // Pass through unchanged
thinkingLevel: phaseModel.thinkingLevel,
reasoningEffort: phaseModel.reasoningEffort,
providerId: phaseModel.providerId,
};
}
@@ -239,5 +245,6 @@ export function resolvePhaseModel(
return {
model: resolveModelString(phaseModel.model, defaultModel),
thinkingLevel: phaseModel.thinkingLevel,
reasoningEffort: phaseModel.reasoningEffort,
};
}

View File

@@ -228,3 +228,35 @@ export interface IdeationAnalysisEvent {
result?: ProjectAnalysisResult;
error?: string;
}
// ============================================================================
// Context Sources Configuration
// ============================================================================
/**
* Configuration for which context sources to include when generating ideas.
* All values default to true for backward compatibility.
*/
export interface IdeationContextSources {
/** Include .automaker/context/*.md|.txt files */
useContextFiles: boolean;
/** Include .automaker/memory/*.md files */
useMemoryFiles: boolean;
/** Include existing features from the board */
useExistingFeatures: boolean;
/** Include existing ideas from ideation */
useExistingIdeas: boolean;
/** Include app specification (.automaker/app_spec.txt) */
useAppSpec: boolean;
}
/**
* Default context sources configuration - all enabled for backward compatibility
*/
export const DEFAULT_IDEATION_CONTEXT_SOURCES: IdeationContextSources = {
useContextFiles: true,
useMemoryFiles: true,
useExistingFeatures: true,
useExistingIdeas: true,
useAppSpec: true,
};

View File

@@ -145,6 +145,7 @@ export { DEFAULT_PROMPT_CUSTOMIZATION } from './prompts.js';
// Settings types and constants
export type {
ThemeMode,
SidebarStyle,
PlanningMode,
ThinkingLevel,
ServerLogLevel,
@@ -271,6 +272,7 @@ export {
getBareModelId,
normalizeModelString,
validateBareModelId,
supportsStructuredOutput,
} from './provider-utils.js';
// Model migration utilities
@@ -324,7 +326,9 @@ export type {
IdeationEventType,
IdeationStreamEvent,
IdeationAnalysisEvent,
IdeationContextSources,
} from './ideation.js';
export { DEFAULT_IDEATION_CONTEXT_SOURCES } from './ideation.js';
// Notification types
export type { NotificationType, Notification, NotificationsFile } from './notification.js';

View File

@@ -7,7 +7,7 @@
*/
import type { ModelProvider } from './settings.js';
import { CURSOR_MODEL_MAP, LEGACY_CURSOR_MODEL_MAP } from './cursor-models.js';
import { LEGACY_CURSOR_MODEL_MAP } from './cursor-models.js';
import { CLAUDE_MODEL_MAP, CODEX_MODEL_MAP } from './model.js';
import { OPENCODE_MODEL_CONFIG_MAP, LEGACY_OPENCODE_MODEL_MAP } from './opencode-models.js';
import { GEMINI_MODEL_MAP } from './gemini-models.js';
@@ -345,6 +345,44 @@ export function normalizeModelString(model: string | undefined | null): string {
return model;
}
/**
* Check if a model supports structured output (JSON schema)
*
* Structured output is a feature that allows the model to return responses
* conforming to a JSON schema. Currently supported by:
* - Claude models (native Anthropic API support)
* - Codex/OpenAI models (via response_format with json_schema)
*
* Models that do NOT support structured output:
* - Cursor models (uses different API format)
* - OpenCode models (various backend providers)
* - Gemini models (different API)
* - Copilot models (proxy to various backends)
*
* @param model - Model string to check
* @returns true if the model supports structured output
*
* @example
* supportsStructuredOutput('sonnet') // true (Claude)
* supportsStructuredOutput('claude-sonnet-4-20250514') // true (Claude)
* supportsStructuredOutput('codex-gpt-5.2') // true (Codex/OpenAI)
* supportsStructuredOutput('cursor-auto') // false
* supportsStructuredOutput('gemini-2.5-pro') // false
*/
export function supportsStructuredOutput(model: string | undefined | null): boolean {
// Exclude proxy providers first - they may have Claude/Codex in the model name
// but route through different APIs that don't support structured output
if (
isCursorModel(model) ||
isGeminiModel(model) ||
isOpencodeModel(model) ||
isCopilotModel(model)
) {
return false;
}
return isClaudeModel(model) || isCodexModel(model);
}
/**
* Validate that a model ID does not contain a provider prefix
*

View File

@@ -78,6 +78,14 @@ export type ServerLogLevel = 'error' | 'warn' | 'info' | 'debug';
/** ThinkingLevel - Extended thinking levels for Claude models (reasoning intensity) */
export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
/**
* SidebarStyle - Sidebar layout style options
*
* - 'unified': Single sidebar with integrated project dropdown (default, modern)
* - 'discord': Two sidebars - narrow project switcher + expandable navigation sidebar (classic)
*/
export type SidebarStyle = 'unified' | 'discord';
/**
* Thinking token budget mapping based on Claude SDK documentation.
* @see https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking
@@ -836,6 +844,10 @@ export interface GlobalSettings {
// UI State Preferences
/** Whether sidebar is currently open */
sidebarOpen: boolean;
/** Sidebar layout style ('unified' = modern single sidebar, 'discord' = classic two-sidebar layout) */
sidebarStyle: SidebarStyle;
/** Collapsed state of sidebar navigation sections (key: section label, value: is collapsed) */
collapsedNavSections?: Record<string, boolean>;
/** Whether chat history panel is open */
chatHistoryOpen: boolean;
@@ -861,6 +873,10 @@ export interface GlobalSettings {
/** Mute completion notification sound */
muteDoneSound: boolean;
// Splash Screen
/** Disable the splash screen overlay on app startup */
disableSplashScreen: boolean;
// Server Logging Preferences
/** Log level for the API server (error, warn, info, debug). Default: info */
serverLogLevel?: ServerLogLevel;
@@ -1310,6 +1326,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
skipClaudeSetup: false,
theme: 'dark',
sidebarOpen: true,
sidebarStyle: 'unified',
collapsedNavSections: {},
chatHistoryOpen: false,
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
defaultSkipTests: true,
@@ -1320,6 +1338,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
defaultRequirePlanApproval: false,
defaultFeatureModel: { model: 'claude-opus' }, // Use canonical ID
muteDoneSound: false,
disableSplashScreen: false,
serverLogLevel: 'info',
enableRequestLogging: true,
showQueryDevtools: true,

View File

@@ -97,6 +97,8 @@ export interface LoadContextFilesOptions {
projectPath: string;
/** Optional custom secure fs module (for dependency injection) */
fsModule?: ContextFsModule;
/** Whether to include context files from .automaker/context/ (default: true) */
includeContextFiles?: boolean;
/** Whether to include memory files from .automaker/memory/ (default: true) */
includeMemory?: boolean;
/** Whether to initialize memory folder if it doesn't exist (default: true) */
@@ -210,6 +212,7 @@ export async function loadContextFiles(
const {
projectPath,
fsModule = secureFs,
includeContextFiles = true,
includeMemory = true,
initializeMemory = true,
taskContext,
@@ -220,42 +223,44 @@ export async function loadContextFiles(
const files: ContextFileInfo[] = [];
const memoryFiles: MemoryFileInfo[] = [];
// Load context files
try {
// Check if directory exists
await fsModule.access(contextDir);
// Load context files if enabled
if (includeContextFiles) {
try {
// Check if directory exists
await fsModule.access(contextDir);
// Read directory contents
const allFiles = await fsModule.readdir(contextDir);
// Read directory contents
const allFiles = await fsModule.readdir(contextDir);
// Filter for text-based context files (case-insensitive for cross-platform)
const textFiles = allFiles.filter((f) => {
const lower = f.toLowerCase();
return (lower.endsWith('.md') || lower.endsWith('.txt')) && f !== 'context-metadata.json';
});
// Filter for text-based context files (case-insensitive for cross-platform)
const textFiles = allFiles.filter((f) => {
const lower = f.toLowerCase();
return (lower.endsWith('.md') || lower.endsWith('.txt')) && f !== 'context-metadata.json';
});
if (textFiles.length > 0) {
// Load metadata for descriptions
const metadata = await loadContextMetadata(contextDir, fsModule);
if (textFiles.length > 0) {
// Load metadata for descriptions
const metadata = await loadContextMetadata(contextDir, fsModule);
// Load each file with its content and metadata
for (const fileName of textFiles) {
const filePath = path.join(contextDir, fileName);
try {
const content = await fsModule.readFile(filePath, 'utf-8');
files.push({
name: fileName,
path: filePath,
content: content as string,
description: metadata.files[fileName]?.description,
});
} catch (error) {
console.warn(`[ContextLoader] Failed to read context file ${fileName}:`, error);
// Load each file with its content and metadata
for (const fileName of textFiles) {
const filePath = path.join(contextDir, fileName);
try {
const content = await fsModule.readFile(filePath, 'utf-8');
files.push({
name: fileName,
path: filePath,
content: content as string,
description: metadata.files[fileName]?.description,
});
} catch (error) {
console.warn(`[ContextLoader] Failed to read context file ${fileName}:`, error);
}
}
}
} catch {
// Context directory doesn't exist or is inaccessible - that's fine
}
} catch {
// Context directory doesn't exist or is inaccessible - that's fine
}
// Load memory files if enabled (with smart selection)