mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +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/prompts/package*.json ./libs/prompts/
|
||||
COPY libs/platform/package*.json ./libs/platform/
|
||||
COPY libs/spec-parser/package*.json ./libs/spec-parser/
|
||||
COPY libs/model-resolver/package*.json ./libs/model-resolver/
|
||||
COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
|
||||
COPY libs/git-utils/package*.json ./libs/git-utils/
|
||||
|
||||
@@ -98,9 +98,14 @@ const TEXT_ENCODING = 'utf-8';
|
||||
* This is the "no output" timeout - if the CLI doesn't produce any JSONL output
|
||||
* for this duration, the process is killed. For reasoning models with high
|
||||
* reasoning effort, this timeout is dynamically extended via calculateReasoningTimeout().
|
||||
*
|
||||
* For feature generation (which can generate 50+ features), we use a much longer
|
||||
* base timeout (5 minutes) since Codex models are slower at generating large JSON responses.
|
||||
*
|
||||
* @see calculateReasoningTimeout from @automaker/types
|
||||
*/
|
||||
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
||||
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
|
||||
const CONTEXT_WINDOW_256K = 256000;
|
||||
const MAX_OUTPUT_32K = 32000;
|
||||
const MAX_OUTPUT_16K = 16000;
|
||||
@@ -827,7 +832,14 @@ export class CodexProvider extends BaseProvider {
|
||||
// Higher reasoning effort (e.g., 'xhigh' for "xtra thinking" mode) requires more time
|
||||
// for the model to generate reasoning tokens before producing output.
|
||||
// This fixes GitHub issue #530 where features would get stuck with reasoning models.
|
||||
const timeout = calculateReasoningTimeout(options.reasoningEffort, CODEX_CLI_TIMEOUT_MS);
|
||||
//
|
||||
// For feature generation with 'xhigh', use the extended 5-minute base timeout
|
||||
// since generating 50+ features takes significantly longer than normal operations.
|
||||
const baseTimeout =
|
||||
options.reasoningEffort === 'xhigh'
|
||||
? CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS
|
||||
: CODEX_CLI_TIMEOUT_MS;
|
||||
const timeout = calculateReasoningTimeout(options.reasoningEffort, baseTimeout);
|
||||
|
||||
const stream = spawnJSONLProcess({
|
||||
command: commandPath,
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput, isCodexModel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import {
|
||||
@@ -25,6 +26,64 @@ const logger = createLogger('SpecRegeneration');
|
||||
|
||||
const DEFAULT_MAX_FEATURES = 50;
|
||||
|
||||
/**
|
||||
* Timeout for Codex models when generating features (5 minutes).
|
||||
* Codex models are slower and need more time to generate 50+ features.
|
||||
*/
|
||||
const CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Type for extracted features JSON response
|
||||
*/
|
||||
interface FeaturesExtractionResult {
|
||||
features: Array<{
|
||||
id: string;
|
||||
category?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
priority?: number;
|
||||
complexity?: 'simple' | 'moderate' | 'complex';
|
||||
dependencies?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON schema for features output format (Claude/Codex structured output)
|
||||
*/
|
||||
const featuresOutputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
features: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Unique feature identifier (kebab-case)' },
|
||||
category: { type: 'string', description: 'Feature category' },
|
||||
title: { type: 'string', description: 'Short, descriptive title' },
|
||||
description: { type: 'string', description: 'Detailed feature description' },
|
||||
priority: {
|
||||
type: 'number',
|
||||
description: 'Priority level: 1 (highest) to 5 (lowest)',
|
||||
},
|
||||
complexity: {
|
||||
type: 'string',
|
||||
enum: ['simple', 'moderate', 'complex'],
|
||||
description: 'Implementation complexity',
|
||||
},
|
||||
dependencies: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'IDs of features this depends on',
|
||||
},
|
||||
},
|
||||
required: ['id', 'title', 'description'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['features'],
|
||||
} as const;
|
||||
|
||||
export async function generateFeaturesFromSpec(
|
||||
projectPath: string,
|
||||
events: EventEmitter,
|
||||
@@ -136,23 +195,80 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
|
||||
provider: undefined,
|
||||
credentials: undefined,
|
||||
};
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
const { model, thinkingLevel, reasoningEffort } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
|
||||
|
||||
// Codex models need extended timeout for generating many features.
|
||||
// Use 'xhigh' reasoning effort to get 5-minute timeout (300s base * 1.0x = 300s).
|
||||
// The Codex provider has a special 5-minute base timeout for feature generation.
|
||||
const isCodex = isCodexModel(model);
|
||||
const effectiveReasoningEffort = isCodex ? 'xhigh' : reasoningEffort;
|
||||
|
||||
if (isCodex) {
|
||||
logger.info('Codex model detected - using extended timeout (5 minutes for feature generation)');
|
||||
}
|
||||
if (effectiveReasoningEffort) {
|
||||
logger.info('Reasoning effort:', effectiveReasoningEffort);
|
||||
}
|
||||
|
||||
// Determine if we should use structured output based on model type
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
logger.info(
|
||||
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
|
||||
);
|
||||
|
||||
// Build the final prompt - for non-Claude/Codex models, include explicit JSON instructions
|
||||
let finalPrompt = prompt;
|
||||
if (!useStructuredOutput) {
|
||||
finalPrompt = `${prompt}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. DO NOT write any files. Return the JSON in your response only.
|
||||
2. After analyzing the spec, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
|
||||
3. The JSON must have this exact structure:
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": "unique-feature-id",
|
||||
"category": "Category Name",
|
||||
"title": "Short Feature Title",
|
||||
"description": "Detailed description of the feature",
|
||||
"priority": 1,
|
||||
"complexity": "simple|moderate|complex",
|
||||
"dependencies": ["other-feature-id"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
4. Feature IDs must be unique, lowercase, kebab-case (e.g., "user-authentication", "data-export")
|
||||
5. Priority ranges from 1 (highest) to 5 (lowest)
|
||||
6. Complexity must be one of: "simple", "moderate", "complex"
|
||||
7. Dependencies is an array of feature IDs that must be completed first (can be empty)
|
||||
|
||||
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
|
||||
}
|
||||
|
||||
// Use streamingQuery with event callbacks
|
||||
const result = await streamingQuery({
|
||||
prompt,
|
||||
prompt: finalPrompt,
|
||||
model,
|
||||
cwd: projectPath,
|
||||
maxTurns: 250,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
abortController,
|
||||
thinkingLevel,
|
||||
reasoningEffort: effectiveReasoningEffort, // Extended timeout for Codex models
|
||||
readOnly: true, // Feature generation only reads code, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
schema: featuresOutputSchema,
|
||||
}
|
||||
: undefined,
|
||||
onText: (text) => {
|
||||
logger.debug(`Feature text block received (${text.length} chars)`);
|
||||
events.emit('spec-regeneration:event', {
|
||||
@@ -163,15 +279,51 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
|
||||
},
|
||||
});
|
||||
|
||||
const responseText = result.text;
|
||||
// Get response content - prefer structured output if available
|
||||
let contentForParsing: string;
|
||||
|
||||
logger.info(`Feature stream complete.`);
|
||||
logger.info(`Feature response length: ${responseText.length} chars`);
|
||||
logger.info('========== FULL RESPONSE TEXT ==========');
|
||||
logger.info(responseText);
|
||||
logger.info('========== END RESPONSE TEXT ==========');
|
||||
if (result.structured_output) {
|
||||
// Use structured output from Claude/Codex models
|
||||
logger.info('✅ Received structured output from model');
|
||||
contentForParsing = JSON.stringify(result.structured_output);
|
||||
logger.debug('Structured output:', contentForParsing);
|
||||
} else {
|
||||
// Use text response (for non-Claude/Codex models or fallback)
|
||||
// Pre-extract JSON to handle conversational text that may surround the JSON response
|
||||
// This follows the same pattern used in generate-spec.ts and validate-issue.ts
|
||||
const rawText = result.text;
|
||||
logger.info(`Feature stream complete.`);
|
||||
logger.info(`Feature response length: ${rawText.length} chars`);
|
||||
logger.info('========== FULL RESPONSE TEXT ==========');
|
||||
logger.info(rawText);
|
||||
logger.info('========== END RESPONSE TEXT ==========');
|
||||
|
||||
await parseAndCreateFeatures(projectPath, responseText, events);
|
||||
// Pre-extract JSON from response - handles conversational text around the JSON
|
||||
const extracted = extractJsonWithArray<FeaturesExtractionResult>(rawText, 'features', {
|
||||
logger,
|
||||
});
|
||||
if (extracted) {
|
||||
contentForParsing = JSON.stringify(extracted);
|
||||
logger.info('✅ Pre-extracted JSON from text response');
|
||||
} else {
|
||||
// If pre-extraction fails, we know the next step will also fail.
|
||||
// Throw an error here to avoid redundant parsing and make the failure point clearer.
|
||||
logger.error(
|
||||
'❌ Could not extract features JSON from model response. Full response text was:\n' +
|
||||
rawText
|
||||
);
|
||||
const errorMessage =
|
||||
'Failed to parse features from model response: No valid JSON with a "features" array found.';
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_error',
|
||||
error: errorMessage,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
await parseAndCreateFeatures(projectPath, contentForParsing, events);
|
||||
|
||||
logger.debug('========== generateFeaturesFromSpec() completed ==========');
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { specOutputSchema, specToXml, type SpecOutput } from '../../lib/app-spec-format.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, isClaudeModel, isCodexModel } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { extractJson } from '../../lib/json-extractor.js';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
@@ -120,10 +120,13 @@ ${prompts.appSpec.structuredSpecInstructions}`;
|
||||
let responseText = '';
|
||||
let structuredOutput: SpecOutput | null = null;
|
||||
|
||||
// Determine if we should use structured output (only Claude and Codex support it)
|
||||
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
|
||||
// Determine if we should use structured output based on model type
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
logger.info(
|
||||
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
|
||||
);
|
||||
|
||||
// Build the final prompt - for Cursor, include JSON schema instructions
|
||||
// Build the final prompt - for non-Claude/Codex models, include JSON schema instructions
|
||||
let finalPrompt = prompt;
|
||||
if (!useStructuredOutput) {
|
||||
finalPrompt = `${prompt}
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { extractJson } from '../../lib/json-extractor.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import {
|
||||
@@ -34,6 +35,28 @@ import { getNotificationService } from '../../services/notification-service.js';
|
||||
|
||||
const logger = createLogger('SpecSync');
|
||||
|
||||
/**
|
||||
* Type for extracted tech stack JSON response
|
||||
*/
|
||||
interface TechStackExtractionResult {
|
||||
technologies: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON schema for tech stack analysis output (Claude/Codex structured output)
|
||||
*/
|
||||
const techStackOutputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
technologies: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'List of technologies detected in the project',
|
||||
},
|
||||
},
|
||||
required: ['technologies'],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Result of a sync operation
|
||||
*/
|
||||
@@ -176,8 +199,14 @@ export async function syncSpec(
|
||||
|
||||
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
|
||||
|
||||
// Determine if we should use structured output based on model type
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
logger.info(
|
||||
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
|
||||
);
|
||||
|
||||
// Use AI to analyze tech stack
|
||||
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
|
||||
let techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
|
||||
|
||||
Current known technologies: ${currentTechStack.join(', ')}
|
||||
|
||||
@@ -193,6 +222,16 @@ Return ONLY this JSON format, no other text:
|
||||
"technologies": ["Technology 1", "Technology 2", ...]
|
||||
}`;
|
||||
|
||||
// Add explicit JSON instructions for non-Claude/Codex models
|
||||
if (!useStructuredOutput) {
|
||||
techAnalysisPrompt = `${techAnalysisPrompt}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. DO NOT write any files. Return the JSON in your response only.
|
||||
2. Your entire response should be valid JSON starting with { and ending with }.
|
||||
3. No explanations, no markdown, no text before or after the JSON.`;
|
||||
}
|
||||
|
||||
try {
|
||||
const techResult = await streamingQuery({
|
||||
prompt: techAnalysisPrompt,
|
||||
@@ -206,44 +245,67 @@ Return ONLY this JSON format, no other text:
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
schema: techStackOutputSchema,
|
||||
}
|
||||
: undefined,
|
||||
onText: (text) => {
|
||||
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Parse tech stack from response
|
||||
const jsonMatch = techResult.text.match(/\{[\s\S]*"technologies"[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
if (Array.isArray(parsed.technologies)) {
|
||||
const newTechStack = parsed.technologies as string[];
|
||||
// Parse tech stack from response - prefer structured output if available
|
||||
let parsedTechnologies: string[] | null = null;
|
||||
|
||||
// Calculate differences
|
||||
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
|
||||
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
|
||||
if (techResult.structured_output) {
|
||||
// Use structured output from Claude/Codex models
|
||||
const structured = techResult.structured_output as unknown as TechStackExtractionResult;
|
||||
if (Array.isArray(structured.technologies)) {
|
||||
parsedTechnologies = structured.technologies;
|
||||
logger.info('✅ Received structured output for tech analysis');
|
||||
}
|
||||
} else {
|
||||
// Fall back to text parsing for non-Claude/Codex models
|
||||
const extracted = extractJson<TechStackExtractionResult>(techResult.text, {
|
||||
logger,
|
||||
requiredKey: 'technologies',
|
||||
requireArray: true,
|
||||
});
|
||||
if (extracted && Array.isArray(extracted.technologies)) {
|
||||
parsedTechnologies = extracted.technologies;
|
||||
logger.info('✅ Extracted tech stack from text response');
|
||||
} else {
|
||||
logger.warn('⚠️ Failed to extract tech stack JSON from response');
|
||||
}
|
||||
}
|
||||
|
||||
for (const tech of newTechStack) {
|
||||
if (!currentSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.added.push(tech);
|
||||
}
|
||||
if (parsedTechnologies) {
|
||||
const newTechStack = parsedTechnologies;
|
||||
|
||||
// Calculate differences
|
||||
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
|
||||
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
|
||||
|
||||
for (const tech of newTechStack) {
|
||||
if (!currentSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.added.push(tech);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tech of currentTechStack) {
|
||||
if (!newSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.removed.push(tech);
|
||||
}
|
||||
for (const tech of currentTechStack) {
|
||||
if (!newSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.removed.push(tech);
|
||||
}
|
||||
}
|
||||
|
||||
// Update spec with new tech stack if there are changes
|
||||
if (
|
||||
result.techStackUpdates.added.length > 0 ||
|
||||
result.techStackUpdates.removed.length > 0
|
||||
) {
|
||||
specContent = updateTechnologyStack(specContent, newTechStack);
|
||||
logger.info(
|
||||
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
|
||||
);
|
||||
}
|
||||
// Update spec with new tech stack if there are changes
|
||||
if (result.techStackUpdates.added.length > 0 || result.techStackUpdates.removed.length > 0) {
|
||||
specContent = updateTechnologyStack(specContent, newTechStack);
|
||||
logger.info(
|
||||
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
isCodexModel,
|
||||
isCursorModel,
|
||||
isOpencodeModel,
|
||||
supportsStructuredOutput,
|
||||
} from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { extractJson } from '../../../lib/json-extractor.js';
|
||||
@@ -124,8 +125,9 @@ async function runValidation(
|
||||
const prompts = await getPromptCustomization(settingsService, '[ValidateIssue]');
|
||||
const issueValidationSystemPrompt = prompts.issueValidation.systemPrompt;
|
||||
|
||||
// Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't)
|
||||
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
|
||||
// Determine if we should use structured output based on model type
|
||||
// Claude and Codex support it; Cursor, Gemini, OpenCode, Copilot don't
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
|
||||
// Build the final prompt - for Cursor, include system prompt and JSON schema instructions
|
||||
let finalPrompt = basePrompt;
|
||||
|
||||
@@ -4,15 +4,21 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { IdeationContextSources } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('ideation:suggestions-generate');
|
||||
|
||||
/**
|
||||
* Creates an Express route handler for generating AI-powered ideation suggestions.
|
||||
* Accepts a prompt, category, and optional context sources configuration,
|
||||
* then returns structured suggestions that can be added to the board.
|
||||
*/
|
||||
export function createSuggestionsGenerateHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, promptId, category, count } = req.body;
|
||||
const { projectPath, promptId, category, count, contextSources } = req.body;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
@@ -38,7 +44,8 @@ export function createSuggestionsGenerateHandler(ideationService: IdeationServic
|
||||
projectPath,
|
||||
promptId,
|
||||
category,
|
||||
suggestionCount
|
||||
suggestionCount,
|
||||
contextSources as IdeationContextSources | undefined
|
||||
);
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -39,8 +39,15 @@ interface GitHubRemoteCacheEntry {
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
interface GitHubPRCacheEntry {
|
||||
prs: Map<string, WorktreePRInfo>;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
const githubRemoteCache = new Map<string, GitHubRemoteCacheEntry>();
|
||||
const githubPRCache = new Map<string, GitHubPRCacheEntry>();
|
||||
const GITHUB_REMOTE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const GITHUB_PR_CACHE_TTL_MS = 2 * 60 * 1000; // 2 minutes - avoid hitting GitHub on every poll
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -180,9 +187,21 @@ async function getGitHubRemoteStatus(projectPath: string): Promise<GitHubRemoteS
|
||||
* This also allows detecting PRs that were created outside the app.
|
||||
*
|
||||
* Uses cached GitHub remote status to avoid repeated warnings when the
|
||||
* project doesn't have a GitHub remote configured.
|
||||
* project doesn't have a GitHub remote configured. Results are cached
|
||||
* briefly to avoid hammering GitHub on frequent worktree polls.
|
||||
*/
|
||||
async function fetchGitHubPRs(projectPath: string): Promise<Map<string, WorktreePRInfo>> {
|
||||
async function fetchGitHubPRs(
|
||||
projectPath: string,
|
||||
forceRefresh = false
|
||||
): Promise<Map<string, WorktreePRInfo>> {
|
||||
const now = Date.now();
|
||||
const cached = githubPRCache.get(projectPath);
|
||||
|
||||
// Return cached result if valid and not forcing refresh
|
||||
if (!forceRefresh && cached && now - cached.fetchedAt < GITHUB_PR_CACHE_TTL_MS) {
|
||||
return cached.prs;
|
||||
}
|
||||
|
||||
const prMap = new Map<string, WorktreePRInfo>();
|
||||
|
||||
try {
|
||||
@@ -225,8 +244,22 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
|
||||
createdAt: pr.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Only update cache on successful fetch
|
||||
githubPRCache.set(projectPath, {
|
||||
prs: prMap,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - PR detection is optional
|
||||
// On fetch failure, return stale cached data if available to avoid
|
||||
// repeated API calls during GitHub API flakiness or temporary outages
|
||||
if (cached) {
|
||||
logger.warn(`Failed to fetch GitHub PRs, returning stale cache: ${getErrorMessage(error)}`);
|
||||
// Extend cache TTL to avoid repeated retries during outages
|
||||
githubPRCache.set(projectPath, { prs: cached.prs, fetchedAt: Date.now() });
|
||||
return cached.prs;
|
||||
}
|
||||
// No cache available, log warning and return empty map
|
||||
logger.warn(`Failed to fetch GitHub PRs: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
@@ -364,7 +397,7 @@ export function createListHandler() {
|
||||
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
|
||||
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
|
||||
const githubPRs = includeDetails
|
||||
? await fetchGitHubPRs(projectPath)
|
||||
? await fetchGitHubPRs(projectPath, forceRefreshGitHub)
|
||||
: new Map<string, WorktreePRInfo>();
|
||||
|
||||
for (const worktree of worktrees) {
|
||||
|
||||
@@ -233,6 +233,7 @@ interface RunningFeature {
|
||||
abortController: AbortController;
|
||||
isAutoMode: boolean;
|
||||
startTime: number;
|
||||
leaseCount: number;
|
||||
model?: string;
|
||||
provider?: ModelProvider;
|
||||
}
|
||||
@@ -334,6 +335,54 @@ export class AutoModeService {
|
||||
this.settingsService = settingsService ?? null;
|
||||
}
|
||||
|
||||
private acquireRunningFeature(params: {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
isAutoMode: boolean;
|
||||
allowReuse?: boolean;
|
||||
abortController?: AbortController;
|
||||
}): RunningFeature {
|
||||
const existing = this.runningFeatures.get(params.featureId);
|
||||
if (existing) {
|
||||
if (!params.allowReuse) {
|
||||
throw new Error('already running');
|
||||
}
|
||||
existing.leaseCount = (existing.leaseCount ?? 1) + 1;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const abortController = params.abortController ?? new AbortController();
|
||||
const entry: RunningFeature = {
|
||||
featureId: params.featureId,
|
||||
projectPath: params.projectPath,
|
||||
worktreePath: null,
|
||||
branchName: null,
|
||||
abortController,
|
||||
isAutoMode: params.isAutoMode,
|
||||
startTime: Date.now(),
|
||||
leaseCount: 1,
|
||||
};
|
||||
this.runningFeatures.set(params.featureId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void {
|
||||
const entry = this.runningFeatures.get(featureId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.force) {
|
||||
this.runningFeatures.delete(featureId);
|
||||
return;
|
||||
}
|
||||
|
||||
entry.leaseCount = (entry.leaseCount ?? 1) - 1;
|
||||
if (entry.leaseCount <= 0) {
|
||||
this.runningFeatures.delete(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a failure and check if we should pause due to consecutive failures.
|
||||
* This handles cases where the SDK doesn't return useful error messages.
|
||||
@@ -1076,24 +1125,17 @@ export class AutoModeService {
|
||||
providedWorktreePath?: string,
|
||||
options?: {
|
||||
continuationPrompt?: string;
|
||||
/** Internal flag: set to true when called from a method that already tracks the feature */
|
||||
_calledInternally?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
if (this.runningFeatures.has(featureId)) {
|
||||
throw new Error('already running');
|
||||
}
|
||||
|
||||
// Add to running features immediately to prevent race conditions
|
||||
const abortController = new AbortController();
|
||||
const tempRunningFeature: RunningFeature = {
|
||||
const tempRunningFeature = this.acquireRunningFeature({
|
||||
featureId,
|
||||
projectPath,
|
||||
worktreePath: null,
|
||||
branchName: null,
|
||||
abortController,
|
||||
isAutoMode,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
this.runningFeatures.set(featureId, tempRunningFeature);
|
||||
allowReuse: options?._calledInternally,
|
||||
});
|
||||
const abortController = tempRunningFeature.abortController;
|
||||
|
||||
// Save execution state when feature starts
|
||||
if (isAutoMode) {
|
||||
@@ -1130,9 +1172,8 @@ export class AutoModeService {
|
||||
continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent);
|
||||
|
||||
// Recursively call executeFeature with the continuation prompt
|
||||
// Remove from running features temporarily, it will be added back
|
||||
this.runningFeatures.delete(featureId);
|
||||
return this.executeFeature(
|
||||
// Feature is already tracked, the recursive call will reuse the entry
|
||||
return await this.executeFeature(
|
||||
projectPath,
|
||||
featureId,
|
||||
useWorktrees,
|
||||
@@ -1140,6 +1181,7 @@ export class AutoModeService {
|
||||
providedWorktreePath,
|
||||
{
|
||||
continuationPrompt,
|
||||
_calledInternally: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1149,9 +1191,8 @@ export class AutoModeService {
|
||||
logger.info(
|
||||
`Feature ${featureId} has existing context, resuming instead of starting fresh`
|
||||
);
|
||||
// Remove from running features temporarily, resumeFeature will add it back
|
||||
this.runningFeatures.delete(featureId);
|
||||
return this.resumeFeature(projectPath, featureId, useWorktrees);
|
||||
// Feature is already tracked, resumeFeature will reuse the entry
|
||||
return await this.resumeFeature(projectPath, featureId, useWorktrees, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1401,7 +1442,7 @@ export class AutoModeService {
|
||||
logger.info(
|
||||
`Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
||||
);
|
||||
this.runningFeatures.delete(featureId);
|
||||
this.releaseRunningFeature(featureId);
|
||||
|
||||
// Update execution state after feature completes
|
||||
if (this.autoLoopRunning && projectPath) {
|
||||
@@ -1581,7 +1622,7 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
||||
|
||||
// Remove from running features immediately to allow resume
|
||||
// The abort signal will still propagate to stop any ongoing execution
|
||||
this.runningFeatures.delete(featureId);
|
||||
this.releaseRunningFeature(featureId, { force: true });
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1589,50 +1630,67 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
||||
/**
|
||||
* Resume a feature (continues from saved context)
|
||||
*/
|
||||
async resumeFeature(projectPath: string, featureId: string, useWorktrees = false): Promise<void> {
|
||||
if (this.runningFeatures.has(featureId)) {
|
||||
throw new Error('already running');
|
||||
}
|
||||
|
||||
// Load feature to check status
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
|
||||
// Check if feature is stuck in a pipeline step
|
||||
const pipelineInfo = await this.detectPipelineStatus(
|
||||
projectPath,
|
||||
async resumeFeature(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees = false,
|
||||
/** Internal flag: set to true when called from a method that already tracks the feature */
|
||||
_calledInternally = false
|
||||
): Promise<void> {
|
||||
this.acquireRunningFeature({
|
||||
featureId,
|
||||
(feature.status || '') as FeatureStatusWithPipeline
|
||||
);
|
||||
projectPath,
|
||||
isAutoMode: false,
|
||||
allowReuse: _calledInternally,
|
||||
});
|
||||
|
||||
if (pipelineInfo.isPipeline) {
|
||||
// Feature stuck in pipeline - use pipeline resume
|
||||
return this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo);
|
||||
}
|
||||
|
||||
// Normal resume flow for non-pipeline features
|
||||
// Check if context exists in .automaker directory
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||
|
||||
let hasContext = false;
|
||||
try {
|
||||
await secureFs.access(contextPath);
|
||||
hasContext = true;
|
||||
} catch {
|
||||
// No context
|
||||
}
|
||||
// Load feature to check status
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
|
||||
if (hasContext) {
|
||||
// Load previous context and continue
|
||||
const context = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
||||
return this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees);
|
||||
}
|
||||
// Check if feature is stuck in a pipeline step
|
||||
const pipelineInfo = await this.detectPipelineStatus(
|
||||
projectPath,
|
||||
featureId,
|
||||
(feature.status || '') as FeatureStatusWithPipeline
|
||||
);
|
||||
|
||||
// No context, start fresh - executeFeature will handle adding to runningFeatures
|
||||
return this.executeFeature(projectPath, featureId, useWorktrees, false);
|
||||
if (pipelineInfo.isPipeline) {
|
||||
// Feature stuck in pipeline - use pipeline resume
|
||||
// Pass _alreadyTracked to prevent double-tracking
|
||||
return await this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo);
|
||||
}
|
||||
|
||||
// Normal resume flow for non-pipeline features
|
||||
// Check if context exists in .automaker directory
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||
|
||||
let hasContext = false;
|
||||
try {
|
||||
await secureFs.access(contextPath);
|
||||
hasContext = true;
|
||||
} catch {
|
||||
// No context
|
||||
}
|
||||
|
||||
if (hasContext) {
|
||||
// Load previous context and continue
|
||||
// executeFeatureWithContext -> executeFeature will see feature is already tracked
|
||||
const context = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
||||
return await this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees);
|
||||
}
|
||||
|
||||
// No context, start fresh - executeFeature will see feature is already tracked
|
||||
return await this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, {
|
||||
_calledInternally: true,
|
||||
});
|
||||
} finally {
|
||||
this.releaseRunningFeature(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1682,7 +1740,9 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
||||
// Reset status to in_progress and start fresh
|
||||
await this.updateFeatureStatus(projectPath, featureId, 'in_progress');
|
||||
|
||||
return this.executeFeature(projectPath, featureId, useWorktrees, false);
|
||||
return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, {
|
||||
_calledInternally: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Edge Case 2: Step no longer exists in pipeline config
|
||||
@@ -1828,17 +1888,14 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
||||
`[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}`
|
||||
);
|
||||
|
||||
// Add to running features immediately
|
||||
const abortController = new AbortController();
|
||||
this.runningFeatures.set(featureId, {
|
||||
const runningEntry = this.acquireRunningFeature({
|
||||
featureId,
|
||||
projectPath,
|
||||
worktreePath: null, // Will be set below
|
||||
branchName: feature.branchName ?? null,
|
||||
abortController,
|
||||
isAutoMode: false,
|
||||
startTime: Date.now(),
|
||||
allowReuse: true,
|
||||
});
|
||||
const abortController = runningEntry.abortController;
|
||||
runningEntry.branchName = feature.branchName ?? null;
|
||||
|
||||
try {
|
||||
// Validate project path
|
||||
@@ -1863,11 +1920,8 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
||||
validateWorkingDirectory(workDir);
|
||||
|
||||
// Update running feature with worktree info
|
||||
const runningFeature = this.runningFeatures.get(featureId);
|
||||
if (runningFeature) {
|
||||
runningFeature.worktreePath = worktreePath;
|
||||
runningFeature.branchName = branchName ?? null;
|
||||
}
|
||||
runningEntry.worktreePath = worktreePath;
|
||||
runningEntry.branchName = branchName ?? null;
|
||||
|
||||
// Emit resume event
|
||||
this.emitAutoModeEvent('auto_mode_feature_start', {
|
||||
@@ -1945,7 +1999,7 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.runningFeatures.delete(featureId);
|
||||
this.releaseRunningFeature(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1962,11 +2016,12 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
||||
// Validate project path early for fast failure
|
||||
validateWorkingDirectory(projectPath);
|
||||
|
||||
if (this.runningFeatures.has(featureId)) {
|
||||
throw new Error(`Feature ${featureId} is already running`);
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const runningEntry = this.acquireRunningFeature({
|
||||
featureId,
|
||||
projectPath,
|
||||
isAutoMode: false,
|
||||
});
|
||||
const abortController = runningEntry.abortController;
|
||||
|
||||
// Load feature info for context FIRST to get branchName
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
@@ -2048,17 +2103,10 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
const provider = ProviderFactory.getProviderNameForModel(model);
|
||||
logger.info(`Follow-up for feature ${featureId} using model: ${model}, provider: ${provider}`);
|
||||
|
||||
this.runningFeatures.set(featureId, {
|
||||
featureId,
|
||||
projectPath,
|
||||
worktreePath,
|
||||
branchName,
|
||||
abortController,
|
||||
isAutoMode: false,
|
||||
startTime: Date.now(),
|
||||
model,
|
||||
provider,
|
||||
});
|
||||
runningEntry.worktreePath = worktreePath;
|
||||
runningEntry.branchName = branchName;
|
||||
runningEntry.model = model;
|
||||
runningEntry.provider = provider;
|
||||
|
||||
try {
|
||||
// Update feature status to in_progress BEFORE emitting event
|
||||
@@ -2206,7 +2254,7 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.runningFeatures.delete(featureId);
|
||||
this.releaseRunningFeature(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4225,6 +4273,7 @@ After generating the revised spec, output:
|
||||
|
||||
return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, {
|
||||
continuationPrompt: prompt,
|
||||
_calledInternally: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ import type {
|
||||
SendMessageOptions,
|
||||
PromptCategory,
|
||||
IdeationPrompt,
|
||||
IdeationContextSources,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types';
|
||||
import {
|
||||
getIdeationDir,
|
||||
getIdeasDir,
|
||||
@@ -32,8 +34,10 @@ import {
|
||||
getIdeationSessionsDir,
|
||||
getIdeationSessionPath,
|
||||
getIdeationAnalysisPath,
|
||||
getAppSpecPath,
|
||||
ensureIdeationDir,
|
||||
} from '@automaker/platform';
|
||||
import { extractXmlElements, extractImplementedFeatures } from '../lib/xml-extractor.js';
|
||||
import { createLogger, loadContextFiles, isAbortError } from '@automaker/utils';
|
||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
@@ -638,8 +642,12 @@ export class IdeationService {
|
||||
projectPath: string,
|
||||
promptId: string,
|
||||
category: IdeaCategory,
|
||||
count: number = 10
|
||||
count: number = 10,
|
||||
contextSources?: IdeationContextSources
|
||||
): Promise<AnalysisSuggestion[]> {
|
||||
const suggestionCount = Math.min(Math.max(Math.floor(count ?? 10), 1), 20);
|
||||
// Merge with defaults for backward compatibility
|
||||
const sources = { ...DEFAULT_IDEATION_CONTEXT_SOURCES, ...contextSources };
|
||||
validateWorkingDirectory(projectPath);
|
||||
|
||||
// Get the prompt
|
||||
@@ -656,16 +664,26 @@ export class IdeationService {
|
||||
});
|
||||
|
||||
try {
|
||||
// Load context files
|
||||
// Load context files (respecting toggle settings)
|
||||
const contextResult = await loadContextFiles({
|
||||
projectPath,
|
||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||
includeContextFiles: sources.useContextFiles,
|
||||
includeMemory: sources.useMemoryFiles,
|
||||
});
|
||||
|
||||
// Build context from multiple sources
|
||||
let contextPrompt = contextResult.formattedPrompt;
|
||||
|
||||
// If no context files, try to gather basic project info
|
||||
// Add app spec context if enabled
|
||||
if (sources.useAppSpec) {
|
||||
const appSpecContext = await this.buildAppSpecContext(projectPath);
|
||||
if (appSpecContext) {
|
||||
contextPrompt = contextPrompt ? `${contextPrompt}\n\n${appSpecContext}` : appSpecContext;
|
||||
}
|
||||
}
|
||||
|
||||
// If no context was found, try to gather basic project info
|
||||
if (!contextPrompt) {
|
||||
const projectInfo = await this.gatherBasicProjectInfo(projectPath);
|
||||
if (projectInfo) {
|
||||
@@ -673,8 +691,11 @@ export class IdeationService {
|
||||
}
|
||||
}
|
||||
|
||||
// Gather existing features and ideas to prevent duplicates
|
||||
const existingWorkContext = await this.gatherExistingWorkContext(projectPath);
|
||||
// Gather existing features and ideas to prevent duplicates (respecting toggle settings)
|
||||
const existingWorkContext = await this.gatherExistingWorkContext(projectPath, {
|
||||
includeFeatures: sources.useExistingFeatures,
|
||||
includeIdeas: sources.useExistingIdeas,
|
||||
});
|
||||
|
||||
// Get customized prompts from settings
|
||||
const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]');
|
||||
@@ -684,7 +705,7 @@ export class IdeationService {
|
||||
prompts.ideation.suggestionsSystemPrompt,
|
||||
contextPrompt,
|
||||
category,
|
||||
count,
|
||||
suggestionCount,
|
||||
existingWorkContext
|
||||
);
|
||||
|
||||
@@ -751,7 +772,11 @@ export class IdeationService {
|
||||
}
|
||||
|
||||
// Parse the response into structured suggestions
|
||||
const suggestions = this.parseSuggestionsFromResponse(responseText, category);
|
||||
const suggestions = this.parseSuggestionsFromResponse(
|
||||
responseText,
|
||||
category,
|
||||
suggestionCount
|
||||
);
|
||||
|
||||
// Emit complete event
|
||||
this.events.emit('ideation:suggestions', {
|
||||
@@ -814,40 +839,47 @@ ${contextSection}${existingWorkSection}`;
|
||||
*/
|
||||
private parseSuggestionsFromResponse(
|
||||
response: string,
|
||||
category: IdeaCategory
|
||||
category: IdeaCategory,
|
||||
count: number
|
||||
): AnalysisSuggestion[] {
|
||||
try {
|
||||
// Try to extract JSON from the response
|
||||
const jsonMatch = response.match(/\[[\s\S]*\]/);
|
||||
if (!jsonMatch) {
|
||||
logger.warn('No JSON array found in response, falling back to text parsing');
|
||||
return this.parseTextResponse(response, category);
|
||||
return this.parseTextResponse(response, category, count);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return this.parseTextResponse(response, category);
|
||||
return this.parseTextResponse(response, category, count);
|
||||
}
|
||||
|
||||
return parsed.map((item: any, index: number) => ({
|
||||
id: this.generateId('sug'),
|
||||
category,
|
||||
title: item.title || `Suggestion ${index + 1}`,
|
||||
description: item.description || '',
|
||||
rationale: item.rationale || '',
|
||||
priority: item.priority || 'medium',
|
||||
relatedFiles: item.relatedFiles || [],
|
||||
}));
|
||||
return parsed
|
||||
.map((item: any, index: number) => ({
|
||||
id: this.generateId('sug'),
|
||||
category,
|
||||
title: item.title || `Suggestion ${index + 1}`,
|
||||
description: item.description || '',
|
||||
rationale: item.rationale || '',
|
||||
priority: item.priority || 'medium',
|
||||
relatedFiles: item.relatedFiles || [],
|
||||
}))
|
||||
.slice(0, count);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse JSON response:', error);
|
||||
return this.parseTextResponse(response, category);
|
||||
return this.parseTextResponse(response, category, count);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: parse text response into suggestions
|
||||
*/
|
||||
private parseTextResponse(response: string, category: IdeaCategory): AnalysisSuggestion[] {
|
||||
private parseTextResponse(
|
||||
response: string,
|
||||
category: IdeaCategory,
|
||||
count: number
|
||||
): AnalysisSuggestion[] {
|
||||
const suggestions: AnalysisSuggestion[] = [];
|
||||
|
||||
// Try to find numbered items or headers
|
||||
@@ -907,7 +939,7 @@ ${contextSection}${existingWorkSection}`;
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions.slice(0, 5); // Max 5 suggestions
|
||||
return suggestions.slice(0, count);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -1345,6 +1377,68 @@ ${contextSection}${existingWorkSection}`;
|
||||
return descriptions[category] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context from app_spec.txt for suggestion generation
|
||||
* Extracts project name, overview, capabilities, and implemented features
|
||||
*/
|
||||
private async buildAppSpecContext(projectPath: string): Promise<string> {
|
||||
try {
|
||||
const specPath = getAppSpecPath(projectPath);
|
||||
const specContent = (await secureFs.readFile(specPath, 'utf-8')) as string;
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('## App Specification');
|
||||
|
||||
// Extract project name
|
||||
const projectNames = extractXmlElements(specContent, 'project_name');
|
||||
if (projectNames.length > 0 && projectNames[0]) {
|
||||
parts.push(`**Project:** ${projectNames[0]}`);
|
||||
}
|
||||
|
||||
// Extract overview
|
||||
const overviews = extractXmlElements(specContent, 'overview');
|
||||
if (overviews.length > 0 && overviews[0]) {
|
||||
parts.push(`**Overview:** ${overviews[0]}`);
|
||||
}
|
||||
|
||||
// Extract core capabilities
|
||||
const capabilities = extractXmlElements(specContent, 'capability');
|
||||
if (capabilities.length > 0) {
|
||||
parts.push('**Core Capabilities:**');
|
||||
for (const cap of capabilities) {
|
||||
parts.push(`- ${cap}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract implemented features
|
||||
const implementedFeatures = extractImplementedFeatures(specContent);
|
||||
if (implementedFeatures.length > 0) {
|
||||
parts.push('**Implemented Features:**');
|
||||
for (const feature of implementedFeatures) {
|
||||
if (feature.description) {
|
||||
parts.push(`- ${feature.name}: ${feature.description}`);
|
||||
} else {
|
||||
parts.push(`- ${feature.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only return content if we extracted something meaningful
|
||||
if (parts.length > 1) {
|
||||
return parts.join('\n');
|
||||
}
|
||||
return '';
|
||||
} catch (error) {
|
||||
// If file doesn't exist, return empty string silently
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return '';
|
||||
}
|
||||
// For other errors, log and return empty string
|
||||
logger.warn('Failed to build app spec context:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather basic project information for context when no context files exist
|
||||
*/
|
||||
@@ -1440,11 +1534,15 @@ ${contextSection}${existingWorkSection}`;
|
||||
* Gather existing features and ideas to prevent duplicate suggestions
|
||||
* Returns a concise list of titles grouped by status to avoid polluting context
|
||||
*/
|
||||
private async gatherExistingWorkContext(projectPath: string): Promise<string> {
|
||||
private async gatherExistingWorkContext(
|
||||
projectPath: string,
|
||||
options?: { includeFeatures?: boolean; includeIdeas?: boolean }
|
||||
): Promise<string> {
|
||||
const { includeFeatures = true, includeIdeas = true } = options ?? {};
|
||||
const parts: string[] = [];
|
||||
|
||||
// Load existing features from the board
|
||||
if (this.featureLoader) {
|
||||
if (includeFeatures && this.featureLoader) {
|
||||
try {
|
||||
const features = await this.featureLoader.getAll(projectPath);
|
||||
if (features.length > 0) {
|
||||
@@ -1492,34 +1590,36 @@ ${contextSection}${existingWorkSection}`;
|
||||
}
|
||||
|
||||
// Load existing ideas
|
||||
try {
|
||||
const ideas = await this.getIdeas(projectPath);
|
||||
// Filter out archived ideas
|
||||
const activeIdeas = ideas.filter((idea) => idea.status !== 'archived');
|
||||
if (includeIdeas) {
|
||||
try {
|
||||
const ideas = await this.getIdeas(projectPath);
|
||||
// Filter out archived ideas
|
||||
const activeIdeas = ideas.filter((idea) => idea.status !== 'archived');
|
||||
|
||||
if (activeIdeas.length > 0) {
|
||||
parts.push('## Existing Ideas (Do NOT regenerate these)');
|
||||
parts.push(
|
||||
'The following ideas have already been captured. Do NOT suggest similar ideas:\n'
|
||||
);
|
||||
if (activeIdeas.length > 0) {
|
||||
parts.push('## Existing Ideas (Do NOT regenerate these)');
|
||||
parts.push(
|
||||
'The following ideas have already been captured. Do NOT suggest similar ideas:\n'
|
||||
);
|
||||
|
||||
// Group by category for organization
|
||||
const byCategory: Record<string, string[]> = {};
|
||||
for (const idea of activeIdeas) {
|
||||
const cat = idea.category || 'feature';
|
||||
if (!byCategory[cat]) {
|
||||
byCategory[cat] = [];
|
||||
// Group by category for organization
|
||||
const byCategory: Record<string, string[]> = {};
|
||||
for (const idea of activeIdeas) {
|
||||
const cat = idea.category || 'feature';
|
||||
if (!byCategory[cat]) {
|
||||
byCategory[cat] = [];
|
||||
}
|
||||
byCategory[cat].push(idea.title);
|
||||
}
|
||||
byCategory[cat].push(idea.title);
|
||||
}
|
||||
|
||||
for (const [category, titles] of Object.entries(byCategory)) {
|
||||
parts.push(`**${category}:** ${titles.join(', ')}`);
|
||||
for (const [category, titles] of Object.entries(byCategory)) {
|
||||
parts.push(`**${category}:** ${titles.join(', ')}`);
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
parts.push('');
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load existing ideas:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load existing ideas:', error);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
|
||||
@@ -325,8 +325,12 @@ describe('codex-provider.ts', () => {
|
||||
);
|
||||
|
||||
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
||||
// xhigh reasoning effort should have 4x the default timeout (120000ms)
|
||||
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh);
|
||||
// xhigh reasoning effort uses 5-minute base timeout (300000ms) for feature generation
|
||||
// then applies 4x multiplier: 300000 * 4.0 = 1200000ms (20 minutes)
|
||||
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000;
|
||||
expect(call.timeout).toBe(
|
||||
CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh
|
||||
);
|
||||
});
|
||||
|
||||
it('uses default timeout when no reasoning effort is specified', async () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
} from '@automaker/types';
|
||||
import { ProviderFactory } from '@/providers/provider-factory.js';
|
||||
|
||||
// Create a shared mock logger instance for assertions using vi.hoisted
|
||||
// Create shared mock instances for assertions using vi.hoisted
|
||||
const mockLogger = vi.hoisted(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
@@ -23,6 +23,13 @@ const mockLogger = vi.hoisted(() => ({
|
||||
debug: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCreateChatOptions = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
systemPrompt: 'test prompt',
|
||||
}))
|
||||
);
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/lib/secure-fs.js');
|
||||
vi.mock('@automaker/platform');
|
||||
@@ -37,10 +44,7 @@ vi.mock('@automaker/utils', async () => {
|
||||
});
|
||||
vi.mock('@/providers/provider-factory.js');
|
||||
vi.mock('@/lib/sdk-options.js', () => ({
|
||||
createChatOptions: vi.fn(() => ({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
systemPrompt: 'test prompt',
|
||||
})),
|
||||
createChatOptions: mockCreateChatOptions,
|
||||
validateWorkingDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -786,6 +790,143 @@ describe('IdeationService', () => {
|
||||
service.generateSuggestions(testProjectPath, 'non-existent', 'features', 5)
|
||||
).rejects.toThrow('Prompt non-existent not found');
|
||||
});
|
||||
|
||||
it('should include app spec context when useAppSpec is enabled', async () => {
|
||||
const mockAppSpec = `
|
||||
<project_specification>
|
||||
<project_name>Test Project</project_name>
|
||||
<overview>A test application for unit testing</overview>
|
||||
<core_capabilities>
|
||||
<capability>User authentication</capability>
|
||||
<capability>Data visualization</capability>
|
||||
</core_capabilities>
|
||||
<implemented_features>
|
||||
<feature>
|
||||
<name>Login System</name>
|
||||
<description>Basic auth with email/password</description>
|
||||
</feature>
|
||||
</implemented_features>
|
||||
</project_specification>
|
||||
`;
|
||||
|
||||
vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt');
|
||||
|
||||
// First call returns app spec, subsequent calls return empty JSON
|
||||
vi.mocked(secureFs.readFile)
|
||||
.mockResolvedValueOnce(mockAppSpec)
|
||||
.mockResolvedValue(JSON.stringify({}));
|
||||
|
||||
const mockProvider = {
|
||||
executeQuery: vi.fn().mockReturnValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
result: JSON.stringify([{ title: 'Test', description: 'Test' }]),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
|
||||
|
||||
const prompts = service.getAllPrompts();
|
||||
await service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, {
|
||||
useAppSpec: true,
|
||||
useContextFiles: false,
|
||||
useMemoryFiles: false,
|
||||
useExistingFeatures: false,
|
||||
useExistingIdeas: false,
|
||||
});
|
||||
|
||||
// Verify createChatOptions was called with systemPrompt containing app spec info
|
||||
expect(mockCreateChatOptions).toHaveBeenCalled();
|
||||
const chatOptionsCall = mockCreateChatOptions.mock.calls[0][0];
|
||||
expect(chatOptionsCall.systemPrompt).toContain('Test Project');
|
||||
expect(chatOptionsCall.systemPrompt).toContain('A test application for unit testing');
|
||||
expect(chatOptionsCall.systemPrompt).toContain('User authentication');
|
||||
expect(chatOptionsCall.systemPrompt).toContain('Login System');
|
||||
});
|
||||
|
||||
it('should exclude app spec context when useAppSpec is disabled', async () => {
|
||||
const mockAppSpec = `
|
||||
<project_specification>
|
||||
<project_name>Hidden Project</project_name>
|
||||
<overview>This should not appear</overview>
|
||||
</project_specification>
|
||||
`;
|
||||
|
||||
vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt');
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(mockAppSpec);
|
||||
|
||||
const mockProvider = {
|
||||
executeQuery: vi.fn().mockReturnValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
result: JSON.stringify([{ title: 'Test', description: 'Test' }]),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
|
||||
|
||||
const prompts = service.getAllPrompts();
|
||||
await service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, {
|
||||
useAppSpec: false,
|
||||
useContextFiles: false,
|
||||
useMemoryFiles: false,
|
||||
useExistingFeatures: false,
|
||||
useExistingIdeas: false,
|
||||
});
|
||||
|
||||
// Verify createChatOptions was called with systemPrompt NOT containing app spec info
|
||||
expect(mockCreateChatOptions).toHaveBeenCalled();
|
||||
const chatOptionsCall = mockCreateChatOptions.mock.calls[0][0];
|
||||
expect(chatOptionsCall.systemPrompt).not.toContain('Hidden Project');
|
||||
expect(chatOptionsCall.systemPrompt).not.toContain('This should not appear');
|
||||
});
|
||||
|
||||
it('should handle missing app spec file gracefully', async () => {
|
||||
vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt');
|
||||
|
||||
const enoentError = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException;
|
||||
enoentError.code = 'ENOENT';
|
||||
|
||||
// First call fails with ENOENT for app spec, subsequent calls return empty JSON
|
||||
vi.mocked(secureFs.readFile)
|
||||
.mockRejectedValueOnce(enoentError)
|
||||
.mockResolvedValue(JSON.stringify({}));
|
||||
|
||||
const mockProvider = {
|
||||
executeQuery: vi.fn().mockReturnValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
result: JSON.stringify([{ title: 'Test', description: 'Test' }]),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
|
||||
|
||||
const prompts = service.getAllPrompts();
|
||||
|
||||
// Should not throw
|
||||
await expect(
|
||||
service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, {
|
||||
useAppSpec: true,
|
||||
useContextFiles: false,
|
||||
useMemoryFiles: false,
|
||||
useExistingFeatures: false,
|
||||
useExistingIdeas: false,
|
||||
})
|
||||
).resolves.toBeDefined();
|
||||
|
||||
// Should not log warning for ENOENT
|
||||
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"usehooks-ts": "3.1.1",
|
||||
"zod": "^3.24.1 || ^4.0.0",
|
||||
"zustand": "5.0.9"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -6,14 +6,25 @@ import { SplashScreen } from './components/splash-screen';
|
||||
import { useSettingsSync } from './hooks/use-settings-sync';
|
||||
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
|
||||
import { useProviderAuthInit } from './hooks/use-provider-auth-init';
|
||||
import { useAppStore } from './store/app-store';
|
||||
import './styles/global.css';
|
||||
import './styles/theme-imports';
|
||||
import './styles/font-imports';
|
||||
|
||||
const logger = createLogger('App');
|
||||
|
||||
// Key for localStorage to persist splash screen preference
|
||||
const DISABLE_SPLASH_KEY = 'automaker-disable-splash';
|
||||
|
||||
export default function App() {
|
||||
const disableSplashScreen = useAppStore((state) => state.disableSplashScreen);
|
||||
|
||||
const [showSplash, setShowSplash] = useState(() => {
|
||||
// Check localStorage for user preference (available synchronously)
|
||||
const savedPreference = localStorage.getItem(DISABLE_SPLASH_KEY);
|
||||
if (savedPreference === 'true') {
|
||||
return false;
|
||||
}
|
||||
// Only show splash once per session
|
||||
if (sessionStorage.getItem('automaker-splash-shown')) {
|
||||
return false;
|
||||
@@ -21,6 +32,11 @@ export default function App() {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sync the disableSplashScreen setting to localStorage for fast access on next startup
|
||||
useEffect(() => {
|
||||
localStorage.setItem(DISABLE_SPLASH_KEY, String(disableSplashScreen));
|
||||
}, [disableSplashScreen]);
|
||||
|
||||
// Clear accumulated PerformanceMeasure entries to prevent memory leak in dev mode
|
||||
// React's internal scheduler creates performance marks/measures that accumulate without cleanup
|
||||
useEffect(() => {
|
||||
@@ -61,7 +77,7 @@ export default function App() {
|
||||
return (
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
{showSplash && <SplashScreen onComplete={handleSplashComplete} />}
|
||||
{showSplash && !disableSplashScreen && <SplashScreen onComplete={handleSplashComplete} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { ChevronDown, Wrench, Github } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import { formatShortcut, useAppStore } from '@/store/app-store';
|
||||
import type { NavSection } from '../types';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import type { SidebarStyle } from '@automaker/types';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -23,6 +24,7 @@ const sectionIcons: Record<string, React.ComponentType<{ className?: string }>>
|
||||
interface SidebarNavigationProps {
|
||||
currentProject: Project | null;
|
||||
sidebarOpen: boolean;
|
||||
sidebarStyle: SidebarStyle;
|
||||
navSections: NavSection[];
|
||||
isActiveRoute: (id: string) => boolean;
|
||||
navigate: (opts: NavigateOptions) => void;
|
||||
@@ -32,6 +34,7 @@ interface SidebarNavigationProps {
|
||||
export function SidebarNavigation({
|
||||
currentProject,
|
||||
sidebarOpen,
|
||||
sidebarStyle,
|
||||
navSections,
|
||||
isActiveRoute,
|
||||
navigate,
|
||||
@@ -39,21 +42,26 @@ export function SidebarNavigation({
|
||||
}: SidebarNavigationProps) {
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
|
||||
// Track collapsed state for each collapsible section
|
||||
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
|
||||
// Get collapsed state from store (persisted across restarts)
|
||||
const { collapsedNavSections, setCollapsedNavSections, toggleNavSection } = useAppStore();
|
||||
|
||||
// Initialize collapsed state when sections change (e.g., GitHub section appears)
|
||||
// Only set defaults for sections that don't have a persisted state
|
||||
useEffect(() => {
|
||||
setCollapsedSections((prev) => {
|
||||
const updated = { ...prev };
|
||||
navSections.forEach((section) => {
|
||||
if (section.collapsible && section.label && !(section.label in updated)) {
|
||||
updated[section.label] = section.defaultCollapsed ?? false;
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
let hasNewSections = false;
|
||||
const updated = { ...collapsedNavSections };
|
||||
|
||||
navSections.forEach((section) => {
|
||||
if (section.collapsible && section.label && !(section.label in updated)) {
|
||||
updated[section.label] = section.defaultCollapsed ?? false;
|
||||
hasNewSections = true;
|
||||
}
|
||||
});
|
||||
}, [navSections]);
|
||||
|
||||
if (hasNewSections) {
|
||||
setCollapsedNavSections(updated);
|
||||
}
|
||||
}, [navSections, collapsedNavSections, setCollapsedNavSections]);
|
||||
|
||||
// Check scroll state
|
||||
const checkScrollState = useCallback(() => {
|
||||
@@ -77,14 +85,7 @@ export function SidebarNavigation({
|
||||
nav.removeEventListener('scroll', checkScrollState);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [checkScrollState, collapsedSections]);
|
||||
|
||||
const toggleSection = useCallback((label: string) => {
|
||||
setCollapsedSections((prev) => ({
|
||||
...prev,
|
||||
[label]: !prev[label],
|
||||
}));
|
||||
}, []);
|
||||
}, [checkScrollState, collapsedNavSections]);
|
||||
|
||||
// Filter sections: always show non-project sections, only show project sections when project exists
|
||||
const visibleSections = navSections.filter((section) => {
|
||||
@@ -97,10 +98,17 @@ export function SidebarNavigation({
|
||||
});
|
||||
|
||||
return (
|
||||
<nav ref={navRef} className={cn('flex-1 overflow-y-auto scrollbar-hide px-3 pb-2 mt-1')}>
|
||||
<nav
|
||||
ref={navRef}
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
|
||||
// Add top padding in discord mode since there's no header
|
||||
sidebarStyle === 'discord' ? 'pt-3' : 'mt-1'
|
||||
)}
|
||||
>
|
||||
{/* Navigation sections */}
|
||||
{visibleSections.map((section, sectionIdx) => {
|
||||
const isCollapsed = section.label ? collapsedSections[section.label] : false;
|
||||
const isCollapsed = section.label ? collapsedNavSections[section.label] : false;
|
||||
const isCollapsible = section.collapsible && section.label && sidebarOpen;
|
||||
|
||||
const SectionIcon = section.label ? sectionIcons[section.label] : null;
|
||||
@@ -110,21 +118,37 @@ export function SidebarNavigation({
|
||||
{/* Section Label - clickable if collapsible (expanded sidebar) */}
|
||||
{section.label && sidebarOpen && (
|
||||
<button
|
||||
onClick={() => isCollapsible && toggleSection(section.label!)}
|
||||
onClick={() => isCollapsible && toggleNavSection(section.label!)}
|
||||
className={cn(
|
||||
'flex items-center w-full px-3 mb-1.5',
|
||||
isCollapsible && 'cursor-pointer hover:text-foreground'
|
||||
'group flex items-center w-full px-3 py-1.5 mb-1 rounded-md',
|
||||
'transition-all duration-200 ease-out',
|
||||
isCollapsible
|
||||
? [
|
||||
'cursor-pointer',
|
||||
'hover:bg-accent/50 hover:text-foreground',
|
||||
'border border-transparent hover:border-border/40',
|
||||
]
|
||||
: 'cursor-default'
|
||||
)}
|
||||
disabled={!isCollapsible}
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold uppercase tracking-widest transition-colors duration-200',
|
||||
isCollapsible
|
||||
? 'text-muted-foreground/70 group-hover:text-foreground'
|
||||
: 'text-muted-foreground/70'
|
||||
)}
|
||||
>
|
||||
{section.label}
|
||||
</span>
|
||||
{isCollapsible && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-3 h-3 ml-auto text-muted-foreground/50 transition-transform duration-200',
|
||||
isCollapsed && '-rotate-90'
|
||||
'w-3 h-3 ml-auto transition-all duration-200',
|
||||
isCollapsed
|
||||
? '-rotate-90 text-muted-foreground/50 group-hover:text-muted-foreground'
|
||||
: 'text-muted-foreground/50 group-hover:text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -53,6 +53,7 @@ export function Sidebar() {
|
||||
trashedProjects,
|
||||
currentProject,
|
||||
sidebarOpen,
|
||||
sidebarStyle,
|
||||
mobileSidebarHidden,
|
||||
projectHistory,
|
||||
upsertAndSetCurrentProject,
|
||||
@@ -381,17 +382,21 @@ export function Sidebar() {
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<SidebarHeader
|
||||
sidebarOpen={sidebarOpen}
|
||||
currentProject={currentProject}
|
||||
onNewProject={handleNewProject}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onProjectContextMenu={handleContextMenu}
|
||||
/>
|
||||
{/* Only show header in unified mode - in discord mode, ProjectSwitcher has the logo */}
|
||||
{sidebarStyle === 'unified' && (
|
||||
<SidebarHeader
|
||||
sidebarOpen={sidebarOpen}
|
||||
currentProject={currentProject}
|
||||
onNewProject={handleNewProject}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onProjectContextMenu={handleContextMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarNavigation
|
||||
currentProject={currentProject}
|
||||
sidebarOpen={sidebarOpen}
|
||||
sidebarStyle={sidebarStyle}
|
||||
navSections={navSections}
|
||||
isActiveRoute={isActiveRoute}
|
||||
navigate={navigate}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Spinner, type SpinnerVariant } from '@/components/ui/spinner';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
|
||||
@@ -37,9 +37,19 @@ const buttonVariants = cva(
|
||||
}
|
||||
);
|
||||
|
||||
// Loading spinner component
|
||||
function ButtonSpinner({ className }: { className?: string }) {
|
||||
return <Spinner size="sm" className={className} />;
|
||||
/** Button variants that have colored backgrounds requiring foreground spinner color */
|
||||
const COLORED_BACKGROUND_VARIANTS = new Set<string>(['default', 'destructive']);
|
||||
|
||||
/** Get spinner variant based on button variant - use foreground for colored backgrounds */
|
||||
function getSpinnerVariant(
|
||||
buttonVariant: VariantProps<typeof buttonVariants>['variant']
|
||||
): SpinnerVariant {
|
||||
const variant = buttonVariant ?? 'default';
|
||||
if (COLORED_BACKGROUND_VARIANTS.has(variant)) {
|
||||
return 'foreground';
|
||||
}
|
||||
// outline, secondary, ghost, link, animated-outline use standard backgrounds
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
function Button({
|
||||
@@ -57,6 +67,7 @@ function Button({
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const isDisabled = disabled || loading;
|
||||
const spinnerVariant = getSpinnerVariant(variant);
|
||||
|
||||
// Special handling for animated-outline variant
|
||||
if (variant === 'animated-outline' && !asChild) {
|
||||
@@ -83,7 +94,7 @@ function Button({
|
||||
size === 'icon' && 'p-0 gap-0'
|
||||
)}
|
||||
>
|
||||
{loading && <ButtonSpinner />}
|
||||
{loading && <Spinner size="sm" variant={spinnerVariant} />}
|
||||
{children}
|
||||
</span>
|
||||
</button>
|
||||
@@ -99,7 +110,7 @@ function Button({
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{loading && <ButtonSpinner />}
|
||||
{loading && <Spinner size="sm" variant={spinnerVariant} />}
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
export type SpinnerVariant = 'primary' | 'foreground' | 'muted';
|
||||
|
||||
const sizeClasses: Record<SpinnerSize, string> = {
|
||||
xs: 'h-3 w-3',
|
||||
@@ -11,9 +12,17 @@ const sizeClasses: Record<SpinnerSize, string> = {
|
||||
xl: 'h-8 w-8',
|
||||
};
|
||||
|
||||
const variantClasses: Record<SpinnerVariant, string> = {
|
||||
primary: 'text-primary',
|
||||
foreground: 'text-primary-foreground',
|
||||
muted: 'text-muted-foreground',
|
||||
};
|
||||
|
||||
interface SpinnerProps {
|
||||
/** Size of the spinner */
|
||||
size?: SpinnerSize;
|
||||
/** Color variant - use 'foreground' when on primary backgrounds */
|
||||
variant?: SpinnerVariant;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
@@ -21,11 +30,12 @@ interface SpinnerProps {
|
||||
/**
|
||||
* Themed spinner component using the primary brand color.
|
||||
* Use this for all loading indicators throughout the app for consistency.
|
||||
* Use variant='foreground' when placing on primary-colored backgrounds.
|
||||
*/
|
||||
export function Spinner({ size = 'md', className }: SpinnerProps) {
|
||||
export function Spinner({ size = 'md', variant = 'primary', className }: SpinnerProps) {
|
||||
return (
|
||||
<Loader2
|
||||
className={cn(sizeClasses[size], 'animate-spin text-primary', className)}
|
||||
className={cn(sizeClasses[size], 'animate-spin', variantClasses[variant], className)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -261,7 +261,7 @@ export function TaskProgressPanel({
|
||||
)}
|
||||
>
|
||||
{isCompleted && <Check className="h-3.5 w-3.5" />}
|
||||
{isActive && <Spinner size="xs" />}
|
||||
{isActive && <Spinner size="xs" variant="foreground" />}
|
||||
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -463,6 +463,16 @@ export function BoardView() {
|
||||
const selectedWorktreeBranch =
|
||||
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
|
||||
|
||||
// Aggregate running auto tasks across all worktrees for this project
|
||||
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
|
||||
const runningAutoTasksAllWorktrees = useMemo(() => {
|
||||
if (!currentProject?.id) return [];
|
||||
const prefix = `${currentProject.id}::`;
|
||||
return Object.entries(autoModeByWorktree)
|
||||
.filter(([key]) => key.startsWith(prefix))
|
||||
.flatMap(([, state]) => state.runningTasks ?? []);
|
||||
}, [autoModeByWorktree, currentProject?.id]);
|
||||
|
||||
// Get in-progress features for keyboard shortcuts (needed before actions hook)
|
||||
// Must be after runningAutoTasks is defined
|
||||
const inProgressFeaturesForShortcuts = useMemo(() => {
|
||||
@@ -1372,7 +1382,7 @@ export function BoardView() {
|
||||
setWorktreeRefreshKey((k) => k + 1);
|
||||
}}
|
||||
onRemovedWorktrees={handleRemovedWorktrees}
|
||||
runningFeatureIds={runningAutoTasks}
|
||||
runningFeatureIds={runningAutoTasksAllWorktrees}
|
||||
branchCardCounts={branchCardCounts}
|
||||
features={hookFeatures.map((f) => ({
|
||||
id: f.id,
|
||||
|
||||
@@ -78,7 +78,9 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
)}
|
||||
>
|
||||
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', colorClass)} />
|
||||
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
|
||||
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight whitespace-nowrap">
|
||||
{title}
|
||||
</h3>
|
||||
{headerAction}
|
||||
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
|
||||
{count}
|
||||
|
||||
@@ -132,7 +132,7 @@ const SortableColumnHeader = memo(function SortableColumnHeader({
|
||||
)}
|
||||
data-testid={`list-header-${column.id}`}
|
||||
>
|
||||
<span>{column.label}</span>
|
||||
<span className="whitespace-nowrap truncate">{column.label}</span>
|
||||
<SortIcon column={column.id} sortConfig={sortConfig} />
|
||||
</div>
|
||||
);
|
||||
@@ -156,7 +156,7 @@ const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column
|
||||
)}
|
||||
data-testid={`list-header-${column.id}`}
|
||||
>
|
||||
<span>{column.label}</span>
|
||||
<span className="whitespace-nowrap truncate">{column.label}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -330,7 +330,7 @@ export function MergeWorktreeDialog({
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
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 { documentationTemplate } from './documentation';
|
||||
import { optimizationTemplate } from './optimization';
|
||||
import { commitTemplate } from './commit';
|
||||
|
||||
export interface PipelineStepTemplate {
|
||||
id: string;
|
||||
@@ -19,6 +20,7 @@ export const STEP_TEMPLATES: PipelineStepTemplate[] = [
|
||||
testingTemplate,
|
||||
documentationTemplate,
|
||||
optimizationTemplate,
|
||||
commitTemplate,
|
||||
];
|
||||
|
||||
// Helper to get template color class
|
||||
|
||||
@@ -210,7 +210,7 @@ export function PlanApprovalDialog({
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
) : (
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
|
||||
@@ -12,7 +12,8 @@ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
|
||||
import { Feature, useAppStore, formatShortcut } from '@/store/app-store';
|
||||
import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-react';
|
||||
import { Archive, Settings2, CheckSquare, GripVertical, Plus, CheckCircle2 } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
||||
import { getColumnsWithPipeline, type ColumnId } from './constants';
|
||||
import type { PipelineConfig } from '@automaker/types';
|
||||
@@ -357,35 +358,49 @@ export function KanbanBoard({
|
||||
contentClassName="perf-contain"
|
||||
headerAction={
|
||||
column.id === 'verified' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
{columnFeatures.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={onArchiveAllVerified}
|
||||
data-testid="archive-all-verified-button"
|
||||
>
|
||||
<Archive className="w-3 h-3 mr-1" />
|
||||
Complete All
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 relative"
|
||||
onClick={onShowCompletedModal}
|
||||
title={`Completed Features (${completedCount})`}
|
||||
data-testid="completed-features-button"
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{completedCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
|
||||
{completedCount > 99 ? '99+' : completedCount}
|
||||
</span>
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
{columnFeatures.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={onArchiveAllVerified}
|
||||
data-testid="archive-all-verified-button"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Complete All</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 relative"
|
||||
onClick={onShowCompletedModal}
|
||||
data-testid="completed-features-button"
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{completedCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
|
||||
{completedCount > 99 ? '99+' : completedCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Completed Features ({completedCount})</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
) : column.id === 'backlog' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
export { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||
export { DevServerLogsPanel } from './dev-server-logs-panel';
|
||||
export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||
export { WorktreeDropdown } from './worktree-dropdown';
|
||||
export type { WorktreeDropdownProps } from './worktree-dropdown';
|
||||
export { WorktreeDropdownItem } from './worktree-dropdown-item';
|
||||
export type { WorktreeDropdownItemProps } from './worktree-dropdown-item';
|
||||
export {
|
||||
truncateBranchName,
|
||||
getPRBadgeStyles,
|
||||
getChangesBadgeStyles,
|
||||
getTestStatusStyles,
|
||||
} from './worktree-indicator-utils';
|
||||
export type { TestStatus } from './worktree-indicator-utils';
|
||||
export { WorktreeMobileDropdown } from './worktree-mobile-dropdown';
|
||||
export { WorktreeTab } from './worktree-tab';
|
||||
|
||||
@@ -319,7 +319,7 @@ export function WorktreeActionsDropdown({
|
||||
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
|
||||
<span className="flex items-center mr-2">
|
||||
<Zap className="w-3.5 h-3.5 text-yellow-500" />
|
||||
<span className="ml-0.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="ml-1.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
</span>
|
||||
Stop Auto Mode
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -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}
|
||||
data-testid={`worktree-branch-${worktree.branch}`}
|
||||
>
|
||||
{isRunning && <Spinner size="xs" />}
|
||||
{isActivating && !isRunning && <Spinner size="xs" />}
|
||||
{isRunning && <Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />}
|
||||
{isActivating && !isRunning && (
|
||||
<Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />
|
||||
)}
|
||||
{worktree.branch}
|
||||
{cardCount !== undefined && cardCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
@@ -327,8 +329,10 @@ export function WorktreeTab({
|
||||
: 'Click to switch to this branch'
|
||||
}
|
||||
>
|
||||
{isRunning && <Spinner size="xs" />}
|
||||
{isActivating && !isRunning && <Spinner size="xs" />}
|
||||
{isRunning && <Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />}
|
||||
{isActivating && !isRunning && (
|
||||
<Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />
|
||||
)}
|
||||
{worktree.branch}
|
||||
{cardCount !== undefined && cardCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
|
||||
@@ -95,12 +95,20 @@ export function useWorktrees({
|
||||
);
|
||||
|
||||
// fetchWorktrees for backward compatibility - now just triggers a refetch
|
||||
const fetchWorktrees = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.worktrees.all(projectPath),
|
||||
});
|
||||
return refetch();
|
||||
}, [projectPath, queryClient, refetch]);
|
||||
// The silent option is accepted but not used (React Query handles loading states)
|
||||
// Returns removed worktrees array if any were detected, undefined otherwise
|
||||
const fetchWorktrees = useCallback(
|
||||
async (_options?: {
|
||||
silent?: boolean;
|
||||
}): Promise<Array<{ path: string; branch: string }> | undefined> => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.worktrees.all(projectPath),
|
||||
});
|
||||
const result = await refetch();
|
||||
return result.data?.removedWorktrees;
|
||||
},
|
||||
[projectPath, queryClient, refetch]
|
||||
);
|
||||
|
||||
const currentWorktreePath = currentWorktree?.path ?? null;
|
||||
const selectedWorktree = currentWorktreePath
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
WorktreeMobileDropdown,
|
||||
WorktreeActionsDropdown,
|
||||
BranchSwitchDropdown,
|
||||
WorktreeDropdown,
|
||||
} from './components';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
|
||||
@@ -36,6 +37,9 @@ import { TestLogsPanel } from '@/components/ui/test-logs-panel';
|
||||
import { Undo2 } from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
/** Threshold for switching from tabs to dropdown layout (number of worktrees) */
|
||||
const WORKTREE_DROPDOWN_THRESHOLD = 3;
|
||||
|
||||
export function WorktreePanel({
|
||||
projectPath,
|
||||
onCreateWorktree,
|
||||
@@ -379,13 +383,13 @@ export function WorktreePanel({
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Periodic interval check (5 seconds) to detect branch changes on disk
|
||||
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
|
||||
// Periodic interval check (30 seconds) to detect branch changes on disk
|
||||
// Reduced polling to lessen repeated worktree list calls while keeping UI reasonably fresh
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
useEffect(() => {
|
||||
intervalRef.current = setInterval(() => {
|
||||
fetchWorktrees({ silent: true });
|
||||
}, 5000);
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
@@ -712,30 +716,43 @@ export function WorktreePanel({
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop view: full tabs layout
|
||||
// Use dropdown layout when worktree count meets or exceeds the threshold
|
||||
const useDropdownLayout = worktrees.length >= WORKTREE_DROPDOWN_THRESHOLD;
|
||||
|
||||
// Desktop view: full tabs layout or dropdown layout depending on worktree count
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
|
||||
<span className="text-sm text-muted-foreground mr-2">
|
||||
{useDropdownLayout ? 'Worktree:' : 'Branch:'}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{mainWorktree && (
|
||||
<WorktreeTab
|
||||
key={mainWorktree.path}
|
||||
worktree={mainWorktree}
|
||||
cardCount={branchCardCounts?.[mainWorktree.branch]}
|
||||
hasChanges={mainWorktree.hasChanges}
|
||||
changedFilesCount={mainWorktree.changedFilesCount}
|
||||
isSelected={isWorktreeSelected(mainWorktree)}
|
||||
isRunning={hasRunningFeatures(mainWorktree)}
|
||||
{/* Dropdown layout for 3+ worktrees */}
|
||||
{useDropdownLayout ? (
|
||||
<>
|
||||
<WorktreeDropdown
|
||||
worktrees={worktrees}
|
||||
isWorktreeSelected={isWorktreeSelected}
|
||||
hasRunningFeatures={hasRunningFeatures}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(mainWorktree)}
|
||||
devServerInfo={getDevServerInfo(mainWorktree)}
|
||||
branchCardCounts={branchCardCounts}
|
||||
isDevServerRunning={isDevServerRunning}
|
||||
getDevServerInfo={getDevServerInfo}
|
||||
isAutoModeRunningForWorktree={isAutoModeRunningForWorktree}
|
||||
isTestRunningForWorktree={isTestRunningForWorktree}
|
||||
getTestSessionInfo={getTestSessionInfo}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
// Branch switching props
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
// Action dropdown props
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
@@ -743,16 +760,10 @@ export function WorktreePanel({
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||
hasTestCommand={hasTestCommand}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(mainWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(mainWorktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
hasInitScript={hasInitScript}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
@@ -776,111 +787,206 @@ export function WorktreePanel({
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Worktrees section - only show if enabled */}
|
||||
{useWorktreesEnabled && (
|
||||
{useWorktreesEnabled && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCreateWorktree}
|
||||
title="Create new worktree"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={async () => {
|
||||
const removedWorktrees = await fetchWorktrees();
|
||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Standard tabs layout for 1-2 worktrees */
|
||||
<>
|
||||
<div className="w-px h-5 bg-border mx-2" />
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{nonMainWorktrees.map((worktree) => {
|
||||
const cardCount = branchCardCounts?.[worktree.branch];
|
||||
return (
|
||||
<WorktreeTab
|
||||
key={worktree.path}
|
||||
worktree={worktree}
|
||||
cardCount={cardCount}
|
||||
hasChanges={worktree.hasChanges}
|
||||
changedFilesCount={worktree.changedFilesCount}
|
||||
isSelected={isWorktreeSelected(worktree)}
|
||||
isRunning={hasRunningFeatures(worktree)}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(worktree)}
|
||||
devServerInfo={getDevServerInfo(worktree)}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(worktree)}
|
||||
testSessionInfo={getTestSessionInfo(worktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCreateWorktree}
|
||||
title="Create new worktree"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={async () => {
|
||||
const removedWorktrees = await fetchWorktrees();
|
||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{mainWorktree && (
|
||||
<WorktreeTab
|
||||
key={mainWorktree.path}
|
||||
worktree={mainWorktree}
|
||||
cardCount={branchCardCounts?.[mainWorktree.branch]}
|
||||
hasChanges={mainWorktree.hasChanges}
|
||||
changedFilesCount={mainWorktree.changedFilesCount}
|
||||
isSelected={isWorktreeSelected(mainWorktree)}
|
||||
isRunning={hasRunningFeatures(mainWorktree)}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(mainWorktree)}
|
||||
devServerInfo={getDevServerInfo(mainWorktree)}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(mainWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(mainWorktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Worktrees section - only show if enabled and not using dropdown layout */}
|
||||
{useWorktreesEnabled && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-border mx-2" />
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{nonMainWorktrees.map((worktree) => {
|
||||
const cardCount = branchCardCounts?.[worktree.branch];
|
||||
return (
|
||||
<WorktreeTab
|
||||
key={worktree.path}
|
||||
worktree={worktree}
|
||||
cardCount={cardCount}
|
||||
hasChanges={worktree.hasChanges}
|
||||
changedFilesCount={worktree.changedFilesCount}
|
||||
isSelected={isWorktreeSelected(worktree)}
|
||||
isRunning={hasRunningFeatures(worktree)}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(worktree)}
|
||||
devServerInfo={getDevServerInfo(worktree)}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(worktree)}
|
||||
testSessionInfo={getTestSessionInfo(worktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCreateWorktree}
|
||||
title="Create new worktree"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={async () => {
|
||||
const removedWorktrees = await fetchWorktrees();
|
||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Trash2 } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { IdeationSettingsPopover } from './components/ideation-settings-popover';
|
||||
import type { IdeaCategory } from '@automaker/types';
|
||||
import type { IdeationMode } from '@/store/ideation-store';
|
||||
|
||||
@@ -61,7 +62,10 @@ function IdeationBreadcrumbs({
|
||||
);
|
||||
}
|
||||
|
||||
// Header shown on all pages - matches other view headers
|
||||
/**
|
||||
* Header component for the ideation view with navigation, bulk actions, and settings.
|
||||
* Displays breadcrumbs, accept/discard all buttons, and the generate ideas button with settings popover.
|
||||
*/
|
||||
function IdeationHeader({
|
||||
currentMode,
|
||||
selectedCategory,
|
||||
@@ -75,6 +79,7 @@ function IdeationHeader({
|
||||
discardAllReady,
|
||||
discardAllCount,
|
||||
onDiscardAll,
|
||||
projectPath,
|
||||
}: {
|
||||
currentMode: IdeationMode;
|
||||
selectedCategory: IdeaCategory | null;
|
||||
@@ -88,6 +93,7 @@ function IdeationHeader({
|
||||
discardAllReady: boolean;
|
||||
discardAllCount: number;
|
||||
onDiscardAll: () => void;
|
||||
projectPath: string;
|
||||
}) {
|
||||
const { getCategoryById } = useGuidedPrompts();
|
||||
const showBackButton = currentMode === 'prompts';
|
||||
@@ -157,15 +163,23 @@ function IdeationHeader({
|
||||
Accept All ({acceptAllCount})
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onGenerateIdeas} className="gap-2">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
Generate Ideas
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={onGenerateIdeas} className="gap-2">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
Generate Ideas
|
||||
</Button>
|
||||
<IdeationSettingsPopover projectPath={projectPath} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main view for brainstorming and idea management.
|
||||
* Provides a dashboard for reviewing generated ideas and a prompt selection flow
|
||||
* for generating new ideas using AI-powered suggestions.
|
||||
*/
|
||||
export function IdeationView() {
|
||||
const currentProject = useAppStore((s) => s.currentProject);
|
||||
const { currentMode, selectedCategory, setMode, setCategory } = useIdeationStore();
|
||||
@@ -282,6 +296,7 @@ export function IdeationView() {
|
||||
discardAllReady={discardAllReady}
|
||||
discardAllCount={discardAllCount}
|
||||
onDiscardAll={handleDiscardAll}
|
||||
projectPath={currentProject.path}
|
||||
/>
|
||||
|
||||
{/* Dashboard - main view */}
|
||||
|
||||
@@ -572,7 +572,7 @@ export function InterviewView() {
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -448,7 +448,7 @@ export function LoginView() {
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Authenticating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -104,7 +104,10 @@ function FeatureDefaultModelOverrideSection({ project }: { project: Project }) {
|
||||
const hasOverride = !!projectOverride;
|
||||
const effectiveValue = projectOverride || globalValue;
|
||||
|
||||
// Get display name for a model
|
||||
/**
|
||||
* Formats a user-friendly model label using provider metadata when available,
|
||||
* falling back to known Claude aliases or the raw model id.
|
||||
*/
|
||||
const getModelDisplayName = (entry: PhaseModelEntry): string => {
|
||||
if (entry.providerId) {
|
||||
const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId);
|
||||
@@ -127,10 +130,16 @@ function FeatureDefaultModelOverrideSection({ project }: { project: Project }) {
|
||||
return modelMap[entry.model] || entry.model;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears the project-level model override for this scope.
|
||||
*/
|
||||
const handleClearOverride = () => {
|
||||
setProjectDefaultFeatureModel(project.id, null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the project-level model override for this scope.
|
||||
*/
|
||||
const handleSetOverride = (entry: PhaseModelEntry) => {
|
||||
setProjectDefaultFeatureModel(project.id, entry);
|
||||
};
|
||||
@@ -209,6 +218,10 @@ function FeatureDefaultModelOverrideSection({ project }: { project: Project }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single phase override row, showing the effective model
|
||||
* (project override or global default) and wiring selector/reset actions.
|
||||
*/
|
||||
function PhaseOverrideItem({
|
||||
phase,
|
||||
project,
|
||||
@@ -225,7 +238,10 @@ function PhaseOverrideItem({
|
||||
const hasOverride = !!projectOverride;
|
||||
const effectiveValue = projectOverride || globalValue;
|
||||
|
||||
// Get display name for a model
|
||||
/**
|
||||
* Formats a user-friendly model label using provider metadata when available,
|
||||
* falling back to known Claude aliases or the raw model id.
|
||||
*/
|
||||
const getModelDisplayName = (entry: PhaseModelEntry): string => {
|
||||
if (entry.providerId) {
|
||||
const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId);
|
||||
@@ -248,10 +264,16 @@ function PhaseOverrideItem({
|
||||
return modelMap[entry.model] || entry.model;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears the project-level model override for this scope.
|
||||
*/
|
||||
const handleClearOverride = () => {
|
||||
setProjectPhaseModelOverride(project.id, phase.key, null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the project-level model override for this scope.
|
||||
*/
|
||||
const handleSetOverride = (entry: PhaseModelEntry) => {
|
||||
setProjectPhaseModelOverride(project.id, phase.key, entry);
|
||||
};
|
||||
@@ -315,6 +337,10 @@ function PhaseOverrideItem({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a titled group of phase override rows and resolves each phase's
|
||||
* global default model with a fallback to DEFAULT_PHASE_MODELS.
|
||||
*/
|
||||
function PhaseGroup({
|
||||
title,
|
||||
subtitle,
|
||||
@@ -350,9 +376,11 @@ function PhaseGroup({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the per-project model overrides UI for all phase models.
|
||||
*/
|
||||
export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
|
||||
const { clearAllProjectPhaseModelOverrides, disabledProviders, claudeCompatibleProviders } =
|
||||
useAppStore();
|
||||
const { clearAllProjectPhaseModelOverrides, claudeCompatibleProviders } = useAppStore();
|
||||
const [showBulkReplace, setShowBulkReplace] = useState(false);
|
||||
|
||||
// Count how many overrides are set (including defaultFeatureModel)
|
||||
@@ -360,25 +388,13 @@ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
|
||||
const hasDefaultFeatureModelOverride = !!project.defaultFeatureModel;
|
||||
const overrideCount = phaseOverrideCount + (hasDefaultFeatureModelOverride ? 1 : 0);
|
||||
|
||||
// Check if Claude is available
|
||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||
|
||||
// Check if there are any enabled ClaudeCompatibleProviders
|
||||
const hasEnabledProviders =
|
||||
claudeCompatibleProviders && claudeCompatibleProviders.some((p) => p.enabled !== false);
|
||||
|
||||
if (isClaudeDisabled) {
|
||||
return (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Workflow className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">Claude not configured</p>
|
||||
<p className="text-xs mt-1">
|
||||
Enable Claude in global settings to configure per-project model overrides.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all project-level phase model overrides for this project.
|
||||
*/
|
||||
const handleClearAll = () => {
|
||||
clearAllProjectPhaseModelOverrides(project.id);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Palette, Moon, Sun, Type } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Palette, Moon, Sun, Type, Sparkles, PanelLeft, Columns2 } from 'lucide-react';
|
||||
import { darkThemes, lightThemes } from '@/config/theme-options';
|
||||
import {
|
||||
UI_SANS_FONT_OPTIONS,
|
||||
@@ -11,6 +12,7 @@ import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { FontSelector } from '@/components/shared';
|
||||
import type { Theme } from '../shared/types';
|
||||
import type { SidebarStyle } from '@automaker/types';
|
||||
|
||||
interface AppearanceSectionProps {
|
||||
effectiveTheme: Theme;
|
||||
@@ -18,7 +20,16 @@ interface AppearanceSectionProps {
|
||||
}
|
||||
|
||||
export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceSectionProps) {
|
||||
const { fontFamilySans, fontFamilyMono, setFontSans, setFontMono } = useAppStore();
|
||||
const {
|
||||
fontFamilySans,
|
||||
fontFamilyMono,
|
||||
setFontSans,
|
||||
setFontMono,
|
||||
disableSplashScreen,
|
||||
setDisableSplashScreen,
|
||||
sidebarStyle,
|
||||
setSidebarStyle,
|
||||
} = useAppStore();
|
||||
|
||||
// Determine if current theme is light or dark
|
||||
const isLightTheme = lightThemes.some((t) => t.value === effectiveTheme);
|
||||
@@ -189,6 +200,118 @@ export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceS
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Splash Screen Section */}
|
||||
<div className="space-y-4 pt-6 border-t border-border/50">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Sparkles className="w-4 h-4 text-muted-foreground" />
|
||||
<Label className="text-foreground font-medium">Startup</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="disable-splash-screen" className="text-sm">
|
||||
Disable Splash Screen
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skip the animated splash screen when the app starts
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="disable-splash-screen"
|
||||
checked={disableSplashScreen}
|
||||
onCheckedChange={setDisableSplashScreen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Style Section */}
|
||||
<div className="space-y-4 pt-6 border-t border-border/50">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<PanelLeft className="w-4 h-4 text-muted-foreground" />
|
||||
<Label className="text-foreground font-medium">Sidebar Layout</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-2 mb-4">
|
||||
Choose between a modern unified sidebar or classic Discord-style layout with a separate
|
||||
project switcher.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Unified Sidebar Option */}
|
||||
<button
|
||||
onClick={() => setSidebarStyle('unified')}
|
||||
className={cn(
|
||||
'group flex flex-col items-center gap-3 p-4 rounded-xl',
|
||||
'text-sm font-medium transition-all duration-200 ease-out',
|
||||
sidebarStyle === 'unified'
|
||||
? [
|
||||
'bg-gradient-to-br from-brand-500/15 to-brand-600/10',
|
||||
'border-2 border-brand-500/40',
|
||||
'text-foreground',
|
||||
'shadow-md shadow-brand-500/10',
|
||||
]
|
||||
: [
|
||||
'bg-accent/30 hover:bg-accent/50',
|
||||
'border border-border/50 hover:border-border',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:shadow-sm',
|
||||
],
|
||||
'hover:scale-[1.02] active:scale-[0.98]'
|
||||
)}
|
||||
data-testid="sidebar-style-unified"
|
||||
>
|
||||
<PanelLeft
|
||||
className={cn(
|
||||
'w-8 h-8 transition-all duration-200',
|
||||
sidebarStyle === 'unified' ? 'text-brand-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<div className="font-medium">Unified</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Single sidebar with project dropdown
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Discord-style Sidebar Option */}
|
||||
<button
|
||||
onClick={() => setSidebarStyle('discord')}
|
||||
className={cn(
|
||||
'group flex flex-col items-center gap-3 p-4 rounded-xl',
|
||||
'text-sm font-medium transition-all duration-200 ease-out',
|
||||
sidebarStyle === 'discord'
|
||||
? [
|
||||
'bg-gradient-to-br from-brand-500/15 to-brand-600/10',
|
||||
'border-2 border-brand-500/40',
|
||||
'text-foreground',
|
||||
'shadow-md shadow-brand-500/10',
|
||||
]
|
||||
: [
|
||||
'bg-accent/30 hover:bg-accent/50',
|
||||
'border border-border/50 hover:border-border',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:shadow-sm',
|
||||
],
|
||||
'hover:scale-[1.02] active:scale-[0.98]'
|
||||
)}
|
||||
data-testid="sidebar-style-discord"
|
||||
>
|
||||
<Columns2
|
||||
className={cn(
|
||||
'w-8 h-8 transition-all duration-200',
|
||||
sidebarStyle === 'discord' ? 'text-brand-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<div className="font-medium">Classic</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Separate project switcher + sidebar
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -60,7 +60,7 @@ export function CliInstallationCard({
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -412,7 +412,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
@@ -574,7 +574,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
|
||||
>
|
||||
{isSavingApiKey ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -408,7 +408,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
@@ -681,7 +681,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup
|
||||
>
|
||||
{isSavingApiKey ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -318,7 +318,7 @@ export function CursorSetupStep({ onNext, onBack, onSkip }: CursorSetupStepProps
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Waiting for login...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -316,7 +316,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Waiting for login...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -329,7 +329,7 @@ function ClaudeContent() {
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
@@ -424,7 +424,11 @@ function ClaudeContent() {
|
||||
disabled={isSavingApiKey || !apiKey.trim()}
|
||||
className="flex-1 bg-brand-500 hover:bg-brand-600 text-white"
|
||||
>
|
||||
{isSavingApiKey ? <Spinner size="sm" /> : 'Save API Key'}
|
||||
{isSavingApiKey ? (
|
||||
<Spinner size="sm" variant="foreground" />
|
||||
) : (
|
||||
'Save API Key'
|
||||
)}
|
||||
</Button>
|
||||
{hasApiKey && (
|
||||
<Button
|
||||
@@ -661,7 +665,7 @@ function CursorContent() {
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Waiting for login...
|
||||
</>
|
||||
) : (
|
||||
@@ -918,7 +922,7 @@ function CodexContent() {
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Waiting for login...
|
||||
</>
|
||||
) : (
|
||||
@@ -961,7 +965,7 @@ function CodexContent() {
|
||||
disabled={isSaving || !apiKey.trim()}
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||
>
|
||||
{isSaving ? <Spinner size="sm" /> : 'Save API Key'}
|
||||
{isSaving ? <Spinner size="sm" variant="foreground" /> : 'Save API Key'}
|
||||
</Button>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -1194,7 +1198,7 @@ function OpencodeContent() {
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Waiting for login...
|
||||
</>
|
||||
) : (
|
||||
@@ -1466,7 +1470,7 @@ function GeminiContent() {
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Waiting for login...
|
||||
</>
|
||||
) : (
|
||||
@@ -1509,7 +1513,7 @@ function GeminiContent() {
|
||||
disabled={isSaving || !apiKey.trim()}
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||
>
|
||||
{isSaving ? <Spinner size="sm" /> : 'Save API Key'}
|
||||
{isSaving ? <Spinner size="sm" variant="foreground" /> : 'Save API Key'}
|
||||
</Button>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -1745,7 +1749,7 @@ function CopilotContent() {
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Waiting for login...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -68,7 +68,16 @@ export function useGenerateIdeationSuggestions(projectPath: string) {
|
||||
throw new Error('Ideation API not available');
|
||||
}
|
||||
|
||||
const result = await api.ideation.generateSuggestions(projectPath, promptId, category);
|
||||
// Get context sources from store
|
||||
const contextSources = useIdeationStore.getState().getContextSources(projectPath);
|
||||
|
||||
const result = await api.ideation.generateSuggestions(
|
||||
projectPath,
|
||||
promptId,
|
||||
category,
|
||||
undefined, // count - use default
|
||||
contextSources
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to generate suggestions');
|
||||
|
||||
@@ -22,6 +22,57 @@ import { useEventRecencyStore } from './use-event-recency';
|
||||
const PROGRESS_DEBOUNCE_WAIT = 150;
|
||||
const PROGRESS_DEBOUNCE_MAX_WAIT = 2000;
|
||||
|
||||
/**
|
||||
* Events that should invalidate the feature list (features.all query)
|
||||
* Note: pipeline_step_started is included to ensure Kanban board immediately reflects
|
||||
* feature moving to custom pipeline columns (fixes GitHub issue #668)
|
||||
*/
|
||||
const FEATURE_LIST_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
|
||||
'auto_mode_feature_complete',
|
||||
'auto_mode_error',
|
||||
'plan_approval_required',
|
||||
'plan_approved',
|
||||
'plan_rejected',
|
||||
'pipeline_step_started',
|
||||
'pipeline_step_complete',
|
||||
];
|
||||
|
||||
/**
|
||||
* Events that should invalidate a specific feature (features.single query)
|
||||
* Note: pipeline_step_started is NOT included here because it already invalidates
|
||||
* features.all() above, which also invalidates child queries (features.single)
|
||||
*/
|
||||
const SINGLE_FEATURE_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
|
||||
'auto_mode_feature_start',
|
||||
'auto_mode_phase',
|
||||
'auto_mode_phase_complete',
|
||||
];
|
||||
|
||||
/**
|
||||
* Events that should invalidate running agents status
|
||||
*/
|
||||
const RUNNING_AGENTS_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
|
||||
'auto_mode_feature_start',
|
||||
'auto_mode_feature_complete',
|
||||
'auto_mode_error',
|
||||
'auto_mode_resuming_features',
|
||||
];
|
||||
|
||||
/**
|
||||
* Events that signal a feature is done and debounce cleanup should occur
|
||||
*/
|
||||
const FEATURE_CLEANUP_EVENTS: AutoModeEvent['type'][] = [
|
||||
'auto_mode_feature_complete',
|
||||
'auto_mode_error',
|
||||
];
|
||||
|
||||
/**
|
||||
* Type guard to check if an event has a featureId property
|
||||
*/
|
||||
function hasFeatureId(event: AutoModeEvent): event is AutoModeEvent & { featureId: string } {
|
||||
return 'featureId' in event && typeof event.featureId === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a unique key for per-feature debounce tracking
|
||||
*/
|
||||
@@ -115,40 +166,22 @@ export function useAutoModeQueryInvalidation(projectPath: string | undefined) {
|
||||
// This allows polling to be disabled when WebSocket events are flowing
|
||||
recordGlobalEvent();
|
||||
|
||||
// Invalidate features when agent completes, errors, or receives plan approval
|
||||
if (
|
||||
event.type === 'auto_mode_feature_complete' ||
|
||||
event.type === 'auto_mode_error' ||
|
||||
event.type === 'plan_approval_required' ||
|
||||
event.type === 'plan_approved' ||
|
||||
event.type === 'plan_rejected' ||
|
||||
event.type === 'pipeline_step_complete'
|
||||
) {
|
||||
// Invalidate feature list for lifecycle events
|
||||
if (FEATURE_LIST_INVALIDATION_EVENTS.includes(event.type)) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProjectPath),
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate running agents on any status change
|
||||
if (
|
||||
event.type === 'auto_mode_feature_start' ||
|
||||
event.type === 'auto_mode_feature_complete' ||
|
||||
event.type === 'auto_mode_error' ||
|
||||
event.type === 'auto_mode_resuming_features'
|
||||
) {
|
||||
// Invalidate running agents on status changes
|
||||
if (RUNNING_AGENTS_INVALIDATION_EVENTS.includes(event.type)) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.runningAgents.all(),
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate specific feature when it starts or has phase changes
|
||||
if (
|
||||
(event.type === 'auto_mode_feature_start' ||
|
||||
event.type === 'auto_mode_phase' ||
|
||||
event.type === 'auto_mode_phase_complete' ||
|
||||
event.type === 'pipeline_step_started') &&
|
||||
'featureId' in event
|
||||
) {
|
||||
// Invalidate specific feature for phase changes
|
||||
if (SINGLE_FEATURE_INVALIDATION_EVENTS.includes(event.type) && hasFeatureId(event)) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.single(currentProjectPath, event.featureId),
|
||||
});
|
||||
@@ -156,23 +189,19 @@ export function useAutoModeQueryInvalidation(projectPath: string | undefined) {
|
||||
|
||||
// Invalidate agent output during progress updates (DEBOUNCED)
|
||||
// Uses per-feature debouncing to batch rapid progress events during streaming
|
||||
if (event.type === 'auto_mode_progress' && 'featureId' in event) {
|
||||
if (event.type === 'auto_mode_progress' && hasFeatureId(event)) {
|
||||
const debouncedInvalidation = getDebouncedInvalidation(event.featureId);
|
||||
debouncedInvalidation();
|
||||
}
|
||||
|
||||
// Clean up debounced functions when feature completes or errors
|
||||
// This ensures we flush any pending invalidations and free memory
|
||||
if (
|
||||
(event.type === 'auto_mode_feature_complete' || event.type === 'auto_mode_error') &&
|
||||
'featureId' in event &&
|
||||
event.featureId
|
||||
) {
|
||||
if (FEATURE_CLEANUP_EVENTS.includes(event.type) && hasFeatureId(event)) {
|
||||
cleanupFeatureDebounce(event.featureId);
|
||||
}
|
||||
|
||||
// Invalidate worktree queries when feature completes (may have created worktree)
|
||||
if (event.type === 'auto_mode_feature_complete' && 'featureId' in event) {
|
||||
if (event.type === 'auto_mode_feature_complete' && hasFeatureId(event)) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.worktrees.all(currentProjectPath),
|
||||
});
|
||||
|
||||
@@ -14,8 +14,8 @@ export interface ResponsiveKanbanConfig {
|
||||
* Default configuration for responsive Kanban columns
|
||||
*/
|
||||
const DEFAULT_CONFIG: ResponsiveKanbanConfig = {
|
||||
columnWidth: 288, // 18rem = 288px (w-72)
|
||||
columnMinWidth: 280, // Minimum column width - ensures usability
|
||||
columnWidth: 320, // Increased from 288px to accommodate longer column titles
|
||||
columnMinWidth: 320, // Increased from 280px to prevent title overflow
|
||||
columnMaxWidth: Infinity, // No max width - columns scale evenly to fill viewport
|
||||
gap: 20, // gap-5 = 20px
|
||||
padding: 40, // px-5 on both sides = 40px (matches gap between columns)
|
||||
|
||||
@@ -181,6 +181,7 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
||||
defaultPlanningMode: state.defaultPlanningMode as GlobalSettings['defaultPlanningMode'],
|
||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval as boolean,
|
||||
muteDoneSound: state.muteDoneSound as boolean,
|
||||
disableSplashScreen: state.disableSplashScreen as boolean,
|
||||
enhancementModel: state.enhancementModel as GlobalSettings['enhancementModel'],
|
||||
validationModel: state.validationModel as GlobalSettings['validationModel'],
|
||||
phaseModels: state.phaseModels as GlobalSettings['phaseModels'],
|
||||
@@ -698,6 +699,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
fontFamilySans: settings.fontFamilySans ?? null,
|
||||
fontFamilyMono: settings.fontFamilyMono ?? null,
|
||||
sidebarOpen: settings.sidebarOpen ?? true,
|
||||
sidebarStyle: settings.sidebarStyle ?? 'unified',
|
||||
collapsedNavSections: settings.collapsedNavSections ?? {},
|
||||
chatHistoryOpen: settings.chatHistoryOpen ?? false,
|
||||
maxConcurrency: settings.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
||||
autoModeByWorktree: restoredAutoModeByWorktree,
|
||||
@@ -711,6 +714,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
model: 'claude-opus',
|
||||
},
|
||||
muteDoneSound: settings.muteDoneSound ?? false,
|
||||
disableSplashScreen: settings.disableSplashScreen ?? false,
|
||||
serverLogLevel: settings.serverLogLevel ?? 'info',
|
||||
enableRequestLogging: settings.enableRequestLogging ?? true,
|
||||
showQueryDevtools: settings.showQueryDevtools ?? true,
|
||||
@@ -798,6 +802,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
defaultPlanningMode: state.defaultPlanningMode,
|
||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
||||
muteDoneSound: state.muteDoneSound,
|
||||
disableSplashScreen: state.disableSplashScreen,
|
||||
serverLogLevel: state.serverLogLevel,
|
||||
enableRequestLogging: state.enableRequestLogging,
|
||||
enhancementModel: state.enhancementModel,
|
||||
|
||||
@@ -53,6 +53,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'terminalFontFamily', // Maps to terminalState.fontFamily
|
||||
'openTerminalMode', // Maps to terminalState.openTerminalMode
|
||||
'sidebarOpen',
|
||||
'sidebarStyle',
|
||||
'collapsedNavSections',
|
||||
'chatHistoryOpen',
|
||||
'maxConcurrency',
|
||||
'autoModeByWorktree', // Per-worktree auto mode settings (only maxConcurrency is persisted)
|
||||
@@ -64,6 +66,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'defaultRequirePlanApproval',
|
||||
'defaultFeatureModel',
|
||||
'muteDoneSound',
|
||||
'disableSplashScreen',
|
||||
'serverLogLevel',
|
||||
'enableRequestLogging',
|
||||
'showQueryDevtools',
|
||||
@@ -697,6 +700,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
useAppStore.setState({
|
||||
theme: serverSettings.theme as unknown as ThemeMode,
|
||||
sidebarOpen: serverSettings.sidebarOpen,
|
||||
sidebarStyle: serverSettings.sidebarStyle ?? 'unified',
|
||||
collapsedNavSections: serverSettings.collapsedNavSections ?? {},
|
||||
chatHistoryOpen: serverSettings.chatHistoryOpen,
|
||||
maxConcurrency: serverSettings.maxConcurrency,
|
||||
autoModeByWorktree: restoredAutoModeByWorktree,
|
||||
@@ -710,6 +715,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
? migratePhaseModelEntry(serverSettings.defaultFeatureModel)
|
||||
: { model: 'claude-opus' },
|
||||
muteDoneSound: serverSettings.muteDoneSound,
|
||||
disableSplashScreen: serverSettings.disableSplashScreen ?? false,
|
||||
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
|
||||
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
|
||||
enhancementModel: serverSettings.enhancementModel,
|
||||
|
||||
@@ -237,39 +237,34 @@ function cleanFragmentedText(content: string): string {
|
||||
/**
|
||||
* Extracts a summary from completed feature context
|
||||
* Looks for content between <summary> and </summary> tags
|
||||
* Returns the LAST summary found to ensure we get the most recent/updated one
|
||||
*/
|
||||
function extractSummary(content: string): string | undefined {
|
||||
// First, clean up any fragmented text from streaming
|
||||
const cleanedContent = cleanFragmentedText(content);
|
||||
|
||||
// Look for <summary> tags - capture everything between opening and closing tags
|
||||
const summaryTagMatch = cleanedContent.match(/<summary>([\s\S]*?)<\/summary>/i);
|
||||
if (summaryTagMatch) {
|
||||
// Clean up the extracted summary content as well
|
||||
return cleanFragmentedText(summaryTagMatch[1]).trim();
|
||||
}
|
||||
// Define regex patterns to try in order of priority
|
||||
// Each pattern specifies which capture group contains the summary content
|
||||
const regexesToTry = [
|
||||
{ regex: /<summary>([\s\S]*?)<\/summary>/gi, group: 1 },
|
||||
{ regex: /## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/gi, group: 1 },
|
||||
{
|
||||
regex:
|
||||
/✓ (?:Feature|Verification|Task) (?:successfully|completed|verified)[^\n]*(?:\n[^\n]{1,200})?/gi,
|
||||
group: 0,
|
||||
},
|
||||
{
|
||||
regex: /(?:What was done|Changes made|Implemented)[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/gi,
|
||||
group: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// Fallback: Look for summary sections - capture everything including subsections (###)
|
||||
// Stop at same-level ## sections (but not ###), or tool markers, or end
|
||||
const summaryMatch = cleanedContent.match(/## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i);
|
||||
if (summaryMatch) {
|
||||
return cleanFragmentedText(summaryMatch[1]).trim();
|
||||
}
|
||||
|
||||
// Look for completion markers and extract surrounding text
|
||||
const completionMatch = cleanedContent.match(
|
||||
/✓ (?:Feature|Verification|Task) (?:successfully|completed|verified)[^\n]*(?:\n[^\n]{1,200})?/i
|
||||
);
|
||||
if (completionMatch) {
|
||||
return cleanFragmentedText(completionMatch[0]).trim();
|
||||
}
|
||||
|
||||
// Look for "What was done" type sections
|
||||
const whatWasDoneMatch = cleanedContent.match(
|
||||
/(?:What was done|Changes made|Implemented)[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i
|
||||
);
|
||||
if (whatWasDoneMatch) {
|
||||
return cleanFragmentedText(whatWasDoneMatch[1]).trim();
|
||||
for (const { regex, group } of regexesToTry) {
|
||||
const matches = [...cleanedContent.matchAll(regex)];
|
||||
if (matches.length > 0) {
|
||||
const lastMatch = matches[matches.length - 1];
|
||||
return cleanFragmentedText(lastMatch[group]).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
CreateIdeaInput,
|
||||
UpdateIdeaInput,
|
||||
ConvertToFeatureOptions,
|
||||
IdeationContextSources,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
|
||||
import { getJSON, setJSON, removeItem } from './storage';
|
||||
@@ -114,7 +115,8 @@ export interface IdeationAPI {
|
||||
projectPath: string,
|
||||
promptId: string,
|
||||
category: IdeaCategory,
|
||||
count?: number
|
||||
count?: number,
|
||||
contextSources?: IdeationContextSources
|
||||
) => Promise<{ success: boolean; suggestions?: AnalysisSuggestion[]; error?: string }>;
|
||||
|
||||
// Convert to feature
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
NotificationsAPI,
|
||||
EventHistoryAPI,
|
||||
} from './electron';
|
||||
import type { IdeationContextSources } from '@automaker/types';
|
||||
import type { EventHistoryFilter } from '@automaker/types';
|
||||
import type { Message, SessionListItem } from '@/types/electron';
|
||||
import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
|
||||
@@ -2739,9 +2740,16 @@ export class HttpApiClient implements ElectronAPI {
|
||||
projectPath: string,
|
||||
promptId: string,
|
||||
category: IdeaCategory,
|
||||
count?: number
|
||||
count?: number,
|
||||
contextSources?: IdeationContextSources
|
||||
) =>
|
||||
this.post('/api/ideation/suggestions/generate', { projectPath, promptId, category, count }),
|
||||
this.post('/api/ideation/suggestions/generate', {
|
||||
projectPath,
|
||||
promptId,
|
||||
category,
|
||||
count,
|
||||
contextSources,
|
||||
}),
|
||||
|
||||
convertToFeature: (projectPath: string, ideaId: string, options?: ConvertToFeatureOptions) =>
|
||||
this.post('/api/ideation/convert', { projectPath, ideaId, ...options }),
|
||||
|
||||
@@ -1198,46 +1198,48 @@ function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] {
|
||||
|
||||
/**
|
||||
* Extracts summary content from raw log output
|
||||
* Returns the summary text if found, or null if no summary exists
|
||||
* Returns the LAST summary text if found, or null if no summary exists
|
||||
* This ensures we get the most recent/updated summary when multiple exist
|
||||
*/
|
||||
export function extractSummary(rawOutput: string): string | null {
|
||||
if (!rawOutput || !rawOutput.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to find <summary> tags first (preferred format)
|
||||
const summaryTagMatch = rawOutput.match(/<summary>([\s\S]*?)<\/summary>/);
|
||||
if (summaryTagMatch) {
|
||||
return summaryTagMatch[1].trim();
|
||||
}
|
||||
// First, clean up any fragmented text from streaming
|
||||
// This handles cases where streaming providers send partial text chunks
|
||||
// that got separated by newlines during accumulation (e.g., "<sum\n\nmary>")
|
||||
const cleanedOutput = cleanFragmentedText(rawOutput);
|
||||
|
||||
// Try to find markdown ## Summary section
|
||||
const summaryHeaderMatch = rawOutput.match(/^##\s+Summary\s*\n([\s\S]*?)(?=\n##\s+|$)/m);
|
||||
if (summaryHeaderMatch) {
|
||||
return summaryHeaderMatch[1].trim();
|
||||
}
|
||||
// Define regex patterns to try in order of priority
|
||||
// Each pattern specifies a processor function to extract the summary from the match
|
||||
const regexesToTry: Array<{
|
||||
regex: RegExp;
|
||||
processor: (m: RegExpMatchArray) => string;
|
||||
}> = [
|
||||
{ regex: /<summary>([\s\S]*?)<\/summary>/gi, processor: (m) => m[1] },
|
||||
{ regex: /^##\s+Summary[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, processor: (m) => m[1] },
|
||||
{
|
||||
regex: /^##\s+(Feature|Changes|Implementation)[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm,
|
||||
processor: (m) => `## ${m[1]}\n${m[2]}`,
|
||||
},
|
||||
{
|
||||
regex: /(^|\n)(All tasks completed[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g,
|
||||
processor: (m) => m[2],
|
||||
},
|
||||
{
|
||||
regex:
|
||||
/(^|\n)((I've|I have) (successfully |now )?(completed|finished|implemented)[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g,
|
||||
processor: (m) => m[2],
|
||||
},
|
||||
];
|
||||
|
||||
// Try other summary formats (Feature, Changes, Implementation)
|
||||
const otherHeaderMatch = rawOutput.match(
|
||||
/^##\s+(Feature|Changes|Implementation)\s*\n([\s\S]*?)(?=\n##\s+|$)/m
|
||||
);
|
||||
if (otherHeaderMatch) {
|
||||
return `## ${otherHeaderMatch[1]}\n${otherHeaderMatch[2].trim()}`;
|
||||
}
|
||||
|
||||
// Try to find summary introduction lines
|
||||
const introMatch = rawOutput.match(
|
||||
/(^|\n)(All tasks completed[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/
|
||||
);
|
||||
if (introMatch) {
|
||||
return introMatch[2].trim();
|
||||
}
|
||||
|
||||
const completionMatch = rawOutput.match(
|
||||
/(^|\n)((I've|I have) (successfully |now )?(completed|finished|implemented)[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/
|
||||
);
|
||||
if (completionMatch) {
|
||||
return completionMatch[2].trim();
|
||||
for (const { regex, processor } of regexesToTry) {
|
||||
const matches = [...cleanedOutput.matchAll(regex)];
|
||||
if (matches.length > 0) {
|
||||
const lastMatch = matches[matches.length - 1];
|
||||
return cleanFragmentedText(processor(lastMatch)).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { Sidebar } from '@/components/layout/sidebar';
|
||||
import { ProjectSwitcher } from '@/components/layout/project-switcher';
|
||||
import {
|
||||
FileBrowserProvider,
|
||||
useFileBrowser,
|
||||
@@ -167,6 +168,7 @@ function RootLayoutContent() {
|
||||
theme,
|
||||
fontFamilySans,
|
||||
fontFamilyMono,
|
||||
sidebarStyle,
|
||||
skipSandboxWarning,
|
||||
setSkipSandboxWarning,
|
||||
fetchCodexModels,
|
||||
@@ -860,6 +862,8 @@ function RootLayoutContent() {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{/* Discord-style layout: narrow project switcher + expandable sidebar */}
|
||||
{sidebarStyle === 'discord' && <ProjectSwitcher />}
|
||||
<Sidebar />
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
EventHook,
|
||||
ClaudeApiProfile,
|
||||
ClaudeCompatibleProvider,
|
||||
SidebarStyle,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
getAllCursorModelIds,
|
||||
@@ -610,6 +611,8 @@ export interface AppState {
|
||||
// View state
|
||||
currentView: ViewMode;
|
||||
sidebarOpen: boolean;
|
||||
sidebarStyle: SidebarStyle; // 'unified' (modern) or 'discord' (classic two-sidebar layout)
|
||||
collapsedNavSections: Record<string, boolean>; // Collapsed state of nav sections (key: section label)
|
||||
mobileSidebarHidden: boolean; // Completely hides sidebar on mobile
|
||||
|
||||
// Agent Session state (per-project, keyed by project path)
|
||||
@@ -686,6 +689,9 @@ export interface AppState {
|
||||
// Audio Settings
|
||||
muteDoneSound: boolean; // When true, mute the notification sound when agents complete (default: false)
|
||||
|
||||
// Splash Screen Settings
|
||||
disableSplashScreen: boolean; // When true, skip showing the splash screen overlay on startup
|
||||
|
||||
// Server Log Level Settings
|
||||
serverLogLevel: ServerLogLevel; // Log level for the API server (error, warn, info, debug)
|
||||
enableRequestLogging: boolean; // Enable HTTP request logging (Morgan)
|
||||
@@ -1046,6 +1052,9 @@ export interface AppActions {
|
||||
setCurrentView: (view: ViewMode) => void;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setSidebarStyle: (style: SidebarStyle) => void;
|
||||
setCollapsedNavSections: (sections: Record<string, boolean>) => void;
|
||||
toggleNavSection: (sectionLabel: string) => void;
|
||||
toggleMobileSidebarHidden: () => void;
|
||||
setMobileSidebarHidden: (hidden: boolean) => void;
|
||||
|
||||
@@ -1183,6 +1192,9 @@ export interface AppActions {
|
||||
// Audio Settings actions
|
||||
setMuteDoneSound: (muted: boolean) => void;
|
||||
|
||||
// Splash Screen actions
|
||||
setDisableSplashScreen: (disabled: boolean) => void;
|
||||
|
||||
// Server Log Level actions
|
||||
setServerLogLevel: (level: ServerLogLevel) => void;
|
||||
setEnableRequestLogging: (enabled: boolean) => void;
|
||||
@@ -1471,6 +1483,8 @@ const initialState: AppState = {
|
||||
projectHistoryIndex: -1,
|
||||
currentView: 'welcome',
|
||||
sidebarOpen: true,
|
||||
sidebarStyle: 'unified', // Default to modern unified sidebar
|
||||
collapsedNavSections: {}, // Nav sections expanded by default (sections set their own defaults)
|
||||
mobileSidebarHidden: false, // Sidebar visible by default on mobile
|
||||
lastSelectedSessionByProject: {},
|
||||
theme: getStoredTheme() || 'dark', // Use localStorage theme as initial value, fallback to 'dark'
|
||||
@@ -1502,6 +1516,7 @@ const initialState: AppState = {
|
||||
worktreesByProject: {},
|
||||
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
|
||||
muteDoneSound: false, // Default to sound enabled (not muted)
|
||||
disableSplashScreen: false, // Default to showing splash screen
|
||||
serverLogLevel: 'info', // Default to info level for server logs
|
||||
enableRequestLogging: true, // Default to enabled for HTTP request logging
|
||||
showQueryDevtools: true, // Default to enabled (only shown in dev mode anyway)
|
||||
@@ -1929,6 +1944,15 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
setCurrentView: (view) => set({ currentView: view }),
|
||||
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
setSidebarStyle: (style) => set({ sidebarStyle: style }),
|
||||
setCollapsedNavSections: (sections) => set({ collapsedNavSections: sections }),
|
||||
toggleNavSection: (sectionLabel) =>
|
||||
set((state) => ({
|
||||
collapsedNavSections: {
|
||||
...state.collapsedNavSections,
|
||||
[sectionLabel]: !state.collapsedNavSections[sectionLabel],
|
||||
},
|
||||
})),
|
||||
toggleMobileSidebarHidden: () => set({ mobileSidebarHidden: !get().mobileSidebarHidden }),
|
||||
setMobileSidebarHidden: (hidden) => set({ mobileSidebarHidden: hidden }),
|
||||
|
||||
@@ -2626,6 +2650,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
// Audio Settings actions
|
||||
setMuteDoneSound: (muted) => set({ muteDoneSound: muted }),
|
||||
|
||||
// Splash Screen actions
|
||||
setDisableSplashScreen: (disabled) => set({ disableSplashScreen: disabled }),
|
||||
|
||||
// Server Log Level actions
|
||||
setServerLogLevel: (level) => set({ serverLogLevel: level }),
|
||||
setEnableRequestLogging: (enabled) => set({ enableRequestLogging: enabled }),
|
||||
|
||||
@@ -11,7 +11,9 @@ import type {
|
||||
IdeationPrompt,
|
||||
AnalysisSuggestion,
|
||||
ProjectAnalysisResult,
|
||||
IdeationContextSources,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types';
|
||||
|
||||
// ============================================================================
|
||||
// Generation Job Types
|
||||
@@ -61,6 +63,9 @@ interface IdeationState {
|
||||
currentMode: IdeationMode;
|
||||
selectedCategory: IdeaCategory | null;
|
||||
filterStatus: IdeaStatus | 'all';
|
||||
|
||||
// Context sources per project
|
||||
contextSourcesByProject: Record<string, Partial<IdeationContextSources>>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -110,6 +115,21 @@ interface IdeationActions {
|
||||
setCategory: (category: IdeaCategory | null) => void;
|
||||
setFilterStatus: (status: IdeaStatus | 'all') => void;
|
||||
|
||||
// Context sources
|
||||
/**
|
||||
* Returns the effective context-source settings for a project,
|
||||
* merging defaults with any stored overrides.
|
||||
*/
|
||||
getContextSources: (projectPath: string) => IdeationContextSources;
|
||||
/**
|
||||
* Updates a single context-source flag for a project.
|
||||
*/
|
||||
setContextSource: (
|
||||
projectPath: string,
|
||||
key: keyof IdeationContextSources,
|
||||
value: boolean
|
||||
) => void;
|
||||
|
||||
// Reset
|
||||
reset: () => void;
|
||||
resetSuggestions: () => void;
|
||||
@@ -135,6 +155,7 @@ const initialState: IdeationState = {
|
||||
currentMode: 'dashboard',
|
||||
selectedCategory: null,
|
||||
filterStatus: 'all',
|
||||
contextSourcesByProject: {},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -300,6 +321,24 @@ export const useIdeationStore = create<IdeationState & IdeationActions>()(
|
||||
|
||||
setFilterStatus: (status) => set({ filterStatus: status }),
|
||||
|
||||
// Context sources
|
||||
getContextSources: (projectPath) => {
|
||||
const state = get();
|
||||
const projectOverrides = state.contextSourcesByProject[projectPath] ?? {};
|
||||
return { ...DEFAULT_IDEATION_CONTEXT_SOURCES, ...projectOverrides };
|
||||
},
|
||||
|
||||
setContextSource: (projectPath, key, value) =>
|
||||
set((state) => ({
|
||||
contextSourcesByProject: {
|
||||
...state.contextSourcesByProject,
|
||||
[projectPath]: {
|
||||
...state.contextSourcesByProject[projectPath],
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
// Reset
|
||||
reset: () => set(initialState),
|
||||
|
||||
@@ -313,13 +352,14 @@ export const useIdeationStore = create<IdeationState & IdeationActions>()(
|
||||
}),
|
||||
{
|
||||
name: 'automaker-ideation-store',
|
||||
version: 4,
|
||||
version: 5,
|
||||
partialize: (state) => ({
|
||||
// Only persist these fields
|
||||
ideas: state.ideas,
|
||||
generationJobs: state.generationJobs,
|
||||
analysisResult: state.analysisResult,
|
||||
filterStatus: state.filterStatus,
|
||||
contextSourcesByProject: state.contextSourcesByProject,
|
||||
}),
|
||||
migrate: (persistedState: unknown, version: number) => {
|
||||
const state = persistedState as Record<string, unknown>;
|
||||
@@ -331,6 +371,13 @@ export const useIdeationStore = create<IdeationState & IdeationActions>()(
|
||||
generationJobs: jobs.filter((job) => job.projectPath !== undefined),
|
||||
};
|
||||
}
|
||||
if (version < 5) {
|
||||
// Initialize contextSourcesByProject if not present
|
||||
return {
|
||||
...state,
|
||||
contextSourcesByProject: state.contextSourcesByProject ?? {},
|
||||
};
|
||||
}
|
||||
return state;
|
||||
},
|
||||
}
|
||||
|
||||
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,
|
||||
type PhaseModelEntry,
|
||||
type ThinkingLevel,
|
||||
type ReasoningEffort,
|
||||
} from '@automaker/types';
|
||||
|
||||
// Pattern definitions for Codex/OpenAI models
|
||||
@@ -162,8 +163,10 @@ export function getEffectiveModel(
|
||||
export interface ResolvedPhaseModel {
|
||||
/** Resolved model string (full model ID) */
|
||||
model: string;
|
||||
/** Optional thinking level for extended thinking */
|
||||
/** Optional thinking level for extended thinking (Claude models) */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
/** Optional reasoning effort for timeout calculation (Codex models) */
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
/** Provider ID if using a ClaudeCompatibleProvider */
|
||||
providerId?: string;
|
||||
}
|
||||
@@ -205,6 +208,7 @@ export function resolvePhaseModel(
|
||||
return {
|
||||
model: resolveModelString(undefined, defaultModel),
|
||||
thinkingLevel: undefined,
|
||||
reasoningEffort: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -214,12 +218,13 @@ export function resolvePhaseModel(
|
||||
return {
|
||||
model: resolveModelString(phaseModel, defaultModel),
|
||||
thinkingLevel: undefined,
|
||||
reasoningEffort: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle new PhaseModelEntry object format
|
||||
console.log(
|
||||
`[ModelResolver] phaseModel is object format: model="${phaseModel.model}", thinkingLevel="${phaseModel.thinkingLevel}", providerId="${phaseModel.providerId}"`
|
||||
`[ModelResolver] phaseModel is object format: model="${phaseModel.model}", thinkingLevel="${phaseModel.thinkingLevel}", reasoningEffort="${phaseModel.reasoningEffort}", providerId="${phaseModel.providerId}"`
|
||||
);
|
||||
|
||||
// If providerId is set, pass through the model string unchanged
|
||||
@@ -231,6 +236,7 @@ export function resolvePhaseModel(
|
||||
return {
|
||||
model: phaseModel.model, // Pass through unchanged
|
||||
thinkingLevel: phaseModel.thinkingLevel,
|
||||
reasoningEffort: phaseModel.reasoningEffort,
|
||||
providerId: phaseModel.providerId,
|
||||
};
|
||||
}
|
||||
@@ -239,5 +245,6 @@ export function resolvePhaseModel(
|
||||
return {
|
||||
model: resolveModelString(phaseModel.model, defaultModel),
|
||||
thinkingLevel: phaseModel.thinkingLevel,
|
||||
reasoningEffort: phaseModel.reasoningEffort,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -228,3 +228,35 @@ export interface IdeationAnalysisEvent {
|
||||
result?: ProjectAnalysisResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Context Sources Configuration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for which context sources to include when generating ideas.
|
||||
* All values default to true for backward compatibility.
|
||||
*/
|
||||
export interface IdeationContextSources {
|
||||
/** Include .automaker/context/*.md|.txt files */
|
||||
useContextFiles: boolean;
|
||||
/** Include .automaker/memory/*.md files */
|
||||
useMemoryFiles: boolean;
|
||||
/** Include existing features from the board */
|
||||
useExistingFeatures: boolean;
|
||||
/** Include existing ideas from ideation */
|
||||
useExistingIdeas: boolean;
|
||||
/** Include app specification (.automaker/app_spec.txt) */
|
||||
useAppSpec: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default context sources configuration - all enabled for backward compatibility
|
||||
*/
|
||||
export const DEFAULT_IDEATION_CONTEXT_SOURCES: IdeationContextSources = {
|
||||
useContextFiles: true,
|
||||
useMemoryFiles: true,
|
||||
useExistingFeatures: true,
|
||||
useExistingIdeas: true,
|
||||
useAppSpec: true,
|
||||
};
|
||||
|
||||
@@ -145,6 +145,7 @@ export { DEFAULT_PROMPT_CUSTOMIZATION } from './prompts.js';
|
||||
// Settings types and constants
|
||||
export type {
|
||||
ThemeMode,
|
||||
SidebarStyle,
|
||||
PlanningMode,
|
||||
ThinkingLevel,
|
||||
ServerLogLevel,
|
||||
@@ -271,6 +272,7 @@ export {
|
||||
getBareModelId,
|
||||
normalizeModelString,
|
||||
validateBareModelId,
|
||||
supportsStructuredOutput,
|
||||
} from './provider-utils.js';
|
||||
|
||||
// Model migration utilities
|
||||
@@ -324,7 +326,9 @@ export type {
|
||||
IdeationEventType,
|
||||
IdeationStreamEvent,
|
||||
IdeationAnalysisEvent,
|
||||
IdeationContextSources,
|
||||
} from './ideation.js';
|
||||
export { DEFAULT_IDEATION_CONTEXT_SOURCES } from './ideation.js';
|
||||
|
||||
// Notification types
|
||||
export type { NotificationType, Notification, NotificationsFile } from './notification.js';
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { ModelProvider } from './settings.js';
|
||||
import { CURSOR_MODEL_MAP, LEGACY_CURSOR_MODEL_MAP } from './cursor-models.js';
|
||||
import { LEGACY_CURSOR_MODEL_MAP } from './cursor-models.js';
|
||||
import { CLAUDE_MODEL_MAP, CODEX_MODEL_MAP } from './model.js';
|
||||
import { OPENCODE_MODEL_CONFIG_MAP, LEGACY_OPENCODE_MODEL_MAP } from './opencode-models.js';
|
||||
import { GEMINI_MODEL_MAP } from './gemini-models.js';
|
||||
@@ -345,6 +345,44 @@ export function normalizeModelString(model: string | undefined | null): string {
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model supports structured output (JSON schema)
|
||||
*
|
||||
* Structured output is a feature that allows the model to return responses
|
||||
* conforming to a JSON schema. Currently supported by:
|
||||
* - Claude models (native Anthropic API support)
|
||||
* - Codex/OpenAI models (via response_format with json_schema)
|
||||
*
|
||||
* Models that do NOT support structured output:
|
||||
* - Cursor models (uses different API format)
|
||||
* - OpenCode models (various backend providers)
|
||||
* - Gemini models (different API)
|
||||
* - Copilot models (proxy to various backends)
|
||||
*
|
||||
* @param model - Model string to check
|
||||
* @returns true if the model supports structured output
|
||||
*
|
||||
* @example
|
||||
* supportsStructuredOutput('sonnet') // true (Claude)
|
||||
* supportsStructuredOutput('claude-sonnet-4-20250514') // true (Claude)
|
||||
* supportsStructuredOutput('codex-gpt-5.2') // true (Codex/OpenAI)
|
||||
* supportsStructuredOutput('cursor-auto') // false
|
||||
* supportsStructuredOutput('gemini-2.5-pro') // false
|
||||
*/
|
||||
export function supportsStructuredOutput(model: string | undefined | null): boolean {
|
||||
// Exclude proxy providers first - they may have Claude/Codex in the model name
|
||||
// but route through different APIs that don't support structured output
|
||||
if (
|
||||
isCursorModel(model) ||
|
||||
isGeminiModel(model) ||
|
||||
isOpencodeModel(model) ||
|
||||
isCopilotModel(model)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return isClaudeModel(model) || isCodexModel(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a model ID does not contain a provider prefix
|
||||
*
|
||||
|
||||
@@ -78,6 +78,14 @@ export type ServerLogLevel = 'error' | 'warn' | 'info' | 'debug';
|
||||
/** ThinkingLevel - Extended thinking levels for Claude models (reasoning intensity) */
|
||||
export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
|
||||
/**
|
||||
* SidebarStyle - Sidebar layout style options
|
||||
*
|
||||
* - 'unified': Single sidebar with integrated project dropdown (default, modern)
|
||||
* - 'discord': Two sidebars - narrow project switcher + expandable navigation sidebar (classic)
|
||||
*/
|
||||
export type SidebarStyle = 'unified' | 'discord';
|
||||
|
||||
/**
|
||||
* Thinking token budget mapping based on Claude SDK documentation.
|
||||
* @see https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking
|
||||
@@ -836,6 +844,10 @@ export interface GlobalSettings {
|
||||
// UI State Preferences
|
||||
/** Whether sidebar is currently open */
|
||||
sidebarOpen: boolean;
|
||||
/** Sidebar layout style ('unified' = modern single sidebar, 'discord' = classic two-sidebar layout) */
|
||||
sidebarStyle: SidebarStyle;
|
||||
/** Collapsed state of sidebar navigation sections (key: section label, value: is collapsed) */
|
||||
collapsedNavSections?: Record<string, boolean>;
|
||||
/** Whether chat history panel is open */
|
||||
chatHistoryOpen: boolean;
|
||||
|
||||
@@ -861,6 +873,10 @@ export interface GlobalSettings {
|
||||
/** Mute completion notification sound */
|
||||
muteDoneSound: boolean;
|
||||
|
||||
// Splash Screen
|
||||
/** Disable the splash screen overlay on app startup */
|
||||
disableSplashScreen: boolean;
|
||||
|
||||
// Server Logging Preferences
|
||||
/** Log level for the API server (error, warn, info, debug). Default: info */
|
||||
serverLogLevel?: ServerLogLevel;
|
||||
@@ -1310,6 +1326,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
skipClaudeSetup: false,
|
||||
theme: 'dark',
|
||||
sidebarOpen: true,
|
||||
sidebarStyle: 'unified',
|
||||
collapsedNavSections: {},
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
|
||||
defaultSkipTests: true,
|
||||
@@ -1320,6 +1338,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
defaultRequirePlanApproval: false,
|
||||
defaultFeatureModel: { model: 'claude-opus' }, // Use canonical ID
|
||||
muteDoneSound: false,
|
||||
disableSplashScreen: false,
|
||||
serverLogLevel: 'info',
|
||||
enableRequestLogging: true,
|
||||
showQueryDevtools: true,
|
||||
|
||||
@@ -97,6 +97,8 @@ export interface LoadContextFilesOptions {
|
||||
projectPath: string;
|
||||
/** Optional custom secure fs module (for dependency injection) */
|
||||
fsModule?: ContextFsModule;
|
||||
/** Whether to include context files from .automaker/context/ (default: true) */
|
||||
includeContextFiles?: boolean;
|
||||
/** Whether to include memory files from .automaker/memory/ (default: true) */
|
||||
includeMemory?: boolean;
|
||||
/** Whether to initialize memory folder if it doesn't exist (default: true) */
|
||||
@@ -210,6 +212,7 @@ export async function loadContextFiles(
|
||||
const {
|
||||
projectPath,
|
||||
fsModule = secureFs,
|
||||
includeContextFiles = true,
|
||||
includeMemory = true,
|
||||
initializeMemory = true,
|
||||
taskContext,
|
||||
@@ -220,42 +223,44 @@ export async function loadContextFiles(
|
||||
const files: ContextFileInfo[] = [];
|
||||
const memoryFiles: MemoryFileInfo[] = [];
|
||||
|
||||
// Load context files
|
||||
try {
|
||||
// Check if directory exists
|
||||
await fsModule.access(contextDir);
|
||||
// Load context files if enabled
|
||||
if (includeContextFiles) {
|
||||
try {
|
||||
// Check if directory exists
|
||||
await fsModule.access(contextDir);
|
||||
|
||||
// Read directory contents
|
||||
const allFiles = await fsModule.readdir(contextDir);
|
||||
// Read directory contents
|
||||
const allFiles = await fsModule.readdir(contextDir);
|
||||
|
||||
// Filter for text-based context files (case-insensitive for cross-platform)
|
||||
const textFiles = allFiles.filter((f) => {
|
||||
const lower = f.toLowerCase();
|
||||
return (lower.endsWith('.md') || lower.endsWith('.txt')) && f !== 'context-metadata.json';
|
||||
});
|
||||
// Filter for text-based context files (case-insensitive for cross-platform)
|
||||
const textFiles = allFiles.filter((f) => {
|
||||
const lower = f.toLowerCase();
|
||||
return (lower.endsWith('.md') || lower.endsWith('.txt')) && f !== 'context-metadata.json';
|
||||
});
|
||||
|
||||
if (textFiles.length > 0) {
|
||||
// Load metadata for descriptions
|
||||
const metadata = await loadContextMetadata(contextDir, fsModule);
|
||||
if (textFiles.length > 0) {
|
||||
// Load metadata for descriptions
|
||||
const metadata = await loadContextMetadata(contextDir, fsModule);
|
||||
|
||||
// Load each file with its content and metadata
|
||||
for (const fileName of textFiles) {
|
||||
const filePath = path.join(contextDir, fileName);
|
||||
try {
|
||||
const content = await fsModule.readFile(filePath, 'utf-8');
|
||||
files.push({
|
||||
name: fileName,
|
||||
path: filePath,
|
||||
content: content as string,
|
||||
description: metadata.files[fileName]?.description,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[ContextLoader] Failed to read context file ${fileName}:`, error);
|
||||
// Load each file with its content and metadata
|
||||
for (const fileName of textFiles) {
|
||||
const filePath = path.join(contextDir, fileName);
|
||||
try {
|
||||
const content = await fsModule.readFile(filePath, 'utf-8');
|
||||
files.push({
|
||||
name: fileName,
|
||||
path: filePath,
|
||||
content: content as string,
|
||||
description: metadata.files[fileName]?.description,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[ContextLoader] Failed to read context file ${fileName}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Context directory doesn't exist or is inaccessible - that's fine
|
||||
}
|
||||
} catch {
|
||||
// Context directory doesn't exist or is inaccessible - that's fine
|
||||
}
|
||||
|
||||
// Load memory files if enabled (with smart selection)
|
||||
|
||||
Reference in New Issue
Block a user