mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-05 09:33:07 +00:00
Compare commits
50 Commits
fix/spec-g
...
906f471521
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
906f471521 | ||
|
|
a10ddadbde | ||
|
|
3399d48823 | ||
|
|
7f5c5e864d | ||
|
|
35d2d41821 | ||
|
|
7a5cb38a37 | ||
|
|
c9833b67a0 | ||
|
|
0f11ee2212 | ||
|
|
74b301c2d1 | ||
|
|
81ee2d1399 | ||
|
|
07f95ae13b | ||
|
|
8dd6ab2161 | ||
|
|
b5143f4b00 | ||
|
|
f5efa857ca | ||
|
|
c401bf4e63 | ||
|
|
43d5ec9aed | ||
|
|
f8108b1a6c | ||
|
|
a4c43b99a5 | ||
|
|
0f00180c50 | ||
|
|
22853c988a | ||
|
|
e52837cbe7 | ||
|
|
d12e0705f0 | ||
|
|
a3e536b8e6 | ||
|
|
43661e5a6e | ||
|
|
1b2bf0df3f | ||
|
|
db87e83aed | ||
|
|
92b1fb3725 | ||
|
|
d7f86d142a | ||
|
|
bbe669cdf2 | ||
|
|
8e13245aab | ||
|
|
ed92d4fd80 | ||
|
|
a6190f71b3 | ||
|
|
d04934359a | ||
|
|
7246debb69 | ||
|
|
066ffe5639 | ||
|
|
7bf02b64fa | ||
|
|
a3c62e8358 | ||
|
|
1ecb97b71c | ||
|
|
1e87b73dfd | ||
|
|
5a3dac1533 | ||
|
|
f3b16ad8ce | ||
|
|
140c444e6f | ||
|
|
907c1d65b3 | ||
|
|
92f2702f3b | ||
|
|
735786701f | ||
|
|
900bbb5e80 | ||
|
|
bc3e3dad1c | ||
|
|
d8fa5c4cd1 | ||
|
|
f005c30017 | ||
|
|
4012a2964a |
@@ -25,6 +25,7 @@ COPY libs/types/package*.json ./libs/types/
|
|||||||
COPY libs/utils/package*.json ./libs/utils/
|
COPY libs/utils/package*.json ./libs/utils/
|
||||||
COPY libs/prompts/package*.json ./libs/prompts/
|
COPY libs/prompts/package*.json ./libs/prompts/
|
||||||
COPY libs/platform/package*.json ./libs/platform/
|
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/model-resolver/package*.json ./libs/model-resolver/
|
||||||
COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
|
COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
|
||||||
COPY libs/git-utils/package*.json ./libs/git-utils/
|
COPY libs/git-utils/package*.json ./libs/git-utils/
|
||||||
|
|||||||
@@ -98,9 +98,14 @@ const TEXT_ENCODING = 'utf-8';
|
|||||||
* This is the "no output" timeout - if the CLI doesn't produce any JSONL output
|
* 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
|
* for this duration, the process is killed. For reasoning models with high
|
||||||
* reasoning effort, this timeout is dynamically extended via calculateReasoningTimeout().
|
* 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
|
* @see calculateReasoningTimeout from @automaker/types
|
||||||
*/
|
*/
|
||||||
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
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 CONTEXT_WINDOW_256K = 256000;
|
||||||
const MAX_OUTPUT_32K = 32000;
|
const MAX_OUTPUT_32K = 32000;
|
||||||
const MAX_OUTPUT_16K = 16000;
|
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
|
// Higher reasoning effort (e.g., 'xhigh' for "xtra thinking" mode) requires more time
|
||||||
// for the model to generate reasoning tokens before producing output.
|
// for the model to generate reasoning tokens before producing output.
|
||||||
// This fixes GitHub issue #530 where features would get stuck with reasoning models.
|
// 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({
|
const stream = spawnJSONLProcess({
|
||||||
command: commandPath,
|
command: commandPath,
|
||||||
|
|||||||
@@ -8,10 +8,11 @@
|
|||||||
import * as secureFs from '../../lib/secure-fs.js';
|
import * as secureFs from '../../lib/secure-fs.js';
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
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 { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||||
import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
||||||
|
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||||
import { getAppSpecPath } from '@automaker/platform';
|
import { getAppSpecPath } from '@automaker/platform';
|
||||||
import type { SettingsService } from '../../services/settings-service.js';
|
import type { SettingsService } from '../../services/settings-service.js';
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +26,64 @@ const logger = createLogger('SpecRegeneration');
|
|||||||
|
|
||||||
const DEFAULT_MAX_FEATURES = 50;
|
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(
|
export async function generateFeaturesFromSpec(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
events: EventEmitter,
|
events: EventEmitter,
|
||||||
@@ -136,23 +195,80 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
|
|||||||
provider: undefined,
|
provider: undefined,
|
||||||
credentials: 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');
|
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
|
// Use streamingQuery with event callbacks
|
||||||
const result = await streamingQuery({
|
const result = await streamingQuery({
|
||||||
prompt,
|
prompt: finalPrompt,
|
||||||
model,
|
model,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
maxTurns: 250,
|
maxTurns: 250,
|
||||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||||
abortController,
|
abortController,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
|
reasoningEffort: effectiveReasoningEffort, // Extended timeout for Codex models
|
||||||
readOnly: true, // Feature generation only reads code, doesn't write
|
readOnly: true, // Feature generation only reads code, doesn't write
|
||||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||||
|
outputFormat: useStructuredOutput
|
||||||
|
? {
|
||||||
|
type: 'json_schema',
|
||||||
|
schema: featuresOutputSchema,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
onText: (text) => {
|
onText: (text) => {
|
||||||
logger.debug(`Feature text block received (${text.length} chars)`);
|
logger.debug(`Feature text block received (${text.length} chars)`);
|
||||||
events.emit('spec-regeneration:event', {
|
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;
|
||||||
|
|
||||||
|
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 stream complete.`);
|
||||||
logger.info(`Feature response length: ${responseText.length} chars`);
|
logger.info(`Feature response length: ${rawText.length} chars`);
|
||||||
logger.info('========== FULL RESPONSE TEXT ==========');
|
logger.info('========== FULL RESPONSE TEXT ==========');
|
||||||
logger.info(responseText);
|
logger.info(rawText);
|
||||||
logger.info('========== END RESPONSE TEXT ==========');
|
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 ==========');
|
logger.debug('========== generateFeaturesFromSpec() completed ==========');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import * as secureFs from '../../lib/secure-fs.js';
|
|||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import { specOutputSchema, specToXml, type SpecOutput } from '../../lib/app-spec-format.js';
|
import { specOutputSchema, specToXml, type SpecOutput } from '../../lib/app-spec-format.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { DEFAULT_PHASE_MODELS, isClaudeModel, isCodexModel } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { extractJson } from '../../lib/json-extractor.js';
|
import { extractJson } from '../../lib/json-extractor.js';
|
||||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||||
@@ -120,10 +120,13 @@ ${prompts.appSpec.structuredSpecInstructions}`;
|
|||||||
let responseText = '';
|
let responseText = '';
|
||||||
let structuredOutput: SpecOutput | null = null;
|
let structuredOutput: SpecOutput | null = null;
|
||||||
|
|
||||||
// Determine if we should use structured output (only Claude and Codex support it)
|
// Determine if we should use structured output based on model type
|
||||||
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
|
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;
|
let finalPrompt = prompt;
|
||||||
if (!useStructuredOutput) {
|
if (!useStructuredOutput) {
|
||||||
finalPrompt = `${prompt}
|
finalPrompt = `${prompt}
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
import * as secureFs from '../../lib/secure-fs.js';
|
import * as secureFs from '../../lib/secure-fs.js';
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
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 { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||||
|
import { extractJson } from '../../lib/json-extractor.js';
|
||||||
import { getAppSpecPath } from '@automaker/platform';
|
import { getAppSpecPath } from '@automaker/platform';
|
||||||
import type { SettingsService } from '../../services/settings-service.js';
|
import type { SettingsService } from '../../services/settings-service.js';
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +35,28 @@ import { getNotificationService } from '../../services/notification-service.js';
|
|||||||
|
|
||||||
const logger = createLogger('SpecSync');
|
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
|
* 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');
|
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
|
// 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(', ')}
|
Current known technologies: ${currentTechStack.join(', ')}
|
||||||
|
|
||||||
@@ -193,6 +222,16 @@ Return ONLY this JSON format, no other text:
|
|||||||
"technologies": ["Technology 1", "Technology 2", ...]
|
"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 {
|
try {
|
||||||
const techResult = await streamingQuery({
|
const techResult = await streamingQuery({
|
||||||
prompt: techAnalysisPrompt,
|
prompt: techAnalysisPrompt,
|
||||||
@@ -206,17 +245,44 @@ Return ONLY this JSON format, no other text:
|
|||||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||||
|
outputFormat: useStructuredOutput
|
||||||
|
? {
|
||||||
|
type: 'json_schema',
|
||||||
|
schema: techStackOutputSchema,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
onText: (text) => {
|
onText: (text) => {
|
||||||
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
|
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse tech stack from response
|
// Parse tech stack from response - prefer structured output if available
|
||||||
const jsonMatch = techResult.text.match(/\{[\s\S]*"technologies"[\s\S]*\}/);
|
let parsedTechnologies: string[] | null = null;
|
||||||
if (jsonMatch) {
|
|
||||||
const parsed = JSON.parse(jsonMatch[0]);
|
if (techResult.structured_output) {
|
||||||
if (Array.isArray(parsed.technologies)) {
|
// Use structured output from Claude/Codex models
|
||||||
const newTechStack = parsed.technologies as string[];
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedTechnologies) {
|
||||||
|
const newTechStack = parsedTechnologies;
|
||||||
|
|
||||||
// Calculate differences
|
// Calculate differences
|
||||||
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
|
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
|
||||||
@@ -235,17 +301,13 @@ Return ONLY this JSON format, no other text:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update spec with new tech stack if there are changes
|
// Update spec with new tech stack if there are changes
|
||||||
if (
|
if (result.techStackUpdates.added.length > 0 || result.techStackUpdates.removed.length > 0) {
|
||||||
result.techStackUpdates.added.length > 0 ||
|
|
||||||
result.techStackUpdates.removed.length > 0
|
|
||||||
) {
|
|
||||||
specContent = updateTechnologyStack(specContent, newTechStack);
|
specContent = updateTechnologyStack(specContent, newTechStack);
|
||||||
logger.info(
|
logger.info(
|
||||||
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
|
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Failed to analyze tech stack:', error);
|
logger.warn('Failed to analyze tech stack:', error);
|
||||||
// Continue with other sync operations
|
// Continue with other sync operations
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
isCodexModel,
|
isCodexModel,
|
||||||
isCursorModel,
|
isCursorModel,
|
||||||
isOpencodeModel,
|
isOpencodeModel,
|
||||||
|
supportsStructuredOutput,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { extractJson } from '../../../lib/json-extractor.js';
|
import { extractJson } from '../../../lib/json-extractor.js';
|
||||||
@@ -124,8 +125,9 @@ async function runValidation(
|
|||||||
const prompts = await getPromptCustomization(settingsService, '[ValidateIssue]');
|
const prompts = await getPromptCustomization(settingsService, '[ValidateIssue]');
|
||||||
const issueValidationSystemPrompt = prompts.issueValidation.systemPrompt;
|
const issueValidationSystemPrompt = prompts.issueValidation.systemPrompt;
|
||||||
|
|
||||||
// Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't)
|
// Determine if we should use structured output based on model type
|
||||||
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
|
// 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
|
// Build the final prompt - for Cursor, include system prompt and JSON schema instructions
|
||||||
let finalPrompt = basePrompt;
|
let finalPrompt = basePrompt;
|
||||||
|
|||||||
@@ -4,15 +4,21 @@
|
|||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||||
|
import type { IdeationContextSources } from '@automaker/types';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
const logger = createLogger('ideation:suggestions-generate');
|
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) {
|
export function createSuggestionsGenerateHandler(ideationService: IdeationService) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, promptId, category, count } = req.body;
|
const { projectPath, promptId, category, count, contextSources } = req.body;
|
||||||
|
|
||||||
if (!projectPath) {
|
if (!projectPath) {
|
||||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||||
@@ -38,7 +44,8 @@ export function createSuggestionsGenerateHandler(ideationService: IdeationServic
|
|||||||
projectPath,
|
projectPath,
|
||||||
promptId,
|
promptId,
|
||||||
category,
|
category,
|
||||||
suggestionCount
|
suggestionCount,
|
||||||
|
contextSources as IdeationContextSources | undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -39,8 +39,15 @@ interface GitHubRemoteCacheEntry {
|
|||||||
checkedAt: number;
|
checkedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GitHubPRCacheEntry {
|
||||||
|
prs: Map<string, WorktreePRInfo>;
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
const githubRemoteCache = new Map<string, GitHubRemoteCacheEntry>();
|
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_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 {
|
interface WorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -180,9 +187,21 @@ async function getGitHubRemoteStatus(projectPath: string): Promise<GitHubRemoteS
|
|||||||
* This also allows detecting PRs that were created outside the app.
|
* This also allows detecting PRs that were created outside the app.
|
||||||
*
|
*
|
||||||
* Uses cached GitHub remote status to avoid repeated warnings when the
|
* 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>();
|
const prMap = new Map<string, WorktreePRInfo>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -225,8 +244,22 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
|
|||||||
createdAt: pr.createdAt,
|
createdAt: pr.createdAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only update cache on successful fetch
|
||||||
|
githubPRCache.set(projectPath, {
|
||||||
|
prs: prMap,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
});
|
||||||
} catch (error) {
|
} 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)}`);
|
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).
|
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
|
||||||
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
|
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
|
||||||
const githubPRs = includeDetails
|
const githubPRs = includeDetails
|
||||||
? await fetchGitHubPRs(projectPath)
|
? await fetchGitHubPRs(projectPath, forceRefreshGitHub)
|
||||||
: new Map<string, WorktreePRInfo>();
|
: new Map<string, WorktreePRInfo>();
|
||||||
|
|
||||||
for (const worktree of worktrees) {
|
for (const worktree of worktrees) {
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ interface RunningFeature {
|
|||||||
abortController: AbortController;
|
abortController: AbortController;
|
||||||
isAutoMode: boolean;
|
isAutoMode: boolean;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
|
leaseCount: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
provider?: ModelProvider;
|
provider?: ModelProvider;
|
||||||
}
|
}
|
||||||
@@ -334,6 +335,54 @@ export class AutoModeService {
|
|||||||
this.settingsService = settingsService ?? null;
|
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.
|
* 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.
|
* This handles cases where the SDK doesn't return useful error messages.
|
||||||
@@ -1076,24 +1125,17 @@ export class AutoModeService {
|
|||||||
providedWorktreePath?: string,
|
providedWorktreePath?: string,
|
||||||
options?: {
|
options?: {
|
||||||
continuationPrompt?: string;
|
continuationPrompt?: string;
|
||||||
|
/** Internal flag: set to true when called from a method that already tracks the feature */
|
||||||
|
_calledInternally?: boolean;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.runningFeatures.has(featureId)) {
|
const tempRunningFeature = this.acquireRunningFeature({
|
||||||
throw new Error('already running');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to running features immediately to prevent race conditions
|
|
||||||
const abortController = new AbortController();
|
|
||||||
const tempRunningFeature: RunningFeature = {
|
|
||||||
featureId,
|
featureId,
|
||||||
projectPath,
|
projectPath,
|
||||||
worktreePath: null,
|
|
||||||
branchName: null,
|
|
||||||
abortController,
|
|
||||||
isAutoMode,
|
isAutoMode,
|
||||||
startTime: Date.now(),
|
allowReuse: options?._calledInternally,
|
||||||
};
|
});
|
||||||
this.runningFeatures.set(featureId, tempRunningFeature);
|
const abortController = tempRunningFeature.abortController;
|
||||||
|
|
||||||
// Save execution state when feature starts
|
// Save execution state when feature starts
|
||||||
if (isAutoMode) {
|
if (isAutoMode) {
|
||||||
@@ -1130,9 +1172,8 @@ export class AutoModeService {
|
|||||||
continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent);
|
continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent);
|
||||||
|
|
||||||
// Recursively call executeFeature with the continuation prompt
|
// Recursively call executeFeature with the continuation prompt
|
||||||
// Remove from running features temporarily, it will be added back
|
// Feature is already tracked, the recursive call will reuse the entry
|
||||||
this.runningFeatures.delete(featureId);
|
return await this.executeFeature(
|
||||||
return this.executeFeature(
|
|
||||||
projectPath,
|
projectPath,
|
||||||
featureId,
|
featureId,
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
@@ -1140,6 +1181,7 @@ export class AutoModeService {
|
|||||||
providedWorktreePath,
|
providedWorktreePath,
|
||||||
{
|
{
|
||||||
continuationPrompt,
|
continuationPrompt,
|
||||||
|
_calledInternally: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1149,9 +1191,8 @@ export class AutoModeService {
|
|||||||
logger.info(
|
logger.info(
|
||||||
`Feature ${featureId} has existing context, resuming instead of starting fresh`
|
`Feature ${featureId} has existing context, resuming instead of starting fresh`
|
||||||
);
|
);
|
||||||
// Remove from running features temporarily, resumeFeature will add it back
|
// Feature is already tracked, resumeFeature will reuse the entry
|
||||||
this.runningFeatures.delete(featureId);
|
return await this.resumeFeature(projectPath, featureId, useWorktrees, true);
|
||||||
return this.resumeFeature(projectPath, featureId, useWorktrees);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1401,7 +1442,7 @@ export class AutoModeService {
|
|||||||
logger.info(
|
logger.info(
|
||||||
`Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
`Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
||||||
);
|
);
|
||||||
this.runningFeatures.delete(featureId);
|
this.releaseRunningFeature(featureId);
|
||||||
|
|
||||||
// Update execution state after feature completes
|
// Update execution state after feature completes
|
||||||
if (this.autoLoopRunning && projectPath) {
|
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
|
// Remove from running features immediately to allow resume
|
||||||
// The abort signal will still propagate to stop any ongoing execution
|
// The abort signal will still propagate to stop any ongoing execution
|
||||||
this.runningFeatures.delete(featureId);
|
this.releaseRunningFeature(featureId, { force: true });
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1589,11 +1630,21 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
|||||||
/**
|
/**
|
||||||
* Resume a feature (continues from saved context)
|
* Resume a feature (continues from saved context)
|
||||||
*/
|
*/
|
||||||
async resumeFeature(projectPath: string, featureId: string, useWorktrees = false): Promise<void> {
|
async resumeFeature(
|
||||||
if (this.runningFeatures.has(featureId)) {
|
projectPath: string,
|
||||||
throw new Error('already running');
|
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,
|
||||||
|
projectPath,
|
||||||
|
isAutoMode: false,
|
||||||
|
allowReuse: _calledInternally,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
// Load feature to check status
|
// Load feature to check status
|
||||||
const feature = await this.loadFeature(projectPath, featureId);
|
const feature = await this.loadFeature(projectPath, featureId);
|
||||||
if (!feature) {
|
if (!feature) {
|
||||||
@@ -1609,7 +1660,8 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
|||||||
|
|
||||||
if (pipelineInfo.isPipeline) {
|
if (pipelineInfo.isPipeline) {
|
||||||
// Feature stuck in pipeline - use pipeline resume
|
// Feature stuck in pipeline - use pipeline resume
|
||||||
return this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo);
|
// Pass _alreadyTracked to prevent double-tracking
|
||||||
|
return await this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal resume flow for non-pipeline features
|
// Normal resume flow for non-pipeline features
|
||||||
@@ -1627,12 +1679,18 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
|||||||
|
|
||||||
if (hasContext) {
|
if (hasContext) {
|
||||||
// Load previous context and continue
|
// Load previous context and continue
|
||||||
|
// executeFeatureWithContext -> executeFeature will see feature is already tracked
|
||||||
const context = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
const context = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
||||||
return this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees);
|
return await this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No context, start fresh - executeFeature will handle adding to runningFeatures
|
// No context, start fresh - executeFeature will see feature is already tracked
|
||||||
return this.executeFeature(projectPath, featureId, useWorktrees, false);
|
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
|
// Reset status to in_progress and start fresh
|
||||||
await this.updateFeatureStatus(projectPath, featureId, 'in_progress');
|
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
|
// 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}`
|
`[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add to running features immediately
|
const runningEntry = this.acquireRunningFeature({
|
||||||
const abortController = new AbortController();
|
|
||||||
this.runningFeatures.set(featureId, {
|
|
||||||
featureId,
|
featureId,
|
||||||
projectPath,
|
projectPath,
|
||||||
worktreePath: null, // Will be set below
|
|
||||||
branchName: feature.branchName ?? null,
|
|
||||||
abortController,
|
|
||||||
isAutoMode: false,
|
isAutoMode: false,
|
||||||
startTime: Date.now(),
|
allowReuse: true,
|
||||||
});
|
});
|
||||||
|
const abortController = runningEntry.abortController;
|
||||||
|
runningEntry.branchName = feature.branchName ?? null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate project path
|
// Validate project path
|
||||||
@@ -1863,11 +1920,8 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
|||||||
validateWorkingDirectory(workDir);
|
validateWorkingDirectory(workDir);
|
||||||
|
|
||||||
// Update running feature with worktree info
|
// Update running feature with worktree info
|
||||||
const runningFeature = this.runningFeatures.get(featureId);
|
runningEntry.worktreePath = worktreePath;
|
||||||
if (runningFeature) {
|
runningEntry.branchName = branchName ?? null;
|
||||||
runningFeature.worktreePath = worktreePath;
|
|
||||||
runningFeature.branchName = branchName ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit resume event
|
// Emit resume event
|
||||||
this.emitAutoModeEvent('auto_mode_feature_start', {
|
this.emitAutoModeEvent('auto_mode_feature_start', {
|
||||||
@@ -1945,7 +1999,7 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} 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
|
// Validate project path early for fast failure
|
||||||
validateWorkingDirectory(projectPath);
|
validateWorkingDirectory(projectPath);
|
||||||
|
|
||||||
if (this.runningFeatures.has(featureId)) {
|
const runningEntry = this.acquireRunningFeature({
|
||||||
throw new Error(`Feature ${featureId} is already running`);
|
featureId,
|
||||||
}
|
projectPath,
|
||||||
|
isAutoMode: false,
|
||||||
const abortController = new AbortController();
|
});
|
||||||
|
const abortController = runningEntry.abortController;
|
||||||
|
|
||||||
// Load feature info for context FIRST to get branchName
|
// Load feature info for context FIRST to get branchName
|
||||||
const feature = await this.loadFeature(projectPath, featureId);
|
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);
|
const provider = ProviderFactory.getProviderNameForModel(model);
|
||||||
logger.info(`Follow-up for feature ${featureId} using model: ${model}, provider: ${provider}`);
|
logger.info(`Follow-up for feature ${featureId} using model: ${model}, provider: ${provider}`);
|
||||||
|
|
||||||
this.runningFeatures.set(featureId, {
|
runningEntry.worktreePath = worktreePath;
|
||||||
featureId,
|
runningEntry.branchName = branchName;
|
||||||
projectPath,
|
runningEntry.model = model;
|
||||||
worktreePath,
|
runningEntry.provider = provider;
|
||||||
branchName,
|
|
||||||
abortController,
|
|
||||||
isAutoMode: false,
|
|
||||||
startTime: Date.now(),
|
|
||||||
model,
|
|
||||||
provider,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update feature status to in_progress BEFORE emitting event
|
// 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 {
|
} 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, {
|
return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, {
|
||||||
continuationPrompt: prompt,
|
continuationPrompt: prompt,
|
||||||
|
_calledInternally: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ import type {
|
|||||||
SendMessageOptions,
|
SendMessageOptions,
|
||||||
PromptCategory,
|
PromptCategory,
|
||||||
IdeationPrompt,
|
IdeationPrompt,
|
||||||
|
IdeationContextSources,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
|
import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
getIdeationDir,
|
getIdeationDir,
|
||||||
getIdeasDir,
|
getIdeasDir,
|
||||||
@@ -32,8 +34,10 @@ import {
|
|||||||
getIdeationSessionsDir,
|
getIdeationSessionsDir,
|
||||||
getIdeationSessionPath,
|
getIdeationSessionPath,
|
||||||
getIdeationAnalysisPath,
|
getIdeationAnalysisPath,
|
||||||
|
getAppSpecPath,
|
||||||
ensureIdeationDir,
|
ensureIdeationDir,
|
||||||
} from '@automaker/platform';
|
} from '@automaker/platform';
|
||||||
|
import { extractXmlElements, extractImplementedFeatures } from '../lib/xml-extractor.js';
|
||||||
import { createLogger, loadContextFiles, isAbortError } from '@automaker/utils';
|
import { createLogger, loadContextFiles, isAbortError } from '@automaker/utils';
|
||||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||||
import type { SettingsService } from './settings-service.js';
|
import type { SettingsService } from './settings-service.js';
|
||||||
@@ -638,8 +642,12 @@ export class IdeationService {
|
|||||||
projectPath: string,
|
projectPath: string,
|
||||||
promptId: string,
|
promptId: string,
|
||||||
category: IdeaCategory,
|
category: IdeaCategory,
|
||||||
count: number = 10
|
count: number = 10,
|
||||||
|
contextSources?: IdeationContextSources
|
||||||
): Promise<AnalysisSuggestion[]> {
|
): 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);
|
validateWorkingDirectory(projectPath);
|
||||||
|
|
||||||
// Get the prompt
|
// Get the prompt
|
||||||
@@ -656,16 +664,26 @@ export class IdeationService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load context files
|
// Load context files (respecting toggle settings)
|
||||||
const contextResult = await loadContextFiles({
|
const contextResult = await loadContextFiles({
|
||||||
projectPath,
|
projectPath,
|
||||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||||
|
includeContextFiles: sources.useContextFiles,
|
||||||
|
includeMemory: sources.useMemoryFiles,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build context from multiple sources
|
// Build context from multiple sources
|
||||||
let contextPrompt = contextResult.formattedPrompt;
|
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) {
|
if (!contextPrompt) {
|
||||||
const projectInfo = await this.gatherBasicProjectInfo(projectPath);
|
const projectInfo = await this.gatherBasicProjectInfo(projectPath);
|
||||||
if (projectInfo) {
|
if (projectInfo) {
|
||||||
@@ -673,8 +691,11 @@ export class IdeationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gather existing features and ideas to prevent duplicates
|
// Gather existing features and ideas to prevent duplicates (respecting toggle settings)
|
||||||
const existingWorkContext = await this.gatherExistingWorkContext(projectPath);
|
const existingWorkContext = await this.gatherExistingWorkContext(projectPath, {
|
||||||
|
includeFeatures: sources.useExistingFeatures,
|
||||||
|
includeIdeas: sources.useExistingIdeas,
|
||||||
|
});
|
||||||
|
|
||||||
// Get customized prompts from settings
|
// Get customized prompts from settings
|
||||||
const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]');
|
const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]');
|
||||||
@@ -684,7 +705,7 @@ export class IdeationService {
|
|||||||
prompts.ideation.suggestionsSystemPrompt,
|
prompts.ideation.suggestionsSystemPrompt,
|
||||||
contextPrompt,
|
contextPrompt,
|
||||||
category,
|
category,
|
||||||
count,
|
suggestionCount,
|
||||||
existingWorkContext
|
existingWorkContext
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -751,7 +772,11 @@ export class IdeationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse the response into structured suggestions
|
// Parse the response into structured suggestions
|
||||||
const suggestions = this.parseSuggestionsFromResponse(responseText, category);
|
const suggestions = this.parseSuggestionsFromResponse(
|
||||||
|
responseText,
|
||||||
|
category,
|
||||||
|
suggestionCount
|
||||||
|
);
|
||||||
|
|
||||||
// Emit complete event
|
// Emit complete event
|
||||||
this.events.emit('ideation:suggestions', {
|
this.events.emit('ideation:suggestions', {
|
||||||
@@ -814,22 +839,24 @@ ${contextSection}${existingWorkSection}`;
|
|||||||
*/
|
*/
|
||||||
private parseSuggestionsFromResponse(
|
private parseSuggestionsFromResponse(
|
||||||
response: string,
|
response: string,
|
||||||
category: IdeaCategory
|
category: IdeaCategory,
|
||||||
|
count: number
|
||||||
): AnalysisSuggestion[] {
|
): AnalysisSuggestion[] {
|
||||||
try {
|
try {
|
||||||
// Try to extract JSON from the response
|
// Try to extract JSON from the response
|
||||||
const jsonMatch = response.match(/\[[\s\S]*\]/);
|
const jsonMatch = response.match(/\[[\s\S]*\]/);
|
||||||
if (!jsonMatch) {
|
if (!jsonMatch) {
|
||||||
logger.warn('No JSON array found in response, falling back to text parsing');
|
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]);
|
const parsed = JSON.parse(jsonMatch[0]);
|
||||||
if (!Array.isArray(parsed)) {
|
if (!Array.isArray(parsed)) {
|
||||||
return this.parseTextResponse(response, category);
|
return this.parseTextResponse(response, category, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsed.map((item: any, index: number) => ({
|
return parsed
|
||||||
|
.map((item: any, index: number) => ({
|
||||||
id: this.generateId('sug'),
|
id: this.generateId('sug'),
|
||||||
category,
|
category,
|
||||||
title: item.title || `Suggestion ${index + 1}`,
|
title: item.title || `Suggestion ${index + 1}`,
|
||||||
@@ -837,17 +864,22 @@ ${contextSection}${existingWorkSection}`;
|
|||||||
rationale: item.rationale || '',
|
rationale: item.rationale || '',
|
||||||
priority: item.priority || 'medium',
|
priority: item.priority || 'medium',
|
||||||
relatedFiles: item.relatedFiles || [],
|
relatedFiles: item.relatedFiles || [],
|
||||||
}));
|
}))
|
||||||
|
.slice(0, count);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Failed to parse JSON response:', 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
|
* 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[] = [];
|
const suggestions: AnalysisSuggestion[] = [];
|
||||||
|
|
||||||
// Try to find numbered items or headers
|
// 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] || '';
|
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
|
* 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
|
* Gather existing features and ideas to prevent duplicate suggestions
|
||||||
* Returns a concise list of titles grouped by status to avoid polluting context
|
* 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[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
// Load existing features from the board
|
// Load existing features from the board
|
||||||
if (this.featureLoader) {
|
if (includeFeatures && this.featureLoader) {
|
||||||
try {
|
try {
|
||||||
const features = await this.featureLoader.getAll(projectPath);
|
const features = await this.featureLoader.getAll(projectPath);
|
||||||
if (features.length > 0) {
|
if (features.length > 0) {
|
||||||
@@ -1492,6 +1590,7 @@ ${contextSection}${existingWorkSection}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load existing ideas
|
// Load existing ideas
|
||||||
|
if (includeIdeas) {
|
||||||
try {
|
try {
|
||||||
const ideas = await this.getIdeas(projectPath);
|
const ideas = await this.getIdeas(projectPath);
|
||||||
// Filter out archived ideas
|
// Filter out archived ideas
|
||||||
@@ -1521,6 +1620,7 @@ ${contextSection}${existingWorkSection}`;
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Failed to load existing ideas:', error);
|
logger.warn('Failed to load existing ideas:', error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return parts.join('\n');
|
return parts.join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,8 +325,12 @@ describe('codex-provider.ts', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
||||||
// xhigh reasoning effort should have 4x the default timeout (120000ms)
|
// xhigh reasoning effort uses 5-minute base timeout (300000ms) for feature generation
|
||||||
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh);
|
// 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 () => {
|
it('uses default timeout when no reasoning effort is specified', async () => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { ProviderFactory } from '@/providers/provider-factory.js';
|
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(() => ({
|
const mockLogger = vi.hoisted(() => ({
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
@@ -23,6 +23,13 @@ const mockLogger = vi.hoisted(() => ({
|
|||||||
debug: vi.fn(),
|
debug: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockCreateChatOptions = vi.hoisted(() =>
|
||||||
|
vi.fn(() => ({
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
systemPrompt: 'test prompt',
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock('@/lib/secure-fs.js');
|
vi.mock('@/lib/secure-fs.js');
|
||||||
vi.mock('@automaker/platform');
|
vi.mock('@automaker/platform');
|
||||||
@@ -37,10 +44,7 @@ vi.mock('@automaker/utils', async () => {
|
|||||||
});
|
});
|
||||||
vi.mock('@/providers/provider-factory.js');
|
vi.mock('@/providers/provider-factory.js');
|
||||||
vi.mock('@/lib/sdk-options.js', () => ({
|
vi.mock('@/lib/sdk-options.js', () => ({
|
||||||
createChatOptions: vi.fn(() => ({
|
createChatOptions: mockCreateChatOptions,
|
||||||
model: 'claude-sonnet-4-20250514',
|
|
||||||
systemPrompt: 'test prompt',
|
|
||||||
})),
|
|
||||||
validateWorkingDirectory: vi.fn(),
|
validateWorkingDirectory: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -786,6 +790,143 @@ describe('IdeationService', () => {
|
|||||||
service.generateSuggestions(testProjectPath, 'non-existent', 'features', 5)
|
service.generateSuggestions(testProjectPath, 'non-existent', 'features', 5)
|
||||||
).rejects.toThrow('Prompt non-existent not found');
|
).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -107,6 +107,7 @@
|
|||||||
"sonner": "2.0.7",
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "3.4.0",
|
"tailwind-merge": "3.4.0",
|
||||||
"usehooks-ts": "3.1.1",
|
"usehooks-ts": "3.1.1",
|
||||||
|
"zod": "^3.24.1 || ^4.0.0",
|
||||||
"zustand": "5.0.9"
|
"zustand": "5.0.9"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|||||||
@@ -6,14 +6,25 @@ import { SplashScreen } from './components/splash-screen';
|
|||||||
import { useSettingsSync } from './hooks/use-settings-sync';
|
import { useSettingsSync } from './hooks/use-settings-sync';
|
||||||
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
|
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
|
||||||
import { useProviderAuthInit } from './hooks/use-provider-auth-init';
|
import { useProviderAuthInit } from './hooks/use-provider-auth-init';
|
||||||
|
import { useAppStore } from './store/app-store';
|
||||||
import './styles/global.css';
|
import './styles/global.css';
|
||||||
import './styles/theme-imports';
|
import './styles/theme-imports';
|
||||||
import './styles/font-imports';
|
import './styles/font-imports';
|
||||||
|
|
||||||
const logger = createLogger('App');
|
const logger = createLogger('App');
|
||||||
|
|
||||||
|
// Key for localStorage to persist splash screen preference
|
||||||
|
const DISABLE_SPLASH_KEY = 'automaker-disable-splash';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const disableSplashScreen = useAppStore((state) => state.disableSplashScreen);
|
||||||
|
|
||||||
const [showSplash, setShowSplash] = useState(() => {
|
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
|
// Only show splash once per session
|
||||||
if (sessionStorage.getItem('automaker-splash-shown')) {
|
if (sessionStorage.getItem('automaker-splash-shown')) {
|
||||||
return false;
|
return false;
|
||||||
@@ -21,6 +32,11 @@ export default function App() {
|
|||||||
return true;
|
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
|
// Clear accumulated PerformanceMeasure entries to prevent memory leak in dev mode
|
||||||
// React's internal scheduler creates performance marks/measures that accumulate without cleanup
|
// React's internal scheduler creates performance marks/measures that accumulate without cleanup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -61,7 +77,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
{showSplash && <SplashScreen onComplete={handleSplashComplete} />}
|
{showSplash && !disableSplashScreen && <SplashScreen onComplete={handleSplashComplete} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 type { NavigateOptions } from '@tanstack/react-router';
|
||||||
import { ChevronDown, Wrench, Github } from 'lucide-react';
|
import { ChevronDown, Wrench, Github } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
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 { NavSection } from '../types';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
|
import type { SidebarStyle } from '@automaker/types';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -23,6 +24,7 @@ const sectionIcons: Record<string, React.ComponentType<{ className?: string }>>
|
|||||||
interface SidebarNavigationProps {
|
interface SidebarNavigationProps {
|
||||||
currentProject: Project | null;
|
currentProject: Project | null;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
|
sidebarStyle: SidebarStyle;
|
||||||
navSections: NavSection[];
|
navSections: NavSection[];
|
||||||
isActiveRoute: (id: string) => boolean;
|
isActiveRoute: (id: string) => boolean;
|
||||||
navigate: (opts: NavigateOptions) => void;
|
navigate: (opts: NavigateOptions) => void;
|
||||||
@@ -32,6 +34,7 @@ interface SidebarNavigationProps {
|
|||||||
export function SidebarNavigation({
|
export function SidebarNavigation({
|
||||||
currentProject,
|
currentProject,
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
|
sidebarStyle,
|
||||||
navSections,
|
navSections,
|
||||||
isActiveRoute,
|
isActiveRoute,
|
||||||
navigate,
|
navigate,
|
||||||
@@ -39,21 +42,26 @@ export function SidebarNavigation({
|
|||||||
}: SidebarNavigationProps) {
|
}: SidebarNavigationProps) {
|
||||||
const navRef = useRef<HTMLElement>(null);
|
const navRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
// Track collapsed state for each collapsible section
|
// Get collapsed state from store (persisted across restarts)
|
||||||
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
|
const { collapsedNavSections, setCollapsedNavSections, toggleNavSection } = useAppStore();
|
||||||
|
|
||||||
// Initialize collapsed state when sections change (e.g., GitHub section appears)
|
// Initialize collapsed state when sections change (e.g., GitHub section appears)
|
||||||
|
// Only set defaults for sections that don't have a persisted state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCollapsedSections((prev) => {
|
let hasNewSections = false;
|
||||||
const updated = { ...prev };
|
const updated = { ...collapsedNavSections };
|
||||||
|
|
||||||
navSections.forEach((section) => {
|
navSections.forEach((section) => {
|
||||||
if (section.collapsible && section.label && !(section.label in updated)) {
|
if (section.collapsible && section.label && !(section.label in updated)) {
|
||||||
updated[section.label] = section.defaultCollapsed ?? false;
|
updated[section.label] = section.defaultCollapsed ?? false;
|
||||||
|
hasNewSections = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return updated;
|
|
||||||
});
|
if (hasNewSections) {
|
||||||
}, [navSections]);
|
setCollapsedNavSections(updated);
|
||||||
|
}
|
||||||
|
}, [navSections, collapsedNavSections, setCollapsedNavSections]);
|
||||||
|
|
||||||
// Check scroll state
|
// Check scroll state
|
||||||
const checkScrollState = useCallback(() => {
|
const checkScrollState = useCallback(() => {
|
||||||
@@ -77,14 +85,7 @@ export function SidebarNavigation({
|
|||||||
nav.removeEventListener('scroll', checkScrollState);
|
nav.removeEventListener('scroll', checkScrollState);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
};
|
};
|
||||||
}, [checkScrollState, collapsedSections]);
|
}, [checkScrollState, collapsedNavSections]);
|
||||||
|
|
||||||
const toggleSection = useCallback((label: string) => {
|
|
||||||
setCollapsedSections((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[label]: !prev[label],
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Filter sections: always show non-project sections, only show project sections when project exists
|
// Filter sections: always show non-project sections, only show project sections when project exists
|
||||||
const visibleSections = navSections.filter((section) => {
|
const visibleSections = navSections.filter((section) => {
|
||||||
@@ -97,10 +98,17 @@ export function SidebarNavigation({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Navigation sections */}
|
||||||
{visibleSections.map((section, sectionIdx) => {
|
{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 isCollapsible = section.collapsible && section.label && sidebarOpen;
|
||||||
|
|
||||||
const SectionIcon = section.label ? sectionIcons[section.label] : null;
|
const SectionIcon = section.label ? sectionIcons[section.label] : null;
|
||||||
@@ -110,21 +118,37 @@ export function SidebarNavigation({
|
|||||||
{/* Section Label - clickable if collapsible (expanded sidebar) */}
|
{/* Section Label - clickable if collapsible (expanded sidebar) */}
|
||||||
{section.label && sidebarOpen && (
|
{section.label && sidebarOpen && (
|
||||||
<button
|
<button
|
||||||
onClick={() => isCollapsible && toggleSection(section.label!)}
|
onClick={() => isCollapsible && toggleNavSection(section.label!)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center w-full px-3 mb-1.5',
|
'group flex items-center w-full px-3 py-1.5 mb-1 rounded-md',
|
||||||
isCollapsible && 'cursor-pointer hover:text-foreground'
|
'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}
|
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}
|
{section.label}
|
||||||
</span>
|
</span>
|
||||||
{isCollapsible && (
|
{isCollapsible && (
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-3 h-3 ml-auto text-muted-foreground/50 transition-transform duration-200',
|
'w-3 h-3 ml-auto transition-all duration-200',
|
||||||
isCollapsed && '-rotate-90'
|
isCollapsed
|
||||||
|
? '-rotate-90 text-muted-foreground/50 group-hover:text-muted-foreground'
|
||||||
|
: 'text-muted-foreground/50 group-hover:text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function Sidebar() {
|
|||||||
trashedProjects,
|
trashedProjects,
|
||||||
currentProject,
|
currentProject,
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
|
sidebarStyle,
|
||||||
mobileSidebarHidden,
|
mobileSidebarHidden,
|
||||||
projectHistory,
|
projectHistory,
|
||||||
upsertAndSetCurrentProject,
|
upsertAndSetCurrentProject,
|
||||||
@@ -381,6 +382,8 @@ export function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Only show header in unified mode - in discord mode, ProjectSwitcher has the logo */}
|
||||||
|
{sidebarStyle === 'unified' && (
|
||||||
<SidebarHeader
|
<SidebarHeader
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
currentProject={currentProject}
|
currentProject={currentProject}
|
||||||
@@ -388,10 +391,12 @@ export function Sidebar() {
|
|||||||
onOpenFolder={handleOpenFolder}
|
onOpenFolder={handleOpenFolder}
|
||||||
onProjectContextMenu={handleContextMenu}
|
onProjectContextMenu={handleContextMenu}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<SidebarNavigation
|
<SidebarNavigation
|
||||||
currentProject={currentProject}
|
currentProject={currentProject}
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
|
sidebarStyle={sidebarStyle}
|
||||||
navSections={navSections}
|
navSections={navSections}
|
||||||
isActiveRoute={isActiveRoute}
|
isActiveRoute={isActiveRoute}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Slot } from '@radix-ui/react-slot';
|
|||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner, type SpinnerVariant } from '@/components/ui/spinner';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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]",
|
"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
|
/** Button variants that have colored backgrounds requiring foreground spinner color */
|
||||||
function ButtonSpinner({ className }: { className?: string }) {
|
const COLORED_BACKGROUND_VARIANTS = new Set<string>(['default', 'destructive']);
|
||||||
return <Spinner size="sm" className={className} />;
|
|
||||||
|
/** 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({
|
function Button({
|
||||||
@@ -57,6 +67,7 @@ function Button({
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const isDisabled = disabled || loading;
|
const isDisabled = disabled || loading;
|
||||||
|
const spinnerVariant = getSpinnerVariant(variant);
|
||||||
|
|
||||||
// Special handling for animated-outline variant
|
// Special handling for animated-outline variant
|
||||||
if (variant === 'animated-outline' && !asChild) {
|
if (variant === 'animated-outline' && !asChild) {
|
||||||
@@ -83,7 +94,7 @@ function Button({
|
|||||||
size === 'icon' && 'p-0 gap-0'
|
size === 'icon' && 'p-0 gap-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{loading && <ButtonSpinner />}
|
{loading && <Spinner size="sm" variant={spinnerVariant} />}
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -99,7 +110,7 @@ function Button({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{loading && <ButtonSpinner />}
|
{loading && <Spinner size="sm" variant={spinnerVariant} />}
|
||||||
{children}
|
{children}
|
||||||
</Comp>
|
</Comp>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
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> = {
|
const sizeClasses: Record<SpinnerSize, string> = {
|
||||||
xs: 'h-3 w-3',
|
xs: 'h-3 w-3',
|
||||||
@@ -11,9 +12,17 @@ const sizeClasses: Record<SpinnerSize, string> = {
|
|||||||
xl: 'h-8 w-8',
|
xl: 'h-8 w-8',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const variantClasses: Record<SpinnerVariant, string> = {
|
||||||
|
primary: 'text-primary',
|
||||||
|
foreground: 'text-primary-foreground',
|
||||||
|
muted: 'text-muted-foreground',
|
||||||
|
};
|
||||||
|
|
||||||
interface SpinnerProps {
|
interface SpinnerProps {
|
||||||
/** Size of the spinner */
|
/** Size of the spinner */
|
||||||
size?: SpinnerSize;
|
size?: SpinnerSize;
|
||||||
|
/** Color variant - use 'foreground' when on primary backgrounds */
|
||||||
|
variant?: SpinnerVariant;
|
||||||
/** Additional class names */
|
/** Additional class names */
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -21,11 +30,12 @@ interface SpinnerProps {
|
|||||||
/**
|
/**
|
||||||
* Themed spinner component using the primary brand color.
|
* Themed spinner component using the primary brand color.
|
||||||
* Use this for all loading indicators throughout the app for consistency.
|
* 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 (
|
return (
|
||||||
<Loader2
|
<Loader2
|
||||||
className={cn(sizeClasses[size], 'animate-spin text-primary', className)}
|
className={cn(sizeClasses[size], 'animate-spin', variantClasses[variant], className)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ export function TaskProgressPanel({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isCompleted && <Check className="h-3.5 w-3.5" />}
|
{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" />}
|
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -463,6 +463,16 @@ export function BoardView() {
|
|||||||
const selectedWorktreeBranch =
|
const selectedWorktreeBranch =
|
||||||
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
|
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)
|
// Get in-progress features for keyboard shortcuts (needed before actions hook)
|
||||||
// Must be after runningAutoTasks is defined
|
// Must be after runningAutoTasks is defined
|
||||||
const inProgressFeaturesForShortcuts = useMemo(() => {
|
const inProgressFeaturesForShortcuts = useMemo(() => {
|
||||||
@@ -1372,7 +1382,7 @@ export function BoardView() {
|
|||||||
setWorktreeRefreshKey((k) => k + 1);
|
setWorktreeRefreshKey((k) => k + 1);
|
||||||
}}
|
}}
|
||||||
onRemovedWorktrees={handleRemovedWorktrees}
|
onRemovedWorktrees={handleRemovedWorktrees}
|
||||||
runningFeatureIds={runningAutoTasks}
|
runningFeatureIds={runningAutoTasksAllWorktrees}
|
||||||
branchCardCounts={branchCardCounts}
|
branchCardCounts={branchCardCounts}
|
||||||
features={hookFeatures.map((f) => ({
|
features={hookFeatures.map((f) => ({
|
||||||
id: f.id,
|
id: f.id,
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', colorClass)} />
|
<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}
|
{headerAction}
|
||||||
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
|
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
|
||||||
{count}
|
{count}
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ const SortableColumnHeader = memo(function SortableColumnHeader({
|
|||||||
)}
|
)}
|
||||||
data-testid={`list-header-${column.id}`}
|
data-testid={`list-header-${column.id}`}
|
||||||
>
|
>
|
||||||
<span>{column.label}</span>
|
<span className="whitespace-nowrap truncate">{column.label}</span>
|
||||||
<SortIcon column={column.id} sortConfig={sortConfig} />
|
<SortIcon column={column.id} sortConfig={sortConfig} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -156,7 +156,7 @@ const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column
|
|||||||
)}
|
)}
|
||||||
data-testid={`list-header-${column.id}`}
|
data-testid={`list-header-${column.id}`}
|
||||||
>
|
>
|
||||||
<span>{column.label}</span>
|
<span className="whitespace-nowrap truncate">{column.label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ export function MergeWorktreeDialog({
|
|||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
Merging...
|
Merging...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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.**`,
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import { uxReviewTemplate } from './ux-review';
|
|||||||
import { testingTemplate } from './testing';
|
import { testingTemplate } from './testing';
|
||||||
import { documentationTemplate } from './documentation';
|
import { documentationTemplate } from './documentation';
|
||||||
import { optimizationTemplate } from './optimization';
|
import { optimizationTemplate } from './optimization';
|
||||||
|
import { commitTemplate } from './commit';
|
||||||
|
|
||||||
export interface PipelineStepTemplate {
|
export interface PipelineStepTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,6 +20,7 @@ export const STEP_TEMPLATES: PipelineStepTemplate[] = [
|
|||||||
testingTemplate,
|
testingTemplate,
|
||||||
documentationTemplate,
|
documentationTemplate,
|
||||||
optimizationTemplate,
|
optimizationTemplate,
|
||||||
|
commitTemplate,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Helper to get template color class
|
// Helper to get template color class
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function PlanApprovalDialog({
|
|||||||
className="bg-green-600 hover:bg-green-700 text-white"
|
className="bg-green-600 hover:bg-green-700 text-white"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
) : (
|
) : (
|
||||||
<Check className="w-4 h-4 mr-2" />
|
<Check className="w-4 h-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
|
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
|
||||||
import { Feature, useAppStore, formatShortcut } from '@/store/app-store';
|
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 { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
||||||
import { getColumnsWithPipeline, type ColumnId } from './constants';
|
import { getColumnsWithPipeline, type ColumnId } from './constants';
|
||||||
import type { PipelineConfig } from '@automaker/types';
|
import type { PipelineConfig } from '@automaker/types';
|
||||||
@@ -357,25 +358,33 @@ export function KanbanBoard({
|
|||||||
contentClassName="perf-contain"
|
contentClassName="perf-contain"
|
||||||
headerAction={
|
headerAction={
|
||||||
column.id === 'verified' ? (
|
column.id === 'verified' ? (
|
||||||
|
<TooltipProvider>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{columnFeatures.length > 0 && (
|
{columnFeatures.length > 0 && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 px-2 text-xs"
|
className="h-6 w-6 p-0"
|
||||||
onClick={onArchiveAllVerified}
|
onClick={onArchiveAllVerified}
|
||||||
data-testid="archive-all-verified-button"
|
data-testid="archive-all-verified-button"
|
||||||
>
|
>
|
||||||
<Archive className="w-3 h-3 mr-1" />
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
Complete All
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Complete All</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0 relative"
|
className="h-6 w-6 p-0 relative"
|
||||||
onClick={onShowCompletedModal}
|
onClick={onShowCompletedModal}
|
||||||
title={`Completed Features (${completedCount})`}
|
|
||||||
data-testid="completed-features-button"
|
data-testid="completed-features-button"
|
||||||
>
|
>
|
||||||
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
|
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
@@ -385,7 +394,13 @@ export function KanbanBoard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Completed Features ({completedCount})</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
) : column.id === 'backlog' ? (
|
) : column.id === 'backlog' ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
export { BranchSwitchDropdown } from './branch-switch-dropdown';
|
export { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||||
export { DevServerLogsPanel } from './dev-server-logs-panel';
|
export { DevServerLogsPanel } from './dev-server-logs-panel';
|
||||||
export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
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 { WorktreeMobileDropdown } from './worktree-mobile-dropdown';
|
||||||
export { WorktreeTab } from './worktree-tab';
|
export { WorktreeTab } from './worktree-tab';
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ export function WorktreeActionsDropdown({
|
|||||||
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
|
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
|
||||||
<span className="flex items-center mr-2">
|
<span className="flex items-center mr-2">
|
||||||
<Zap className="w-3.5 h-3.5 text-yellow-500" />
|
<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>
|
</span>
|
||||||
Stop Auto Mode
|
Stop Auto Mode
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -260,8 +260,10 @@ export function WorktreeTab({
|
|||||||
aria-label={worktree.branch}
|
aria-label={worktree.branch}
|
||||||
data-testid={`worktree-branch-${worktree.branch}`}
|
data-testid={`worktree-branch-${worktree.branch}`}
|
||||||
>
|
>
|
||||||
{isRunning && <Spinner size="xs" />}
|
{isRunning && <Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />}
|
||||||
{isActivating && !isRunning && <Spinner size="xs" />}
|
{isActivating && !isRunning && (
|
||||||
|
<Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />
|
||||||
|
)}
|
||||||
{worktree.branch}
|
{worktree.branch}
|
||||||
{cardCount !== undefined && cardCount > 0 && (
|
{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">
|
<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'
|
: 'Click to switch to this branch'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isRunning && <Spinner size="xs" />}
|
{isRunning && <Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />}
|
||||||
{isActivating && !isRunning && <Spinner size="xs" />}
|
{isActivating && !isRunning && (
|
||||||
|
<Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />
|
||||||
|
)}
|
||||||
{worktree.branch}
|
{worktree.branch}
|
||||||
{cardCount !== undefined && cardCount > 0 && (
|
{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">
|
<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">
|
||||||
|
|||||||
@@ -95,12 +95,20 @@ export function useWorktrees({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// fetchWorktrees for backward compatibility - now just triggers a refetch
|
// fetchWorktrees for backward compatibility - now just triggers a refetch
|
||||||
const fetchWorktrees = useCallback(async () => {
|
// 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({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.worktrees.all(projectPath),
|
queryKey: queryKeys.worktrees.all(projectPath),
|
||||||
});
|
});
|
||||||
return refetch();
|
const result = await refetch();
|
||||||
}, [projectPath, queryClient, refetch]);
|
return result.data?.removedWorktrees;
|
||||||
|
},
|
||||||
|
[projectPath, queryClient, refetch]
|
||||||
|
);
|
||||||
|
|
||||||
const currentWorktreePath = currentWorktree?.path ?? null;
|
const currentWorktreePath = currentWorktree?.path ?? null;
|
||||||
const selectedWorktree = currentWorktreePath
|
const selectedWorktree = currentWorktreePath
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
WorktreeMobileDropdown,
|
WorktreeMobileDropdown,
|
||||||
WorktreeActionsDropdown,
|
WorktreeActionsDropdown,
|
||||||
BranchSwitchDropdown,
|
BranchSwitchDropdown,
|
||||||
|
WorktreeDropdown,
|
||||||
} from './components';
|
} from './components';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
|
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
|
||||||
@@ -36,6 +37,9 @@ import { TestLogsPanel } from '@/components/ui/test-logs-panel';
|
|||||||
import { Undo2 } from 'lucide-react';
|
import { Undo2 } from 'lucide-react';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
|
||||||
|
/** Threshold for switching from tabs to dropdown layout (number of worktrees) */
|
||||||
|
const WORKTREE_DROPDOWN_THRESHOLD = 3;
|
||||||
|
|
||||||
export function WorktreePanel({
|
export function WorktreePanel({
|
||||||
projectPath,
|
projectPath,
|
||||||
onCreateWorktree,
|
onCreateWorktree,
|
||||||
@@ -379,13 +383,13 @@ export function WorktreePanel({
|
|||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// Periodic interval check (5 seconds) to detect branch changes on disk
|
// Periodic interval check (30 seconds) to detect branch changes on disk
|
||||||
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
|
// Reduced polling to lessen repeated worktree list calls while keeping UI reasonably fresh
|
||||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
fetchWorktrees({ silent: true });
|
fetchWorktrees({ silent: true });
|
||||||
}, 5000);
|
}, 30000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
@@ -712,12 +716,112 @@ 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 (
|
return (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
<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" />
|
<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>
|
||||||
|
|
||||||
|
{/* Dropdown layout for 3+ worktrees */}
|
||||||
|
{useDropdownLayout ? (
|
||||||
|
<>
|
||||||
|
<WorktreeDropdown
|
||||||
|
worktrees={worktrees}
|
||||||
|
isWorktreeSelected={isWorktreeSelected}
|
||||||
|
hasRunningFeatures={hasRunningFeatures}
|
||||||
|
isActivating={isActivating}
|
||||||
|
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}
|
||||||
|
aheadCount={aheadCount}
|
||||||
|
behindCount={behindCount}
|
||||||
|
hasRemoteBranch={hasRemoteBranch}
|
||||||
|
gitRepoStatus={gitRepoStatus}
|
||||||
|
hasTestCommand={hasTestCommand}
|
||||||
|
isStartingTests={isStartingTests}
|
||||||
|
hasInitScript={hasInitScript}
|
||||||
|
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{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="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{mainWorktree && (
|
{mainWorktree && (
|
||||||
<WorktreeTab
|
<WorktreeTab
|
||||||
@@ -782,7 +886,7 @@ export function WorktreePanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Worktrees section - only show if enabled */}
|
{/* Worktrees section - only show if enabled and not using dropdown layout */}
|
||||||
{useWorktreesEnabled && (
|
{useWorktreesEnabled && (
|
||||||
<>
|
<>
|
||||||
<div className="w-px h-5 bg-border mx-2" />
|
<div className="w-px h-5 bg-border mx-2" />
|
||||||
@@ -883,6 +987,8 @@ export function WorktreePanel({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* View Changes Dialog */}
|
{/* View Changes Dialog */}
|
||||||
<ViewWorktreeChangesDialog
|
<ViewWorktreeChangesDialog
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Trash2 } from 'lucide-react';
|
import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Trash2 } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { IdeationSettingsPopover } from './components/ideation-settings-popover';
|
||||||
import type { IdeaCategory } from '@automaker/types';
|
import type { IdeaCategory } from '@automaker/types';
|
||||||
import type { IdeationMode } from '@/store/ideation-store';
|
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({
|
function IdeationHeader({
|
||||||
currentMode,
|
currentMode,
|
||||||
selectedCategory,
|
selectedCategory,
|
||||||
@@ -75,6 +79,7 @@ function IdeationHeader({
|
|||||||
discardAllReady,
|
discardAllReady,
|
||||||
discardAllCount,
|
discardAllCount,
|
||||||
onDiscardAll,
|
onDiscardAll,
|
||||||
|
projectPath,
|
||||||
}: {
|
}: {
|
||||||
currentMode: IdeationMode;
|
currentMode: IdeationMode;
|
||||||
selectedCategory: IdeaCategory | null;
|
selectedCategory: IdeaCategory | null;
|
||||||
@@ -88,6 +93,7 @@ function IdeationHeader({
|
|||||||
discardAllReady: boolean;
|
discardAllReady: boolean;
|
||||||
discardAllCount: number;
|
discardAllCount: number;
|
||||||
onDiscardAll: () => void;
|
onDiscardAll: () => void;
|
||||||
|
projectPath: string;
|
||||||
}) {
|
}) {
|
||||||
const { getCategoryById } = useGuidedPrompts();
|
const { getCategoryById } = useGuidedPrompts();
|
||||||
const showBackButton = currentMode === 'prompts';
|
const showBackButton = currentMode === 'prompts';
|
||||||
@@ -157,15 +163,23 @@ function IdeationHeader({
|
|||||||
Accept All ({acceptAllCount})
|
Accept All ({acceptAllCount})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<Button onClick={onGenerateIdeas} className="gap-2">
|
<Button onClick={onGenerateIdeas} className="gap-2">
|
||||||
<Lightbulb className="w-4 h-4" />
|
<Lightbulb className="w-4 h-4" />
|
||||||
Generate Ideas
|
Generate Ideas
|
||||||
</Button>
|
</Button>
|
||||||
|
<IdeationSettingsPopover projectPath={projectPath} />
|
||||||
|
</div>
|
||||||
</div>
|
</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() {
|
export function IdeationView() {
|
||||||
const currentProject = useAppStore((s) => s.currentProject);
|
const currentProject = useAppStore((s) => s.currentProject);
|
||||||
const { currentMode, selectedCategory, setMode, setCategory } = useIdeationStore();
|
const { currentMode, selectedCategory, setMode, setCategory } = useIdeationStore();
|
||||||
@@ -282,6 +296,7 @@ export function IdeationView() {
|
|||||||
discardAllReady={discardAllReady}
|
discardAllReady={discardAllReady}
|
||||||
discardAllCount={discardAllCount}
|
discardAllCount={discardAllCount}
|
||||||
onDiscardAll={handleDiscardAll}
|
onDiscardAll={handleDiscardAll}
|
||||||
|
projectPath={currentProject.path}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dashboard - main view */}
|
{/* Dashboard - main view */}
|
||||||
|
|||||||
@@ -572,7 +572,7 @@ export function InterviewView() {
|
|||||||
>
|
>
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
Creating...
|
Creating...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -448,7 +448,7 @@ export function LoginView() {
|
|||||||
>
|
>
|
||||||
{isLoggingIn ? (
|
{isLoggingIn ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
Authenticating...
|
Authenticating...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -104,7 +104,10 @@ function FeatureDefaultModelOverrideSection({ project }: { project: Project }) {
|
|||||||
const hasOverride = !!projectOverride;
|
const hasOverride = !!projectOverride;
|
||||||
const effectiveValue = projectOverride || globalValue;
|
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 => {
|
const getModelDisplayName = (entry: PhaseModelEntry): string => {
|
||||||
if (entry.providerId) {
|
if (entry.providerId) {
|
||||||
const provider = (claudeCompatibleProviders || []).find((p) => p.id === 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;
|
return modelMap[entry.model] || entry.model;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the project-level model override for this scope.
|
||||||
|
*/
|
||||||
const handleClearOverride = () => {
|
const handleClearOverride = () => {
|
||||||
setProjectDefaultFeatureModel(project.id, null);
|
setProjectDefaultFeatureModel(project.id, null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the project-level model override for this scope.
|
||||||
|
*/
|
||||||
const handleSetOverride = (entry: PhaseModelEntry) => {
|
const handleSetOverride = (entry: PhaseModelEntry) => {
|
||||||
setProjectDefaultFeatureModel(project.id, entry);
|
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({
|
function PhaseOverrideItem({
|
||||||
phase,
|
phase,
|
||||||
project,
|
project,
|
||||||
@@ -225,7 +238,10 @@ function PhaseOverrideItem({
|
|||||||
const hasOverride = !!projectOverride;
|
const hasOverride = !!projectOverride;
|
||||||
const effectiveValue = projectOverride || globalValue;
|
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 => {
|
const getModelDisplayName = (entry: PhaseModelEntry): string => {
|
||||||
if (entry.providerId) {
|
if (entry.providerId) {
|
||||||
const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId);
|
const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId);
|
||||||
@@ -248,10 +264,16 @@ function PhaseOverrideItem({
|
|||||||
return modelMap[entry.model] || entry.model;
|
return modelMap[entry.model] || entry.model;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the project-level model override for this scope.
|
||||||
|
*/
|
||||||
const handleClearOverride = () => {
|
const handleClearOverride = () => {
|
||||||
setProjectPhaseModelOverride(project.id, phase.key, null);
|
setProjectPhaseModelOverride(project.id, phase.key, null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the project-level model override for this scope.
|
||||||
|
*/
|
||||||
const handleSetOverride = (entry: PhaseModelEntry) => {
|
const handleSetOverride = (entry: PhaseModelEntry) => {
|
||||||
setProjectPhaseModelOverride(project.id, phase.key, entry);
|
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({
|
function PhaseGroup({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
@@ -350,9 +376,11 @@ function PhaseGroup({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the per-project model overrides UI for all phase models.
|
||||||
|
*/
|
||||||
export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
|
export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
|
||||||
const { clearAllProjectPhaseModelOverrides, disabledProviders, claudeCompatibleProviders } =
|
const { clearAllProjectPhaseModelOverrides, claudeCompatibleProviders } = useAppStore();
|
||||||
useAppStore();
|
|
||||||
const [showBulkReplace, setShowBulkReplace] = useState(false);
|
const [showBulkReplace, setShowBulkReplace] = useState(false);
|
||||||
|
|
||||||
// Count how many overrides are set (including defaultFeatureModel)
|
// Count how many overrides are set (including defaultFeatureModel)
|
||||||
@@ -360,25 +388,13 @@ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
|
|||||||
const hasDefaultFeatureModelOverride = !!project.defaultFeatureModel;
|
const hasDefaultFeatureModelOverride = !!project.defaultFeatureModel;
|
||||||
const overrideCount = phaseOverrideCount + (hasDefaultFeatureModelOverride ? 1 : 0);
|
const overrideCount = phaseOverrideCount + (hasDefaultFeatureModelOverride ? 1 : 0);
|
||||||
|
|
||||||
// Check if Claude is available
|
|
||||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
|
||||||
|
|
||||||
// Check if there are any enabled ClaudeCompatibleProviders
|
// Check if there are any enabled ClaudeCompatibleProviders
|
||||||
const hasEnabledProviders =
|
const hasEnabledProviders =
|
||||||
claudeCompatibleProviders && claudeCompatibleProviders.some((p) => p.enabled !== false);
|
claudeCompatibleProviders && claudeCompatibleProviders.some((p) => p.enabled !== false);
|
||||||
|
|
||||||
if (isClaudeDisabled) {
|
/**
|
||||||
return (
|
* Clears all project-level phase model overrides for this project.
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClearAll = () => {
|
const handleClearAll = () => {
|
||||||
clearAllProjectPhaseModelOverrides(project.id);
|
clearAllProjectPhaseModelOverrides(project.id);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { darkThemes, lightThemes } from '@/config/theme-options';
|
||||||
import {
|
import {
|
||||||
UI_SANS_FONT_OPTIONS,
|
UI_SANS_FONT_OPTIONS,
|
||||||
@@ -11,6 +12,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { FontSelector } from '@/components/shared';
|
import { FontSelector } from '@/components/shared';
|
||||||
import type { Theme } from '../shared/types';
|
import type { Theme } from '../shared/types';
|
||||||
|
import type { SidebarStyle } from '@automaker/types';
|
||||||
|
|
||||||
interface AppearanceSectionProps {
|
interface AppearanceSectionProps {
|
||||||
effectiveTheme: Theme;
|
effectiveTheme: Theme;
|
||||||
@@ -18,7 +20,16 @@ interface AppearanceSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AppearanceSection({ effectiveTheme, onThemeChange }: 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
|
// Determine if current theme is light or dark
|
||||||
const isLightTheme = lightThemes.some((t) => t.value === effectiveTheme);
|
const isLightTheme = lightThemes.some((t) => t.value === effectiveTheme);
|
||||||
@@ -189,6 +200,118 @@ export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceS
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function CliInstallationCard({
|
|||||||
>
|
>
|
||||||
{isInstalling ? (
|
{isInstalling ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
Installing...
|
Installing...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -412,7 +412,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
|
|||||||
>
|
>
|
||||||
{isInstalling ? (
|
{isInstalling ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
Installing...
|
Installing...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -574,7 +574,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
|
|||||||
>
|
>
|
||||||
{isSavingApiKey ? (
|
{isSavingApiKey ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
Saving...
|
Saving...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup
|
|||||||
>
|
>
|
||||||
{isInstalling ? (
|
{isInstalling ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
Installing...
|
Installing...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -681,7 +681,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup
|
|||||||
>
|
>
|
||||||
{isSavingApiKey ? (
|
{isSavingApiKey ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
Saving...
|
Saving...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ export function CursorSetupStep({ onNext, onBack, onSkip }: CursorSetupStepProps
|
|||||||
>
|
>
|
||||||
{isLoggingIn ? (
|
{isLoggingIn ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
Waiting for login...
|
Waiting for login...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP
|
|||||||
>
|
>
|
||||||
{isLoggingIn ? (
|
{isLoggingIn ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
Waiting for login...
|
Waiting for login...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ function ClaudeContent() {
|
|||||||
>
|
>
|
||||||
{isInstalling ? (
|
{isInstalling ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
Installing...
|
Installing...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -424,7 +424,11 @@ function ClaudeContent() {
|
|||||||
disabled={isSavingApiKey || !apiKey.trim()}
|
disabled={isSavingApiKey || !apiKey.trim()}
|
||||||
className="flex-1 bg-brand-500 hover:bg-brand-600 text-white"
|
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>
|
</Button>
|
||||||
{hasApiKey && (
|
{hasApiKey && (
|
||||||
<Button
|
<Button
|
||||||
@@ -661,7 +665,7 @@ function CursorContent() {
|
|||||||
>
|
>
|
||||||
{isLoggingIn ? (
|
{isLoggingIn ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
Waiting for login...
|
Waiting for login...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -918,7 +922,7 @@ function CodexContent() {
|
|||||||
>
|
>
|
||||||
{isLoggingIn ? (
|
{isLoggingIn ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
Waiting for login...
|
Waiting for login...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -961,7 +965,7 @@ function CodexContent() {
|
|||||||
disabled={isSaving || !apiKey.trim()}
|
disabled={isSaving || !apiKey.trim()}
|
||||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
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>
|
</Button>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
@@ -1194,7 +1198,7 @@ function OpencodeContent() {
|
|||||||
>
|
>
|
||||||
{isLoggingIn ? (
|
{isLoggingIn ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
Waiting for login...
|
Waiting for login...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -1466,7 +1470,7 @@ function GeminiContent() {
|
|||||||
>
|
>
|
||||||
{isLoggingIn ? (
|
{isLoggingIn ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
Waiting for login...
|
Waiting for login...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -1509,7 +1513,7 @@ function GeminiContent() {
|
|||||||
disabled={isSaving || !apiKey.trim()}
|
disabled={isSaving || !apiKey.trim()}
|
||||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
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>
|
</Button>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
@@ -1745,7 +1749,7 @@ function CopilotContent() {
|
|||||||
>
|
>
|
||||||
{isLoggingIn ? (
|
{isLoggingIn ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
Waiting for login...
|
Waiting for login...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -68,7 +68,16 @@ export function useGenerateIdeationSuggestions(projectPath: string) {
|
|||||||
throw new Error('Ideation API not available');
|
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) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || 'Failed to generate suggestions');
|
throw new Error(result.error || 'Failed to generate suggestions');
|
||||||
|
|||||||
@@ -22,6 +22,57 @@ import { useEventRecencyStore } from './use-event-recency';
|
|||||||
const PROGRESS_DEBOUNCE_WAIT = 150;
|
const PROGRESS_DEBOUNCE_WAIT = 150;
|
||||||
const PROGRESS_DEBOUNCE_MAX_WAIT = 2000;
|
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
|
* 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
|
// This allows polling to be disabled when WebSocket events are flowing
|
||||||
recordGlobalEvent();
|
recordGlobalEvent();
|
||||||
|
|
||||||
// Invalidate features when agent completes, errors, or receives plan approval
|
// Invalidate feature list for lifecycle events
|
||||||
if (
|
if (FEATURE_LIST_INVALIDATION_EVENTS.includes(event.type)) {
|
||||||
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'
|
|
||||||
) {
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.features.all(currentProjectPath),
|
queryKey: queryKeys.features.all(currentProjectPath),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate running agents on any status change
|
// Invalidate running agents on status changes
|
||||||
if (
|
if (RUNNING_AGENTS_INVALIDATION_EVENTS.includes(event.type)) {
|
||||||
event.type === 'auto_mode_feature_start' ||
|
|
||||||
event.type === 'auto_mode_feature_complete' ||
|
|
||||||
event.type === 'auto_mode_error' ||
|
|
||||||
event.type === 'auto_mode_resuming_features'
|
|
||||||
) {
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.runningAgents.all(),
|
queryKey: queryKeys.runningAgents.all(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate specific feature when it starts or has phase changes
|
// Invalidate specific feature for phase changes
|
||||||
if (
|
if (SINGLE_FEATURE_INVALIDATION_EVENTS.includes(event.type) && hasFeatureId(event)) {
|
||||||
(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
|
|
||||||
) {
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.features.single(currentProjectPath, event.featureId),
|
queryKey: queryKeys.features.single(currentProjectPath, event.featureId),
|
||||||
});
|
});
|
||||||
@@ -156,23 +189,19 @@ export function useAutoModeQueryInvalidation(projectPath: string | undefined) {
|
|||||||
|
|
||||||
// Invalidate agent output during progress updates (DEBOUNCED)
|
// Invalidate agent output during progress updates (DEBOUNCED)
|
||||||
// Uses per-feature debouncing to batch rapid progress events during streaming
|
// 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);
|
const debouncedInvalidation = getDebouncedInvalidation(event.featureId);
|
||||||
debouncedInvalidation();
|
debouncedInvalidation();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up debounced functions when feature completes or errors
|
// Clean up debounced functions when feature completes or errors
|
||||||
// This ensures we flush any pending invalidations and free memory
|
// This ensures we flush any pending invalidations and free memory
|
||||||
if (
|
if (FEATURE_CLEANUP_EVENTS.includes(event.type) && hasFeatureId(event)) {
|
||||||
(event.type === 'auto_mode_feature_complete' || event.type === 'auto_mode_error') &&
|
|
||||||
'featureId' in event &&
|
|
||||||
event.featureId
|
|
||||||
) {
|
|
||||||
cleanupFeatureDebounce(event.featureId);
|
cleanupFeatureDebounce(event.featureId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate worktree queries when feature completes (may have created worktree)
|
// 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({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.worktrees.all(currentProjectPath),
|
queryKey: queryKeys.worktrees.all(currentProjectPath),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export interface ResponsiveKanbanConfig {
|
|||||||
* Default configuration for responsive Kanban columns
|
* Default configuration for responsive Kanban columns
|
||||||
*/
|
*/
|
||||||
const DEFAULT_CONFIG: ResponsiveKanbanConfig = {
|
const DEFAULT_CONFIG: ResponsiveKanbanConfig = {
|
||||||
columnWidth: 288, // 18rem = 288px (w-72)
|
columnWidth: 320, // Increased from 288px to accommodate longer column titles
|
||||||
columnMinWidth: 280, // Minimum column width - ensures usability
|
columnMinWidth: 320, // Increased from 280px to prevent title overflow
|
||||||
columnMaxWidth: Infinity, // No max width - columns scale evenly to fill viewport
|
columnMaxWidth: Infinity, // No max width - columns scale evenly to fill viewport
|
||||||
gap: 20, // gap-5 = 20px
|
gap: 20, // gap-5 = 20px
|
||||||
padding: 40, // px-5 on both sides = 40px (matches gap between columns)
|
padding: 40, // px-5 on both sides = 40px (matches gap between columns)
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
|||||||
defaultPlanningMode: state.defaultPlanningMode as GlobalSettings['defaultPlanningMode'],
|
defaultPlanningMode: state.defaultPlanningMode as GlobalSettings['defaultPlanningMode'],
|
||||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval as boolean,
|
defaultRequirePlanApproval: state.defaultRequirePlanApproval as boolean,
|
||||||
muteDoneSound: state.muteDoneSound as boolean,
|
muteDoneSound: state.muteDoneSound as boolean,
|
||||||
|
disableSplashScreen: state.disableSplashScreen as boolean,
|
||||||
enhancementModel: state.enhancementModel as GlobalSettings['enhancementModel'],
|
enhancementModel: state.enhancementModel as GlobalSettings['enhancementModel'],
|
||||||
validationModel: state.validationModel as GlobalSettings['validationModel'],
|
validationModel: state.validationModel as GlobalSettings['validationModel'],
|
||||||
phaseModels: state.phaseModels as GlobalSettings['phaseModels'],
|
phaseModels: state.phaseModels as GlobalSettings['phaseModels'],
|
||||||
@@ -698,6 +699,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
fontFamilySans: settings.fontFamilySans ?? null,
|
fontFamilySans: settings.fontFamilySans ?? null,
|
||||||
fontFamilyMono: settings.fontFamilyMono ?? null,
|
fontFamilyMono: settings.fontFamilyMono ?? null,
|
||||||
sidebarOpen: settings.sidebarOpen ?? true,
|
sidebarOpen: settings.sidebarOpen ?? true,
|
||||||
|
sidebarStyle: settings.sidebarStyle ?? 'unified',
|
||||||
|
collapsedNavSections: settings.collapsedNavSections ?? {},
|
||||||
chatHistoryOpen: settings.chatHistoryOpen ?? false,
|
chatHistoryOpen: settings.chatHistoryOpen ?? false,
|
||||||
maxConcurrency: settings.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
maxConcurrency: settings.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
||||||
autoModeByWorktree: restoredAutoModeByWorktree,
|
autoModeByWorktree: restoredAutoModeByWorktree,
|
||||||
@@ -711,6 +714,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
model: 'claude-opus',
|
model: 'claude-opus',
|
||||||
},
|
},
|
||||||
muteDoneSound: settings.muteDoneSound ?? false,
|
muteDoneSound: settings.muteDoneSound ?? false,
|
||||||
|
disableSplashScreen: settings.disableSplashScreen ?? false,
|
||||||
serverLogLevel: settings.serverLogLevel ?? 'info',
|
serverLogLevel: settings.serverLogLevel ?? 'info',
|
||||||
enableRequestLogging: settings.enableRequestLogging ?? true,
|
enableRequestLogging: settings.enableRequestLogging ?? true,
|
||||||
showQueryDevtools: settings.showQueryDevtools ?? true,
|
showQueryDevtools: settings.showQueryDevtools ?? true,
|
||||||
@@ -798,6 +802,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
|||||||
defaultPlanningMode: state.defaultPlanningMode,
|
defaultPlanningMode: state.defaultPlanningMode,
|
||||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
||||||
muteDoneSound: state.muteDoneSound,
|
muteDoneSound: state.muteDoneSound,
|
||||||
|
disableSplashScreen: state.disableSplashScreen,
|
||||||
serverLogLevel: state.serverLogLevel,
|
serverLogLevel: state.serverLogLevel,
|
||||||
enableRequestLogging: state.enableRequestLogging,
|
enableRequestLogging: state.enableRequestLogging,
|
||||||
enhancementModel: state.enhancementModel,
|
enhancementModel: state.enhancementModel,
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'terminalFontFamily', // Maps to terminalState.fontFamily
|
'terminalFontFamily', // Maps to terminalState.fontFamily
|
||||||
'openTerminalMode', // Maps to terminalState.openTerminalMode
|
'openTerminalMode', // Maps to terminalState.openTerminalMode
|
||||||
'sidebarOpen',
|
'sidebarOpen',
|
||||||
|
'sidebarStyle',
|
||||||
|
'collapsedNavSections',
|
||||||
'chatHistoryOpen',
|
'chatHistoryOpen',
|
||||||
'maxConcurrency',
|
'maxConcurrency',
|
||||||
'autoModeByWorktree', // Per-worktree auto mode settings (only maxConcurrency is persisted)
|
'autoModeByWorktree', // Per-worktree auto mode settings (only maxConcurrency is persisted)
|
||||||
@@ -64,6 +66,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'defaultRequirePlanApproval',
|
'defaultRequirePlanApproval',
|
||||||
'defaultFeatureModel',
|
'defaultFeatureModel',
|
||||||
'muteDoneSound',
|
'muteDoneSound',
|
||||||
|
'disableSplashScreen',
|
||||||
'serverLogLevel',
|
'serverLogLevel',
|
||||||
'enableRequestLogging',
|
'enableRequestLogging',
|
||||||
'showQueryDevtools',
|
'showQueryDevtools',
|
||||||
@@ -697,6 +700,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
useAppStore.setState({
|
useAppStore.setState({
|
||||||
theme: serverSettings.theme as unknown as ThemeMode,
|
theme: serverSettings.theme as unknown as ThemeMode,
|
||||||
sidebarOpen: serverSettings.sidebarOpen,
|
sidebarOpen: serverSettings.sidebarOpen,
|
||||||
|
sidebarStyle: serverSettings.sidebarStyle ?? 'unified',
|
||||||
|
collapsedNavSections: serverSettings.collapsedNavSections ?? {},
|
||||||
chatHistoryOpen: serverSettings.chatHistoryOpen,
|
chatHistoryOpen: serverSettings.chatHistoryOpen,
|
||||||
maxConcurrency: serverSettings.maxConcurrency,
|
maxConcurrency: serverSettings.maxConcurrency,
|
||||||
autoModeByWorktree: restoredAutoModeByWorktree,
|
autoModeByWorktree: restoredAutoModeByWorktree,
|
||||||
@@ -710,6 +715,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
? migratePhaseModelEntry(serverSettings.defaultFeatureModel)
|
? migratePhaseModelEntry(serverSettings.defaultFeatureModel)
|
||||||
: { model: 'claude-opus' },
|
: { model: 'claude-opus' },
|
||||||
muteDoneSound: serverSettings.muteDoneSound,
|
muteDoneSound: serverSettings.muteDoneSound,
|
||||||
|
disableSplashScreen: serverSettings.disableSplashScreen ?? false,
|
||||||
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
|
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
|
||||||
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
|
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
|
||||||
enhancementModel: serverSettings.enhancementModel,
|
enhancementModel: serverSettings.enhancementModel,
|
||||||
|
|||||||
@@ -237,39 +237,34 @@ function cleanFragmentedText(content: string): string {
|
|||||||
/**
|
/**
|
||||||
* Extracts a summary from completed feature context
|
* Extracts a summary from completed feature context
|
||||||
* Looks for content between <summary> and </summary> tags
|
* 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 {
|
function extractSummary(content: string): string | undefined {
|
||||||
// First, clean up any fragmented text from streaming
|
// First, clean up any fragmented text from streaming
|
||||||
const cleanedContent = cleanFragmentedText(content);
|
const cleanedContent = cleanFragmentedText(content);
|
||||||
|
|
||||||
// Look for <summary> tags - capture everything between opening and closing tags
|
// Define regex patterns to try in order of priority
|
||||||
const summaryTagMatch = cleanedContent.match(/<summary>([\s\S]*?)<\/summary>/i);
|
// Each pattern specifies which capture group contains the summary content
|
||||||
if (summaryTagMatch) {
|
const regexesToTry = [
|
||||||
// Clean up the extracted summary content as well
|
{ regex: /<summary>([\s\S]*?)<\/summary>/gi, group: 1 },
|
||||||
return cleanFragmentedText(summaryTagMatch[1]).trim();
|
{ 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 (###)
|
for (const { regex, group } of regexesToTry) {
|
||||||
// Stop at same-level ## sections (but not ###), or tool markers, or end
|
const matches = [...cleanedContent.matchAll(regex)];
|
||||||
const summaryMatch = cleanedContent.match(/## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i);
|
if (matches.length > 0) {
|
||||||
if (summaryMatch) {
|
const lastMatch = matches[matches.length - 1];
|
||||||
return cleanFragmentedText(summaryMatch[1]).trim();
|
return cleanFragmentedText(lastMatch[group]).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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
CreateIdeaInput,
|
CreateIdeaInput,
|
||||||
UpdateIdeaInput,
|
UpdateIdeaInput,
|
||||||
ConvertToFeatureOptions,
|
ConvertToFeatureOptions,
|
||||||
|
IdeationContextSources,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
|
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
|
||||||
import { getJSON, setJSON, removeItem } from './storage';
|
import { getJSON, setJSON, removeItem } from './storage';
|
||||||
@@ -114,7 +115,8 @@ export interface IdeationAPI {
|
|||||||
projectPath: string,
|
projectPath: string,
|
||||||
promptId: string,
|
promptId: string,
|
||||||
category: IdeaCategory,
|
category: IdeaCategory,
|
||||||
count?: number
|
count?: number,
|
||||||
|
contextSources?: IdeationContextSources
|
||||||
) => Promise<{ success: boolean; suggestions?: AnalysisSuggestion[]; error?: string }>;
|
) => Promise<{ success: boolean; suggestions?: AnalysisSuggestion[]; error?: string }>;
|
||||||
|
|
||||||
// Convert to feature
|
// Convert to feature
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import type {
|
|||||||
NotificationsAPI,
|
NotificationsAPI,
|
||||||
EventHistoryAPI,
|
EventHistoryAPI,
|
||||||
} from './electron';
|
} from './electron';
|
||||||
|
import type { IdeationContextSources } from '@automaker/types';
|
||||||
import type { EventHistoryFilter } from '@automaker/types';
|
import type { EventHistoryFilter } from '@automaker/types';
|
||||||
import type { Message, SessionListItem } from '@/types/electron';
|
import type { Message, SessionListItem } from '@/types/electron';
|
||||||
import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
|
import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
|
||||||
@@ -2739,9 +2740,16 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
projectPath: string,
|
projectPath: string,
|
||||||
promptId: string,
|
promptId: string,
|
||||||
category: IdeaCategory,
|
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) =>
|
convertToFeature: (projectPath: string, ideaId: string, options?: ConvertToFeatureOptions) =>
|
||||||
this.post('/api/ideation/convert', { projectPath, ideaId, ...options }),
|
this.post('/api/ideation/convert', { projectPath, ideaId, ...options }),
|
||||||
|
|||||||
@@ -1198,46 +1198,48 @@ function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts summary content from raw log output
|
* 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 {
|
export function extractSummary(rawOutput: string): string | null {
|
||||||
if (!rawOutput || !rawOutput.trim()) {
|
if (!rawOutput || !rawOutput.trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find <summary> tags first (preferred format)
|
// First, clean up any fragmented text from streaming
|
||||||
const summaryTagMatch = rawOutput.match(/<summary>([\s\S]*?)<\/summary>/);
|
// This handles cases where streaming providers send partial text chunks
|
||||||
if (summaryTagMatch) {
|
// that got separated by newlines during accumulation (e.g., "<sum\n\nmary>")
|
||||||
return summaryTagMatch[1].trim();
|
const cleanedOutput = cleanFragmentedText(rawOutput);
|
||||||
}
|
|
||||||
|
|
||||||
// Try to find markdown ## Summary section
|
// Define regex patterns to try in order of priority
|
||||||
const summaryHeaderMatch = rawOutput.match(/^##\s+Summary\s*\n([\s\S]*?)(?=\n##\s+|$)/m);
|
// Each pattern specifies a processor function to extract the summary from the match
|
||||||
if (summaryHeaderMatch) {
|
const regexesToTry: Array<{
|
||||||
return summaryHeaderMatch[1].trim();
|
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)
|
for (const { regex, processor } of regexesToTry) {
|
||||||
const otherHeaderMatch = rawOutput.match(
|
const matches = [...cleanedOutput.matchAll(regex)];
|
||||||
/^##\s+(Feature|Changes|Implementation)\s*\n([\s\S]*?)(?=\n##\s+|$)/m
|
if (matches.length > 0) {
|
||||||
);
|
const lastMatch = matches[matches.length - 1];
|
||||||
if (otherHeaderMatch) {
|
return cleanFragmentedText(processor(lastMatch)).trim();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { Sidebar } from '@/components/layout/sidebar';
|
||||||
|
import { ProjectSwitcher } from '@/components/layout/project-switcher';
|
||||||
import {
|
import {
|
||||||
FileBrowserProvider,
|
FileBrowserProvider,
|
||||||
useFileBrowser,
|
useFileBrowser,
|
||||||
@@ -167,6 +168,7 @@ function RootLayoutContent() {
|
|||||||
theme,
|
theme,
|
||||||
fontFamilySans,
|
fontFamilySans,
|
||||||
fontFamilyMono,
|
fontFamilyMono,
|
||||||
|
sidebarStyle,
|
||||||
skipSandboxWarning,
|
skipSandboxWarning,
|
||||||
setSkipSandboxWarning,
|
setSkipSandboxWarning,
|
||||||
fetchCodexModels,
|
fetchCodexModels,
|
||||||
@@ -860,6 +862,8 @@ function RootLayoutContent() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Discord-style layout: narrow project switcher + expandable sidebar */}
|
||||||
|
{sidebarStyle === 'discord' && <ProjectSwitcher />}
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div
|
<div
|
||||||
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import type {
|
|||||||
EventHook,
|
EventHook,
|
||||||
ClaudeApiProfile,
|
ClaudeApiProfile,
|
||||||
ClaudeCompatibleProvider,
|
ClaudeCompatibleProvider,
|
||||||
|
SidebarStyle,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
getAllCursorModelIds,
|
getAllCursorModelIds,
|
||||||
@@ -610,6 +611,8 @@ export interface AppState {
|
|||||||
// View state
|
// View state
|
||||||
currentView: ViewMode;
|
currentView: ViewMode;
|
||||||
sidebarOpen: boolean;
|
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
|
mobileSidebarHidden: boolean; // Completely hides sidebar on mobile
|
||||||
|
|
||||||
// Agent Session state (per-project, keyed by project path)
|
// Agent Session state (per-project, keyed by project path)
|
||||||
@@ -686,6 +689,9 @@ export interface AppState {
|
|||||||
// Audio Settings
|
// Audio Settings
|
||||||
muteDoneSound: boolean; // When true, mute the notification sound when agents complete (default: false)
|
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
|
// Server Log Level Settings
|
||||||
serverLogLevel: ServerLogLevel; // Log level for the API server (error, warn, info, debug)
|
serverLogLevel: ServerLogLevel; // Log level for the API server (error, warn, info, debug)
|
||||||
enableRequestLogging: boolean; // Enable HTTP request logging (Morgan)
|
enableRequestLogging: boolean; // Enable HTTP request logging (Morgan)
|
||||||
@@ -1046,6 +1052,9 @@ export interface AppActions {
|
|||||||
setCurrentView: (view: ViewMode) => void;
|
setCurrentView: (view: ViewMode) => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
setSidebarOpen: (open: boolean) => void;
|
setSidebarOpen: (open: boolean) => void;
|
||||||
|
setSidebarStyle: (style: SidebarStyle) => void;
|
||||||
|
setCollapsedNavSections: (sections: Record<string, boolean>) => void;
|
||||||
|
toggleNavSection: (sectionLabel: string) => void;
|
||||||
toggleMobileSidebarHidden: () => void;
|
toggleMobileSidebarHidden: () => void;
|
||||||
setMobileSidebarHidden: (hidden: boolean) => void;
|
setMobileSidebarHidden: (hidden: boolean) => void;
|
||||||
|
|
||||||
@@ -1183,6 +1192,9 @@ export interface AppActions {
|
|||||||
// Audio Settings actions
|
// Audio Settings actions
|
||||||
setMuteDoneSound: (muted: boolean) => void;
|
setMuteDoneSound: (muted: boolean) => void;
|
||||||
|
|
||||||
|
// Splash Screen actions
|
||||||
|
setDisableSplashScreen: (disabled: boolean) => void;
|
||||||
|
|
||||||
// Server Log Level actions
|
// Server Log Level actions
|
||||||
setServerLogLevel: (level: ServerLogLevel) => void;
|
setServerLogLevel: (level: ServerLogLevel) => void;
|
||||||
setEnableRequestLogging: (enabled: boolean) => void;
|
setEnableRequestLogging: (enabled: boolean) => void;
|
||||||
@@ -1471,6 +1483,8 @@ const initialState: AppState = {
|
|||||||
projectHistoryIndex: -1,
|
projectHistoryIndex: -1,
|
||||||
currentView: 'welcome',
|
currentView: 'welcome',
|
||||||
sidebarOpen: true,
|
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
|
mobileSidebarHidden: false, // Sidebar visible by default on mobile
|
||||||
lastSelectedSessionByProject: {},
|
lastSelectedSessionByProject: {},
|
||||||
theme: getStoredTheme() || 'dark', // Use localStorage theme as initial value, fallback to 'dark'
|
theme: getStoredTheme() || 'dark', // Use localStorage theme as initial value, fallback to 'dark'
|
||||||
@@ -1502,6 +1516,7 @@ const initialState: AppState = {
|
|||||||
worktreesByProject: {},
|
worktreesByProject: {},
|
||||||
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
|
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
|
||||||
muteDoneSound: false, // Default to sound enabled (not muted)
|
muteDoneSound: false, // Default to sound enabled (not muted)
|
||||||
|
disableSplashScreen: false, // Default to showing splash screen
|
||||||
serverLogLevel: 'info', // Default to info level for server logs
|
serverLogLevel: 'info', // Default to info level for server logs
|
||||||
enableRequestLogging: true, // Default to enabled for HTTP request logging
|
enableRequestLogging: true, // Default to enabled for HTTP request logging
|
||||||
showQueryDevtools: true, // Default to enabled (only shown in dev mode anyway)
|
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 }),
|
setCurrentView: (view) => set({ currentView: view }),
|
||||||
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
|
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
|
||||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
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 }),
|
toggleMobileSidebarHidden: () => set({ mobileSidebarHidden: !get().mobileSidebarHidden }),
|
||||||
setMobileSidebarHidden: (hidden) => set({ mobileSidebarHidden: hidden }),
|
setMobileSidebarHidden: (hidden) => set({ mobileSidebarHidden: hidden }),
|
||||||
|
|
||||||
@@ -2626,6 +2650,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// Audio Settings actions
|
// Audio Settings actions
|
||||||
setMuteDoneSound: (muted) => set({ muteDoneSound: muted }),
|
setMuteDoneSound: (muted) => set({ muteDoneSound: muted }),
|
||||||
|
|
||||||
|
// Splash Screen actions
|
||||||
|
setDisableSplashScreen: (disabled) => set({ disableSplashScreen: disabled }),
|
||||||
|
|
||||||
// Server Log Level actions
|
// Server Log Level actions
|
||||||
setServerLogLevel: (level) => set({ serverLogLevel: level }),
|
setServerLogLevel: (level) => set({ serverLogLevel: level }),
|
||||||
setEnableRequestLogging: (enabled) => set({ enableRequestLogging: enabled }),
|
setEnableRequestLogging: (enabled) => set({ enableRequestLogging: enabled }),
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import type {
|
|||||||
IdeationPrompt,
|
IdeationPrompt,
|
||||||
AnalysisSuggestion,
|
AnalysisSuggestion,
|
||||||
ProjectAnalysisResult,
|
ProjectAnalysisResult,
|
||||||
|
IdeationContextSources,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
|
import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Generation Job Types
|
// Generation Job Types
|
||||||
@@ -61,6 +63,9 @@ interface IdeationState {
|
|||||||
currentMode: IdeationMode;
|
currentMode: IdeationMode;
|
||||||
selectedCategory: IdeaCategory | null;
|
selectedCategory: IdeaCategory | null;
|
||||||
filterStatus: IdeaStatus | 'all';
|
filterStatus: IdeaStatus | 'all';
|
||||||
|
|
||||||
|
// Context sources per project
|
||||||
|
contextSourcesByProject: Record<string, Partial<IdeationContextSources>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -110,6 +115,21 @@ interface IdeationActions {
|
|||||||
setCategory: (category: IdeaCategory | null) => void;
|
setCategory: (category: IdeaCategory | null) => void;
|
||||||
setFilterStatus: (status: IdeaStatus | 'all') => 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
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
resetSuggestions: () => void;
|
resetSuggestions: () => void;
|
||||||
@@ -135,6 +155,7 @@ const initialState: IdeationState = {
|
|||||||
currentMode: 'dashboard',
|
currentMode: 'dashboard',
|
||||||
selectedCategory: null,
|
selectedCategory: null,
|
||||||
filterStatus: 'all',
|
filterStatus: 'all',
|
||||||
|
contextSourcesByProject: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -300,6 +321,24 @@ export const useIdeationStore = create<IdeationState & IdeationActions>()(
|
|||||||
|
|
||||||
setFilterStatus: (status) => set({ filterStatus: status }),
|
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
|
||||||
reset: () => set(initialState),
|
reset: () => set(initialState),
|
||||||
|
|
||||||
@@ -313,13 +352,14 @@ export const useIdeationStore = create<IdeationState & IdeationActions>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'automaker-ideation-store',
|
name: 'automaker-ideation-store',
|
||||||
version: 4,
|
version: 5,
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
// Only persist these fields
|
// Only persist these fields
|
||||||
ideas: state.ideas,
|
ideas: state.ideas,
|
||||||
generationJobs: state.generationJobs,
|
generationJobs: state.generationJobs,
|
||||||
analysisResult: state.analysisResult,
|
analysisResult: state.analysisResult,
|
||||||
filterStatus: state.filterStatus,
|
filterStatus: state.filterStatus,
|
||||||
|
contextSourcesByProject: state.contextSourcesByProject,
|
||||||
}),
|
}),
|
||||||
migrate: (persistedState: unknown, version: number) => {
|
migrate: (persistedState: unknown, version: number) => {
|
||||||
const state = persistedState as Record<string, unknown>;
|
const state = persistedState as Record<string, unknown>;
|
||||||
@@ -331,6 +371,13 @@ export const useIdeationStore = create<IdeationState & IdeationActions>()(
|
|||||||
generationJobs: jobs.filter((job) => job.projectPath !== undefined),
|
generationJobs: jobs.filter((job) => job.projectPath !== undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (version < 5) {
|
||||||
|
// Initialize contextSourcesByProject if not present
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
contextSourcesByProject: state.contextSourcesByProject ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
return state;
|
return state;
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
431
docs/prd-to-features-guide.md
Normal file
431
docs/prd-to-features-guide.md
Normal 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"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
migrateModelId,
|
migrateModelId,
|
||||||
type PhaseModelEntry,
|
type PhaseModelEntry,
|
||||||
type ThinkingLevel,
|
type ThinkingLevel,
|
||||||
|
type ReasoningEffort,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
|
|
||||||
// Pattern definitions for Codex/OpenAI models
|
// Pattern definitions for Codex/OpenAI models
|
||||||
@@ -162,8 +163,10 @@ export function getEffectiveModel(
|
|||||||
export interface ResolvedPhaseModel {
|
export interface ResolvedPhaseModel {
|
||||||
/** Resolved model string (full model ID) */
|
/** Resolved model string (full model ID) */
|
||||||
model: string;
|
model: string;
|
||||||
/** Optional thinking level for extended thinking */
|
/** Optional thinking level for extended thinking (Claude models) */
|
||||||
thinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
|
/** Optional reasoning effort for timeout calculation (Codex models) */
|
||||||
|
reasoningEffort?: ReasoningEffort;
|
||||||
/** Provider ID if using a ClaudeCompatibleProvider */
|
/** Provider ID if using a ClaudeCompatibleProvider */
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
}
|
}
|
||||||
@@ -205,6 +208,7 @@ export function resolvePhaseModel(
|
|||||||
return {
|
return {
|
||||||
model: resolveModelString(undefined, defaultModel),
|
model: resolveModelString(undefined, defaultModel),
|
||||||
thinkingLevel: undefined,
|
thinkingLevel: undefined,
|
||||||
|
reasoningEffort: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,12 +218,13 @@ export function resolvePhaseModel(
|
|||||||
return {
|
return {
|
||||||
model: resolveModelString(phaseModel, defaultModel),
|
model: resolveModelString(phaseModel, defaultModel),
|
||||||
thinkingLevel: undefined,
|
thinkingLevel: undefined,
|
||||||
|
reasoningEffort: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle new PhaseModelEntry object format
|
// Handle new PhaseModelEntry object format
|
||||||
console.log(
|
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
|
// If providerId is set, pass through the model string unchanged
|
||||||
@@ -231,6 +236,7 @@ export function resolvePhaseModel(
|
|||||||
return {
|
return {
|
||||||
model: phaseModel.model, // Pass through unchanged
|
model: phaseModel.model, // Pass through unchanged
|
||||||
thinkingLevel: phaseModel.thinkingLevel,
|
thinkingLevel: phaseModel.thinkingLevel,
|
||||||
|
reasoningEffort: phaseModel.reasoningEffort,
|
||||||
providerId: phaseModel.providerId,
|
providerId: phaseModel.providerId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -239,5 +245,6 @@ export function resolvePhaseModel(
|
|||||||
return {
|
return {
|
||||||
model: resolveModelString(phaseModel.model, defaultModel),
|
model: resolveModelString(phaseModel.model, defaultModel),
|
||||||
thinkingLevel: phaseModel.thinkingLevel,
|
thinkingLevel: phaseModel.thinkingLevel,
|
||||||
|
reasoningEffort: phaseModel.reasoningEffort,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,3 +228,35 @@ export interface IdeationAnalysisEvent {
|
|||||||
result?: ProjectAnalysisResult;
|
result?: ProjectAnalysisResult;
|
||||||
error?: string;
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export { DEFAULT_PROMPT_CUSTOMIZATION } from './prompts.js';
|
|||||||
// Settings types and constants
|
// Settings types and constants
|
||||||
export type {
|
export type {
|
||||||
ThemeMode,
|
ThemeMode,
|
||||||
|
SidebarStyle,
|
||||||
PlanningMode,
|
PlanningMode,
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
ServerLogLevel,
|
ServerLogLevel,
|
||||||
@@ -271,6 +272,7 @@ export {
|
|||||||
getBareModelId,
|
getBareModelId,
|
||||||
normalizeModelString,
|
normalizeModelString,
|
||||||
validateBareModelId,
|
validateBareModelId,
|
||||||
|
supportsStructuredOutput,
|
||||||
} from './provider-utils.js';
|
} from './provider-utils.js';
|
||||||
|
|
||||||
// Model migration utilities
|
// Model migration utilities
|
||||||
@@ -324,7 +326,9 @@ export type {
|
|||||||
IdeationEventType,
|
IdeationEventType,
|
||||||
IdeationStreamEvent,
|
IdeationStreamEvent,
|
||||||
IdeationAnalysisEvent,
|
IdeationAnalysisEvent,
|
||||||
|
IdeationContextSources,
|
||||||
} from './ideation.js';
|
} from './ideation.js';
|
||||||
|
export { DEFAULT_IDEATION_CONTEXT_SOURCES } from './ideation.js';
|
||||||
|
|
||||||
// Notification types
|
// Notification types
|
||||||
export type { NotificationType, Notification, NotificationsFile } from './notification.js';
|
export type { NotificationType, Notification, NotificationsFile } from './notification.js';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ModelProvider } from './settings.js';
|
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 { CLAUDE_MODEL_MAP, CODEX_MODEL_MAP } from './model.js';
|
||||||
import { OPENCODE_MODEL_CONFIG_MAP, LEGACY_OPENCODE_MODEL_MAP } from './opencode-models.js';
|
import { OPENCODE_MODEL_CONFIG_MAP, LEGACY_OPENCODE_MODEL_MAP } from './opencode-models.js';
|
||||||
import { GEMINI_MODEL_MAP } from './gemini-models.js';
|
import { GEMINI_MODEL_MAP } from './gemini-models.js';
|
||||||
@@ -345,6 +345,44 @@ export function normalizeModelString(model: string | undefined | null): string {
|
|||||||
return model;
|
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
|
* Validate that a model ID does not contain a provider prefix
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -78,6 +78,14 @@ export type ServerLogLevel = 'error' | 'warn' | 'info' | 'debug';
|
|||||||
/** ThinkingLevel - Extended thinking levels for Claude models (reasoning intensity) */
|
/** ThinkingLevel - Extended thinking levels for Claude models (reasoning intensity) */
|
||||||
export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
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.
|
* Thinking token budget mapping based on Claude SDK documentation.
|
||||||
* @see https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking
|
* @see https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking
|
||||||
@@ -836,6 +844,10 @@ export interface GlobalSettings {
|
|||||||
// UI State Preferences
|
// UI State Preferences
|
||||||
/** Whether sidebar is currently open */
|
/** Whether sidebar is currently open */
|
||||||
sidebarOpen: boolean;
|
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 */
|
/** Whether chat history panel is open */
|
||||||
chatHistoryOpen: boolean;
|
chatHistoryOpen: boolean;
|
||||||
|
|
||||||
@@ -861,6 +873,10 @@ export interface GlobalSettings {
|
|||||||
/** Mute completion notification sound */
|
/** Mute completion notification sound */
|
||||||
muteDoneSound: boolean;
|
muteDoneSound: boolean;
|
||||||
|
|
||||||
|
// Splash Screen
|
||||||
|
/** Disable the splash screen overlay on app startup */
|
||||||
|
disableSplashScreen: boolean;
|
||||||
|
|
||||||
// Server Logging Preferences
|
// Server Logging Preferences
|
||||||
/** Log level for the API server (error, warn, info, debug). Default: info */
|
/** Log level for the API server (error, warn, info, debug). Default: info */
|
||||||
serverLogLevel?: ServerLogLevel;
|
serverLogLevel?: ServerLogLevel;
|
||||||
@@ -1310,6 +1326,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
|||||||
skipClaudeSetup: false,
|
skipClaudeSetup: false,
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
sidebarOpen: true,
|
sidebarOpen: true,
|
||||||
|
sidebarStyle: 'unified',
|
||||||
|
collapsedNavSections: {},
|
||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
|
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
|
||||||
defaultSkipTests: true,
|
defaultSkipTests: true,
|
||||||
@@ -1320,6 +1338,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
|||||||
defaultRequirePlanApproval: false,
|
defaultRequirePlanApproval: false,
|
||||||
defaultFeatureModel: { model: 'claude-opus' }, // Use canonical ID
|
defaultFeatureModel: { model: 'claude-opus' }, // Use canonical ID
|
||||||
muteDoneSound: false,
|
muteDoneSound: false,
|
||||||
|
disableSplashScreen: false,
|
||||||
serverLogLevel: 'info',
|
serverLogLevel: 'info',
|
||||||
enableRequestLogging: true,
|
enableRequestLogging: true,
|
||||||
showQueryDevtools: true,
|
showQueryDevtools: true,
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ export interface LoadContextFilesOptions {
|
|||||||
projectPath: string;
|
projectPath: string;
|
||||||
/** Optional custom secure fs module (for dependency injection) */
|
/** Optional custom secure fs module (for dependency injection) */
|
||||||
fsModule?: ContextFsModule;
|
fsModule?: ContextFsModule;
|
||||||
|
/** Whether to include context files from .automaker/context/ (default: true) */
|
||||||
|
includeContextFiles?: boolean;
|
||||||
/** Whether to include memory files from .automaker/memory/ (default: true) */
|
/** Whether to include memory files from .automaker/memory/ (default: true) */
|
||||||
includeMemory?: boolean;
|
includeMemory?: boolean;
|
||||||
/** Whether to initialize memory folder if it doesn't exist (default: true) */
|
/** Whether to initialize memory folder if it doesn't exist (default: true) */
|
||||||
@@ -210,6 +212,7 @@ export async function loadContextFiles(
|
|||||||
const {
|
const {
|
||||||
projectPath,
|
projectPath,
|
||||||
fsModule = secureFs,
|
fsModule = secureFs,
|
||||||
|
includeContextFiles = true,
|
||||||
includeMemory = true,
|
includeMemory = true,
|
||||||
initializeMemory = true,
|
initializeMemory = true,
|
||||||
taskContext,
|
taskContext,
|
||||||
@@ -220,7 +223,8 @@ export async function loadContextFiles(
|
|||||||
const files: ContextFileInfo[] = [];
|
const files: ContextFileInfo[] = [];
|
||||||
const memoryFiles: MemoryFileInfo[] = [];
|
const memoryFiles: MemoryFileInfo[] = [];
|
||||||
|
|
||||||
// Load context files
|
// Load context files if enabled
|
||||||
|
if (includeContextFiles) {
|
||||||
try {
|
try {
|
||||||
// Check if directory exists
|
// Check if directory exists
|
||||||
await fsModule.access(contextDir);
|
await fsModule.access(contextDir);
|
||||||
@@ -257,6 +261,7 @@ export async function loadContextFiles(
|
|||||||
} catch {
|
} catch {
|
||||||
// Context directory doesn't exist or is inaccessible - that's fine
|
// Context directory doesn't exist or is inaccessible - that's fine
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load memory files if enabled (with smart selection)
|
// Load memory files if enabled (with smart selection)
|
||||||
if (includeMemory) {
|
if (includeMemory) {
|
||||||
|
|||||||
Reference in New Issue
Block a user