mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
Merge remote-tracking branch 'origin/v0.14.0rc' into feature/v0.14.0rc-1768981415660-tt2v
# Conflicts: # apps/ui/src/components/views/project-settings-view/config/navigation.ts # apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/server",
|
"name": "@automaker/server",
|
||||||
"version": "0.12.0",
|
"version": "0.13.0",
|
||||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||||
"author": "AutoMaker Team",
|
"author": "AutoMaker Team",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ import { createNotificationsRoutes } from './routes/notifications/index.js';
|
|||||||
import { getNotificationService } from './services/notification-service.js';
|
import { getNotificationService } from './services/notification-service.js';
|
||||||
import { createEventHistoryRoutes } from './routes/event-history/index.js';
|
import { createEventHistoryRoutes } from './routes/event-history/index.js';
|
||||||
import { getEventHistoryService } from './services/event-history-service.js';
|
import { getEventHistoryService } from './services/event-history-service.js';
|
||||||
|
import { getTestRunnerService } from './services/test-runner-service.js';
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -248,6 +249,10 @@ notificationService.setEventEmitter(events);
|
|||||||
// Initialize Event History Service
|
// Initialize Event History Service
|
||||||
const eventHistoryService = getEventHistoryService();
|
const eventHistoryService = getEventHistoryService();
|
||||||
|
|
||||||
|
// Initialize Test Runner Service with event emitter for real-time test output streaming
|
||||||
|
const testRunnerService = getTestRunnerService();
|
||||||
|
testRunnerService.setEventEmitter(events);
|
||||||
|
|
||||||
// Initialize Event Hook Service for custom event triggers (with history storage)
|
// Initialize Event Hook Service for custom event triggers (with history storage)
|
||||||
eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader);
|
eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ export type {
|
|||||||
ProviderMessage,
|
ProviderMessage,
|
||||||
InstallationStatus,
|
InstallationStatus,
|
||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
|
AgentDefinition,
|
||||||
|
ReasoningEffort,
|
||||||
|
SystemPromptPreset,
|
||||||
|
ConversationMessage,
|
||||||
|
ContentBlock,
|
||||||
|
ValidationResult,
|
||||||
|
McpServerConfig,
|
||||||
|
McpStdioServerConfig,
|
||||||
|
McpSSEServerConfig,
|
||||||
|
McpHttpServerConfig,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
// Claude provider
|
// Claude provider
|
||||||
|
|||||||
@@ -19,4 +19,7 @@ export type {
|
|||||||
InstallationStatus,
|
InstallationStatus,
|
||||||
ValidationResult,
|
ValidationResult,
|
||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
|
AgentDefinition,
|
||||||
|
ReasoningEffort,
|
||||||
|
SystemPromptPreset,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ import { createStartDevHandler } from './routes/start-dev.js';
|
|||||||
import { createStopDevHandler } from './routes/stop-dev.js';
|
import { createStopDevHandler } from './routes/stop-dev.js';
|
||||||
import { createListDevServersHandler } from './routes/list-dev-servers.js';
|
import { createListDevServersHandler } from './routes/list-dev-servers.js';
|
||||||
import { createGetDevServerLogsHandler } from './routes/dev-server-logs.js';
|
import { createGetDevServerLogsHandler } from './routes/dev-server-logs.js';
|
||||||
|
import { createStartTestsHandler } from './routes/start-tests.js';
|
||||||
|
import { createStopTestsHandler } from './routes/stop-tests.js';
|
||||||
|
import { createGetTestLogsHandler } from './routes/test-logs.js';
|
||||||
import {
|
import {
|
||||||
createGetInitScriptHandler,
|
createGetInitScriptHandler,
|
||||||
createPutInitScriptHandler,
|
createPutInitScriptHandler,
|
||||||
@@ -140,6 +143,15 @@ export function createWorktreeRoutes(
|
|||||||
createGetDevServerLogsHandler()
|
createGetDevServerLogsHandler()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Test runner routes
|
||||||
|
router.post(
|
||||||
|
'/start-tests',
|
||||||
|
validatePathParams('worktreePath', 'projectPath?'),
|
||||||
|
createStartTestsHandler(settingsService)
|
||||||
|
);
|
||||||
|
router.post('/stop-tests', createStopTestsHandler());
|
||||||
|
router.get('/test-logs', validatePathParams('worktreePath?'), createGetTestLogsHandler());
|
||||||
|
|
||||||
// Init script routes
|
// Init script routes
|
||||||
router.get('/init-script', createGetInitScriptHandler());
|
router.get('/init-script', createGetInitScriptHandler());
|
||||||
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
|
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
|
||||||
|
|||||||
92
apps/server/src/routes/worktree/routes/start-tests.ts
Normal file
92
apps/server/src/routes/worktree/routes/start-tests.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* POST /start-tests endpoint - Start tests for a worktree
|
||||||
|
*
|
||||||
|
* Runs the test command configured in project settings.
|
||||||
|
* If no testCommand is configured, returns an error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import type { SettingsService } from '../../../services/settings-service.js';
|
||||||
|
import { getTestRunnerService } from '../../../services/test-runner-service.js';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
export function createStartTestsHandler(settingsService?: SettingsService) {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const body = req.body;
|
||||||
|
|
||||||
|
// Validate request body
|
||||||
|
if (!body || typeof body !== 'object') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Request body must be an object',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const worktreePath = typeof body.worktreePath === 'string' ? body.worktreePath : undefined;
|
||||||
|
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : undefined;
|
||||||
|
const testFile = typeof body.testFile === 'string' ? body.testFile : undefined;
|
||||||
|
|
||||||
|
if (!worktreePath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'worktreePath is required and must be a string',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get project settings to find the test command
|
||||||
|
// Use projectPath if provided, otherwise use worktreePath
|
||||||
|
const settingsPath = projectPath || worktreePath;
|
||||||
|
|
||||||
|
if (!settingsService) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Settings service not available',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectSettings = await settingsService.getProjectSettings(settingsPath);
|
||||||
|
const testCommand = projectSettings?.testCommand;
|
||||||
|
|
||||||
|
if (!testCommand) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'No test command configured. Please configure a test command in Project Settings > Testing Configuration.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testRunnerService = getTestRunnerService();
|
||||||
|
const result = await testRunnerService.startTests(worktreePath, {
|
||||||
|
command: testCommand,
|
||||||
|
testFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
sessionId: result.result.sessionId,
|
||||||
|
worktreePath: result.result.worktreePath,
|
||||||
|
command: result.result.command,
|
||||||
|
status: result.result.status,
|
||||||
|
testFile: result.result.testFile,
|
||||||
|
message: result.result.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: result.error || 'Failed to start tests',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Start tests failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
58
apps/server/src/routes/worktree/routes/stop-tests.ts
Normal file
58
apps/server/src/routes/worktree/routes/stop-tests.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* POST /stop-tests endpoint - Stop a running test session
|
||||||
|
*
|
||||||
|
* Stops the test runner process for a specific session,
|
||||||
|
* cancelling any ongoing tests and freeing up resources.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { getTestRunnerService } from '../../../services/test-runner-service.js';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
export function createStopTestsHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const body = req.body;
|
||||||
|
|
||||||
|
// Validate request body
|
||||||
|
if (!body || typeof body !== 'object') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Request body must be an object',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'sessionId is required and must be a string',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testRunnerService = getTestRunnerService();
|
||||||
|
const result = await testRunnerService.stopTests(sessionId);
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
sessionId: result.result.sessionId,
|
||||||
|
message: result.result.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: result.error || 'Failed to stop tests',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Stop tests failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
160
apps/server/src/routes/worktree/routes/test-logs.ts
Normal file
160
apps/server/src/routes/worktree/routes/test-logs.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* GET /test-logs endpoint - Get buffered logs for a test runner session
|
||||||
|
*
|
||||||
|
* Returns the scrollback buffer containing historical log output for a test run.
|
||||||
|
* Used by clients to populate the log panel on initial connection
|
||||||
|
* before subscribing to real-time updates via WebSocket.
|
||||||
|
*
|
||||||
|
* Query parameters:
|
||||||
|
* - worktreePath: Path to the worktree (optional if sessionId provided)
|
||||||
|
* - sessionId: Specific test session ID (optional, uses active session if not provided)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { getTestRunnerService } from '../../../services/test-runner-service.js';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
interface SessionInfo {
|
||||||
|
sessionId: string;
|
||||||
|
worktreePath?: string;
|
||||||
|
command?: string;
|
||||||
|
testFile?: string;
|
||||||
|
exitCode?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OutputResult {
|
||||||
|
sessionId: string;
|
||||||
|
status: string;
|
||||||
|
output: string;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLogsResponse(session: SessionInfo, output: OutputResult) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
worktreePath: session.worktreePath,
|
||||||
|
command: session.command,
|
||||||
|
status: output.status,
|
||||||
|
testFile: session.testFile,
|
||||||
|
logs: output.output,
|
||||||
|
startedAt: output.startedAt,
|
||||||
|
finishedAt: output.finishedAt,
|
||||||
|
exitCode: session.exitCode ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGetTestLogsHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { worktreePath, sessionId } = req.query as {
|
||||||
|
worktreePath?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const testRunnerService = getTestRunnerService();
|
||||||
|
|
||||||
|
// If sessionId is provided, get logs for that specific session
|
||||||
|
if (sessionId) {
|
||||||
|
const result = testRunnerService.getSessionOutput(sessionId);
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
const session = testRunnerService.getSession(sessionId);
|
||||||
|
res.json(
|
||||||
|
buildLogsResponse(
|
||||||
|
{
|
||||||
|
sessionId: result.result.sessionId,
|
||||||
|
worktreePath: session?.worktreePath,
|
||||||
|
command: session?.command,
|
||||||
|
testFile: session?.testFile,
|
||||||
|
exitCode: session?.exitCode,
|
||||||
|
},
|
||||||
|
result.result
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: result.error || 'Failed to get test logs',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If worktreePath is provided, get logs for the active session
|
||||||
|
if (worktreePath) {
|
||||||
|
const activeSession = testRunnerService.getActiveSession(worktreePath);
|
||||||
|
|
||||||
|
if (activeSession) {
|
||||||
|
const result = testRunnerService.getSessionOutput(activeSession.id);
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
res.json(
|
||||||
|
buildLogsResponse(
|
||||||
|
{
|
||||||
|
sessionId: activeSession.id,
|
||||||
|
worktreePath: activeSession.worktreePath,
|
||||||
|
command: activeSession.command,
|
||||||
|
testFile: activeSession.testFile,
|
||||||
|
exitCode: activeSession.exitCode,
|
||||||
|
},
|
||||||
|
result.result
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: result.error || 'Failed to get test logs',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No active session - check for most recent session for this worktree
|
||||||
|
const sessions = testRunnerService.listSessions(worktreePath);
|
||||||
|
if (sessions.result.sessions.length > 0) {
|
||||||
|
// Get the most recent session (list is not sorted, so find it)
|
||||||
|
const mostRecent = sessions.result.sessions.reduce((latest, current) => {
|
||||||
|
const latestTime = new Date(latest.startedAt).getTime();
|
||||||
|
const currentTime = new Date(current.startedAt).getTime();
|
||||||
|
return currentTime > latestTime ? current : latest;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = testRunnerService.getSessionOutput(mostRecent.sessionId);
|
||||||
|
if (result.success && result.result) {
|
||||||
|
res.json(
|
||||||
|
buildLogsResponse(
|
||||||
|
{
|
||||||
|
sessionId: mostRecent.sessionId,
|
||||||
|
worktreePath: mostRecent.worktreePath,
|
||||||
|
command: mostRecent.command,
|
||||||
|
testFile: mostRecent.testFile,
|
||||||
|
exitCode: mostRecent.exitCode,
|
||||||
|
},
|
||||||
|
result.result
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'No test sessions found for this worktree',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither sessionId nor worktreePath provided
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Either worktreePath or sessionId query parameter is required',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Get test logs failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1281,7 +1281,11 @@ export class AutoModeService {
|
|||||||
|
|
||||||
// Check for pipeline steps and execute them
|
// Check for pipeline steps and execute them
|
||||||
const pipelineConfig = await pipelineService.getPipelineConfig(projectPath);
|
const pipelineConfig = await pipelineService.getPipelineConfig(projectPath);
|
||||||
const sortedSteps = [...(pipelineConfig?.steps || [])].sort((a, b) => a.order - b.order);
|
// Filter out excluded pipeline steps and sort by order
|
||||||
|
const excludedStepIds = new Set(feature.excludedPipelineSteps || []);
|
||||||
|
const sortedSteps = [...(pipelineConfig?.steps || [])]
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.filter((step) => !excludedStepIds.has(step.id));
|
||||||
|
|
||||||
if (sortedSteps.length > 0) {
|
if (sortedSteps.length > 0) {
|
||||||
// Execute pipeline steps sequentially
|
// Execute pipeline steps sequentially
|
||||||
@@ -1743,15 +1747,76 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const featureId = feature.id;
|
const featureId = feature.id;
|
||||||
|
|
||||||
const sortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order);
|
// Sort all steps first
|
||||||
|
const allSortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
// Validate step index
|
// Get the current step we're resuming from (using the index from unfiltered list)
|
||||||
if (startFromStepIndex < 0 || startFromStepIndex >= sortedSteps.length) {
|
if (startFromStepIndex < 0 || startFromStepIndex >= allSortedSteps.length) {
|
||||||
throw new Error(`Invalid step index: ${startFromStepIndex}`);
|
throw new Error(`Invalid step index: ${startFromStepIndex}`);
|
||||||
}
|
}
|
||||||
|
const currentStep = allSortedSteps[startFromStepIndex];
|
||||||
|
|
||||||
// Get steps to execute (from startFromStepIndex onwards)
|
// Filter out excluded pipeline steps
|
||||||
const stepsToExecute = sortedSteps.slice(startFromStepIndex);
|
const excludedStepIds = new Set(feature.excludedPipelineSteps || []);
|
||||||
|
|
||||||
|
// Check if the current step is excluded
|
||||||
|
// If so, use getNextStatus to find the appropriate next step
|
||||||
|
if (excludedStepIds.has(currentStep.id)) {
|
||||||
|
console.log(
|
||||||
|
`[AutoMode] Current step ${currentStep.id} is excluded for feature ${featureId}, finding next valid step`
|
||||||
|
);
|
||||||
|
const nextStatus = pipelineService.getNextStatus(
|
||||||
|
`pipeline_${currentStep.id}`,
|
||||||
|
pipelineConfig,
|
||||||
|
feature.skipTests ?? false,
|
||||||
|
feature.excludedPipelineSteps
|
||||||
|
);
|
||||||
|
|
||||||
|
// If next status is not a pipeline step, feature is done
|
||||||
|
if (!pipelineService.isPipelineStatus(nextStatus)) {
|
||||||
|
await this.updateFeatureStatus(projectPath, featureId, nextStatus);
|
||||||
|
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
|
featureId,
|
||||||
|
featureName: feature.title,
|
||||||
|
branchName: feature.branchName ?? null,
|
||||||
|
passes: true,
|
||||||
|
message: 'Pipeline completed (remaining steps excluded)',
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the next step and update the start index
|
||||||
|
const nextStepId = pipelineService.getStepIdFromStatus(nextStatus);
|
||||||
|
const nextStepIndex = allSortedSteps.findIndex((s) => s.id === nextStepId);
|
||||||
|
if (nextStepIndex === -1) {
|
||||||
|
throw new Error(`Next step ${nextStepId} not found in pipeline config`);
|
||||||
|
}
|
||||||
|
startFromStepIndex = nextStepIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get steps to execute (from startFromStepIndex onwards, excluding excluded steps)
|
||||||
|
const stepsToExecute = allSortedSteps
|
||||||
|
.slice(startFromStepIndex)
|
||||||
|
.filter((step) => !excludedStepIds.has(step.id));
|
||||||
|
|
||||||
|
// If no steps left to execute, complete the feature
|
||||||
|
if (stepsToExecute.length === 0) {
|
||||||
|
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||||
|
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
|
||||||
|
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
|
featureId,
|
||||||
|
featureName: feature.title,
|
||||||
|
branchName: feature.branchName ?? null,
|
||||||
|
passes: true,
|
||||||
|
message: 'Pipeline completed (all remaining steps excluded)',
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the filtered steps for counting
|
||||||
|
const sortedSteps = allSortedSteps.filter((step) => !excludedStepIds.has(step.id));
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}`
|
`[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}`
|
||||||
|
|||||||
@@ -234,51 +234,75 @@ export class PipelineService {
|
|||||||
*
|
*
|
||||||
* Determines what status a feature should transition to based on current status.
|
* Determines what status a feature should transition to based on current status.
|
||||||
* Flow: in_progress -> pipeline_step_0 -> pipeline_step_1 -> ... -> final status
|
* Flow: in_progress -> pipeline_step_0 -> pipeline_step_1 -> ... -> final status
|
||||||
|
* Steps in the excludedStepIds array will be skipped.
|
||||||
*
|
*
|
||||||
* @param currentStatus - Current feature status
|
* @param currentStatus - Current feature status
|
||||||
* @param config - Pipeline configuration (or null if no pipeline)
|
* @param config - Pipeline configuration (or null if no pipeline)
|
||||||
* @param skipTests - Whether to skip tests (affects final status)
|
* @param skipTests - Whether to skip tests (affects final status)
|
||||||
|
* @param excludedStepIds - Optional array of step IDs to skip
|
||||||
* @returns The next status in the pipeline flow
|
* @returns The next status in the pipeline flow
|
||||||
*/
|
*/
|
||||||
getNextStatus(
|
getNextStatus(
|
||||||
currentStatus: FeatureStatusWithPipeline,
|
currentStatus: FeatureStatusWithPipeline,
|
||||||
config: PipelineConfig | null,
|
config: PipelineConfig | null,
|
||||||
skipTests: boolean
|
skipTests: boolean,
|
||||||
|
excludedStepIds?: string[]
|
||||||
): FeatureStatusWithPipeline {
|
): FeatureStatusWithPipeline {
|
||||||
const steps = config?.steps || [];
|
const steps = config?.steps || [];
|
||||||
|
const exclusions = new Set(excludedStepIds || []);
|
||||||
|
|
||||||
// Sort steps by order
|
// Sort steps by order and filter out excluded steps
|
||||||
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
const sortedSteps = [...steps]
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.filter((step) => !exclusions.has(step.id));
|
||||||
|
|
||||||
// If no pipeline steps, use original logic
|
// If no pipeline steps (or all excluded), use original logic
|
||||||
if (sortedSteps.length === 0) {
|
if (sortedSteps.length === 0) {
|
||||||
if (currentStatus === 'in_progress') {
|
// If coming from in_progress or already in a pipeline step, go to final status
|
||||||
|
if (currentStatus === 'in_progress' || currentStatus.startsWith('pipeline_')) {
|
||||||
return skipTests ? 'waiting_approval' : 'verified';
|
return skipTests ? 'waiting_approval' : 'verified';
|
||||||
}
|
}
|
||||||
return currentStatus;
|
return currentStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coming from in_progress -> go to first pipeline step
|
// Coming from in_progress -> go to first non-excluded pipeline step
|
||||||
if (currentStatus === 'in_progress') {
|
if (currentStatus === 'in_progress') {
|
||||||
return `pipeline_${sortedSteps[0].id}`;
|
return `pipeline_${sortedSteps[0].id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coming from a pipeline step -> go to next step or final status
|
// Coming from a pipeline step -> go to next non-excluded step or final status
|
||||||
if (currentStatus.startsWith('pipeline_')) {
|
if (currentStatus.startsWith('pipeline_')) {
|
||||||
const currentStepId = currentStatus.replace('pipeline_', '');
|
const currentStepId = currentStatus.replace('pipeline_', '');
|
||||||
const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId);
|
const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId);
|
||||||
|
|
||||||
if (currentIndex === -1) {
|
if (currentIndex === -1) {
|
||||||
// Step not found, go to final status
|
// Current step not found in filtered list (might be excluded or invalid)
|
||||||
|
// Find next valid step after this one from the original sorted list
|
||||||
|
const allSortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
||||||
|
const originalIndex = allSortedSteps.findIndex((s) => s.id === currentStepId);
|
||||||
|
|
||||||
|
if (originalIndex === -1) {
|
||||||
|
// Step truly doesn't exist, go to final status
|
||||||
|
return skipTests ? 'waiting_approval' : 'verified';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the next non-excluded step after the current one
|
||||||
|
for (let i = originalIndex + 1; i < allSortedSteps.length; i++) {
|
||||||
|
if (!exclusions.has(allSortedSteps[i].id)) {
|
||||||
|
return `pipeline_${allSortedSteps[i].id}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No more non-excluded steps, go to final status
|
||||||
return skipTests ? 'waiting_approval' : 'verified';
|
return skipTests ? 'waiting_approval' : 'verified';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentIndex < sortedSteps.length - 1) {
|
if (currentIndex < sortedSteps.length - 1) {
|
||||||
// Go to next step
|
// Go to next non-excluded step
|
||||||
return `pipeline_${sortedSteps[currentIndex + 1].id}`;
|
return `pipeline_${sortedSteps[currentIndex + 1].id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last step completed, go to final status
|
// Last non-excluded step completed, go to final status
|
||||||
return skipTests ? 'waiting_approval' : 'verified';
|
return skipTests ? 'waiting_approval' : 'verified';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -827,6 +827,16 @@ export class SettingsService {
|
|||||||
delete updated.phaseModelOverrides;
|
delete updated.phaseModelOverrides;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle defaultFeatureModel special cases:
|
||||||
|
// - "__CLEAR__" marker means delete the key (use global setting)
|
||||||
|
// - object means project-specific override
|
||||||
|
if (
|
||||||
|
'defaultFeatureModel' in updates &&
|
||||||
|
(updates as Record<string, unknown>).defaultFeatureModel === '__CLEAR__'
|
||||||
|
) {
|
||||||
|
delete updated.defaultFeatureModel;
|
||||||
|
}
|
||||||
|
|
||||||
await writeSettingsJson(settingsPath, updated);
|
await writeSettingsJson(settingsPath, updated);
|
||||||
logger.info(`Project settings updated for ${projectPath}`);
|
logger.info(`Project settings updated for ${projectPath}`);
|
||||||
|
|
||||||
|
|||||||
682
apps/server/src/services/test-runner-service.ts
Normal file
682
apps/server/src/services/test-runner-service.ts
Normal file
@@ -0,0 +1,682 @@
|
|||||||
|
/**
|
||||||
|
* Test Runner Service
|
||||||
|
*
|
||||||
|
* Manages test execution processes for git worktrees.
|
||||||
|
* Runs user-configured test commands with output streaming.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Process management with graceful shutdown
|
||||||
|
* - Output buffering and throttling for WebSocket streaming
|
||||||
|
* - Support for running all tests or specific files
|
||||||
|
* - Cross-platform process cleanup (Windows/Unix)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn, execSync, type ChildProcess } from 'child_process';
|
||||||
|
import * as secureFs from '../lib/secure-fs.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import type { EventEmitter } from '../lib/events.js';
|
||||||
|
|
||||||
|
const logger = createLogger('TestRunnerService');
|
||||||
|
|
||||||
|
// Maximum scrollback buffer size (characters)
|
||||||
|
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per test run
|
||||||
|
|
||||||
|
// Throttle output to prevent overwhelming WebSocket under heavy load
|
||||||
|
// Note: Too aggressive throttling (< 50ms) can cause memory issues and UI crashes
|
||||||
|
// due to rapid React state updates and string concatenation overhead
|
||||||
|
const OUTPUT_THROTTLE_MS = 100; // ~10fps - balances responsiveness with stability
|
||||||
|
const OUTPUT_BATCH_SIZE = 8192; // Larger batch size to reduce event frequency
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status of a test run
|
||||||
|
*/
|
||||||
|
export type TestRunStatus = 'pending' | 'running' | 'passed' | 'failed' | 'cancelled' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Information about an active test run session
|
||||||
|
*/
|
||||||
|
export interface TestRunSession {
|
||||||
|
/** Unique identifier for this test run */
|
||||||
|
id: string;
|
||||||
|
/** Path to the worktree where tests are running */
|
||||||
|
worktreePath: string;
|
||||||
|
/** The command being run */
|
||||||
|
command: string;
|
||||||
|
/** The spawned child process */
|
||||||
|
process: ChildProcess | null;
|
||||||
|
/** When the test run started */
|
||||||
|
startedAt: Date;
|
||||||
|
/** When the test run finished (if completed) */
|
||||||
|
finishedAt: Date | null;
|
||||||
|
/** Current status of the test run */
|
||||||
|
status: TestRunStatus;
|
||||||
|
/** Exit code from the process (if completed) */
|
||||||
|
exitCode: number | null;
|
||||||
|
/** Specific test file being run (optional) */
|
||||||
|
testFile?: string;
|
||||||
|
/** Scrollback buffer for log history (replay on reconnect) */
|
||||||
|
scrollbackBuffer: string;
|
||||||
|
/** Pending output to be flushed to subscribers */
|
||||||
|
outputBuffer: string;
|
||||||
|
/** Throttle timer for batching output */
|
||||||
|
flushTimeout: NodeJS.Timeout | null;
|
||||||
|
/** Flag to indicate session is stopping (prevents output after stop) */
|
||||||
|
stopping: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a test run operation
|
||||||
|
*/
|
||||||
|
export interface TestRunResult {
|
||||||
|
success: boolean;
|
||||||
|
result?: {
|
||||||
|
sessionId: string;
|
||||||
|
worktreePath: string;
|
||||||
|
command: string;
|
||||||
|
status: TestRunStatus;
|
||||||
|
testFile?: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Runner Service class
|
||||||
|
* Manages test execution processes across worktrees
|
||||||
|
*/
|
||||||
|
class TestRunnerService {
|
||||||
|
private sessions: Map<string, TestRunSession> = new Map();
|
||||||
|
private emitter: EventEmitter | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the event emitter for streaming log events
|
||||||
|
* Called during service initialization with the global event emitter
|
||||||
|
*/
|
||||||
|
setEventEmitter(emitter: EventEmitter): void {
|
||||||
|
this.emitter = emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to check if a file exists using secureFs
|
||||||
|
*/
|
||||||
|
private async fileExists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await secureFs.access(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append data to scrollback buffer with size limit enforcement
|
||||||
|
* Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE
|
||||||
|
*/
|
||||||
|
private appendToScrollback(session: TestRunSession, data: string): void {
|
||||||
|
session.scrollbackBuffer += data;
|
||||||
|
if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) {
|
||||||
|
session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush buffered output to WebSocket subscribers
|
||||||
|
* Sends batched output to prevent overwhelming clients under heavy load
|
||||||
|
*/
|
||||||
|
private flushOutput(session: TestRunSession): void {
|
||||||
|
// Skip flush if session is stopping or buffer is empty
|
||||||
|
if (session.stopping || session.outputBuffer.length === 0) {
|
||||||
|
session.flushTimeout = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dataToSend = session.outputBuffer;
|
||||||
|
if (dataToSend.length > OUTPUT_BATCH_SIZE) {
|
||||||
|
// Send in batches if buffer is large
|
||||||
|
dataToSend = session.outputBuffer.slice(0, OUTPUT_BATCH_SIZE);
|
||||||
|
session.outputBuffer = session.outputBuffer.slice(OUTPUT_BATCH_SIZE);
|
||||||
|
// Schedule another flush for remaining data
|
||||||
|
session.flushTimeout = setTimeout(() => this.flushOutput(session), OUTPUT_THROTTLE_MS);
|
||||||
|
} else {
|
||||||
|
session.outputBuffer = '';
|
||||||
|
session.flushTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit output event for WebSocket streaming
|
||||||
|
if (this.emitter) {
|
||||||
|
this.emitter.emit('test-runner:output', {
|
||||||
|
sessionId: session.id,
|
||||||
|
worktreePath: session.worktreePath,
|
||||||
|
content: dataToSend,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming stdout/stderr data from test process
|
||||||
|
* Buffers data for scrollback replay and schedules throttled emission
|
||||||
|
*/
|
||||||
|
private handleProcessOutput(session: TestRunSession, data: Buffer): void {
|
||||||
|
// Skip output if session is stopping
|
||||||
|
if (session.stopping) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = data.toString();
|
||||||
|
|
||||||
|
// Append to scrollback buffer for replay on reconnect
|
||||||
|
this.appendToScrollback(session, content);
|
||||||
|
|
||||||
|
// Buffer output for throttled live delivery
|
||||||
|
session.outputBuffer += content;
|
||||||
|
|
||||||
|
// Schedule flush if not already scheduled
|
||||||
|
if (!session.flushTimeout) {
|
||||||
|
session.flushTimeout = setTimeout(() => this.flushOutput(session), OUTPUT_THROTTLE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also log for debugging (existing behavior)
|
||||||
|
logger.debug(`[${session.id}] ${content.trim()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill any process running (platform-specific cleanup)
|
||||||
|
*/
|
||||||
|
private killProcessTree(pid: number): void {
|
||||||
|
try {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
// Windows: use taskkill to kill process tree
|
||||||
|
execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore' });
|
||||||
|
} else {
|
||||||
|
// Unix: kill the process group
|
||||||
|
try {
|
||||||
|
process.kill(-pid, 'SIGTERM');
|
||||||
|
} catch {
|
||||||
|
// Fallback to killing just the process
|
||||||
|
process.kill(pid, 'SIGTERM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`Error killing process ${pid}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique session ID
|
||||||
|
*/
|
||||||
|
private generateSessionId(): string {
|
||||||
|
return `test-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a test file path to prevent command injection
|
||||||
|
* Allows only safe characters for file paths
|
||||||
|
*/
|
||||||
|
private sanitizeTestFile(testFile: string): string {
|
||||||
|
// Remove any shell metacharacters and normalize path
|
||||||
|
// Allow only alphanumeric, dots, slashes, hyphens, underscores, colons (for Windows paths)
|
||||||
|
return testFile.replace(/[^a-zA-Z0-9.\\/_\-:]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start tests in a worktree using the provided command
|
||||||
|
*
|
||||||
|
* @param worktreePath - Path to the worktree where tests should run
|
||||||
|
* @param options - Configuration for the test run
|
||||||
|
* @returns TestRunResult with session info or error
|
||||||
|
*/
|
||||||
|
async startTests(
|
||||||
|
worktreePath: string,
|
||||||
|
options: {
|
||||||
|
command: string;
|
||||||
|
testFile?: string;
|
||||||
|
}
|
||||||
|
): Promise<TestRunResult> {
|
||||||
|
const { command, testFile } = options;
|
||||||
|
|
||||||
|
// Check if already running
|
||||||
|
const existingSession = this.getActiveSession(worktreePath);
|
||||||
|
if (existingSession) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Tests are already running for this worktree (session: ${existingSession.id})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the worktree exists
|
||||||
|
if (!(await this.fileExists(worktreePath))) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Worktree path does not exist: ${worktreePath}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No test command provided',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the final command (append test file if specified)
|
||||||
|
let finalCommand = command;
|
||||||
|
if (testFile) {
|
||||||
|
// Sanitize test file path to prevent command injection
|
||||||
|
const sanitizedFile = this.sanitizeTestFile(testFile);
|
||||||
|
// Append the test file to the command
|
||||||
|
// Most test runners support: command -- file or command file
|
||||||
|
finalCommand = `${command} -- ${sanitizedFile}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse command into cmd and args (shell execution)
|
||||||
|
// We use shell: true to support complex commands like "npm run test:server"
|
||||||
|
logger.info(`Starting tests in ${worktreePath}`);
|
||||||
|
logger.info(`Command: ${finalCommand}`);
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const sessionId = this.generateSessionId();
|
||||||
|
const session: TestRunSession = {
|
||||||
|
id: sessionId,
|
||||||
|
worktreePath,
|
||||||
|
command: finalCommand,
|
||||||
|
process: null,
|
||||||
|
startedAt: new Date(),
|
||||||
|
finishedAt: null,
|
||||||
|
status: 'pending',
|
||||||
|
exitCode: null,
|
||||||
|
testFile,
|
||||||
|
scrollbackBuffer: '',
|
||||||
|
outputBuffer: '',
|
||||||
|
flushTimeout: null,
|
||||||
|
stopping: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spawn the test process using shell
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
FORCE_COLOR: '1',
|
||||||
|
COLORTERM: 'truecolor',
|
||||||
|
TERM: 'xterm-256color',
|
||||||
|
CI: 'true', // Helps some test runners format output better
|
||||||
|
};
|
||||||
|
|
||||||
|
const testProcess = spawn(finalCommand, [], {
|
||||||
|
cwd: worktreePath,
|
||||||
|
env,
|
||||||
|
shell: true,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached: process.platform !== 'win32', // Use process groups on Unix for cleanup
|
||||||
|
});
|
||||||
|
|
||||||
|
session.process = testProcess;
|
||||||
|
session.status = 'running';
|
||||||
|
|
||||||
|
// Track if process failed early
|
||||||
|
const status = { error: null as string | null, exited: false };
|
||||||
|
|
||||||
|
// Helper to clean up resources and emit events
|
||||||
|
const cleanupAndFinish = (
|
||||||
|
exitCode: number | null,
|
||||||
|
finalStatus: TestRunStatus,
|
||||||
|
errorMessage?: string
|
||||||
|
) => {
|
||||||
|
session.finishedAt = new Date();
|
||||||
|
session.exitCode = exitCode;
|
||||||
|
session.status = finalStatus;
|
||||||
|
|
||||||
|
if (session.flushTimeout) {
|
||||||
|
clearTimeout(session.flushTimeout);
|
||||||
|
session.flushTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush any remaining output
|
||||||
|
if (session.outputBuffer.length > 0 && this.emitter && !session.stopping) {
|
||||||
|
this.emitter.emit('test-runner:output', {
|
||||||
|
sessionId: session.id,
|
||||||
|
worktreePath: session.worktreePath,
|
||||||
|
content: session.outputBuffer,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
session.outputBuffer = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit completed event
|
||||||
|
if (this.emitter && !session.stopping) {
|
||||||
|
this.emitter.emit('test-runner:completed', {
|
||||||
|
sessionId: session.id,
|
||||||
|
worktreePath: session.worktreePath,
|
||||||
|
command: session.command,
|
||||||
|
status: finalStatus,
|
||||||
|
exitCode,
|
||||||
|
error: errorMessage,
|
||||||
|
duration: session.finishedAt.getTime() - session.startedAt.getTime(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Capture stdout
|
||||||
|
if (testProcess.stdout) {
|
||||||
|
testProcess.stdout.on('data', (data: Buffer) => {
|
||||||
|
this.handleProcessOutput(session, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture stderr
|
||||||
|
if (testProcess.stderr) {
|
||||||
|
testProcess.stderr.on('data', (data: Buffer) => {
|
||||||
|
this.handleProcessOutput(session, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testProcess.on('error', (error) => {
|
||||||
|
logger.error(`Process error for ${sessionId}:`, error);
|
||||||
|
status.error = error.message;
|
||||||
|
cleanupAndFinish(null, 'error', error.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
testProcess.on('exit', (code) => {
|
||||||
|
logger.info(`Test process for ${worktreePath} exited with code ${code}`);
|
||||||
|
status.exited = true;
|
||||||
|
|
||||||
|
// Determine final status based on exit code
|
||||||
|
let finalStatus: TestRunStatus;
|
||||||
|
if (session.stopping) {
|
||||||
|
finalStatus = 'cancelled';
|
||||||
|
} else if (code === 0) {
|
||||||
|
finalStatus = 'passed';
|
||||||
|
} else {
|
||||||
|
finalStatus = 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupAndFinish(code, finalStatus);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store session
|
||||||
|
this.sessions.set(sessionId, session);
|
||||||
|
|
||||||
|
// Wait a moment to see if the process fails immediately
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
if (status.error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to start tests: ${status.error}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.exited) {
|
||||||
|
// Process already exited - check if it was immediate failure
|
||||||
|
const exitedSession = this.sessions.get(sessionId);
|
||||||
|
if (exitedSession && exitedSession.status === 'error') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Test process exited immediately. Check output for details.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit started event
|
||||||
|
if (this.emitter) {
|
||||||
|
this.emitter.emit('test-runner:started', {
|
||||||
|
sessionId,
|
||||||
|
worktreePath,
|
||||||
|
command: finalCommand,
|
||||||
|
testFile,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
sessionId,
|
||||||
|
worktreePath,
|
||||||
|
command: finalCommand,
|
||||||
|
status: 'running',
|
||||||
|
testFile,
|
||||||
|
message: `Tests started: ${finalCommand}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop a running test session
|
||||||
|
*
|
||||||
|
* @param sessionId - The ID of the test session to stop
|
||||||
|
* @returns Result with success status and message
|
||||||
|
*/
|
||||||
|
async stopTests(sessionId: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
result?: { sessionId: string; message: string };
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Test session not found: ${sessionId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status !== 'running') {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
sessionId,
|
||||||
|
message: `Tests already finished (status: ${session.status})`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Cancelling test session ${sessionId}`);
|
||||||
|
|
||||||
|
// Mark as stopping to prevent further output events
|
||||||
|
session.stopping = true;
|
||||||
|
|
||||||
|
// Clean up flush timeout
|
||||||
|
if (session.flushTimeout) {
|
||||||
|
clearTimeout(session.flushTimeout);
|
||||||
|
session.flushTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill the process
|
||||||
|
if (session.process && !session.process.killed && session.process.pid) {
|
||||||
|
this.killProcessTree(session.process.pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
session.status = 'cancelled';
|
||||||
|
session.finishedAt = new Date();
|
||||||
|
|
||||||
|
// Emit cancelled event
|
||||||
|
if (this.emitter) {
|
||||||
|
this.emitter.emit('test-runner:completed', {
|
||||||
|
sessionId,
|
||||||
|
worktreePath: session.worktreePath,
|
||||||
|
command: session.command,
|
||||||
|
status: 'cancelled',
|
||||||
|
exitCode: null,
|
||||||
|
duration: session.finishedAt.getTime() - session.startedAt.getTime(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
sessionId,
|
||||||
|
message: 'Test run cancelled',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the active test session for a worktree
|
||||||
|
*/
|
||||||
|
getActiveSession(worktreePath: string): TestRunSession | undefined {
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
if (session.worktreePath === worktreePath && session.status === 'running') {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a test session by ID
|
||||||
|
*/
|
||||||
|
getSession(sessionId: string): TestRunSession | undefined {
|
||||||
|
return this.sessions.get(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get buffered output for a test session
|
||||||
|
*/
|
||||||
|
getSessionOutput(sessionId: string): {
|
||||||
|
success: boolean;
|
||||||
|
result?: {
|
||||||
|
sessionId: string;
|
||||||
|
output: string;
|
||||||
|
status: TestRunStatus;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Test session not found: ${sessionId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
sessionId,
|
||||||
|
output: session.scrollbackBuffer,
|
||||||
|
status: session.status,
|
||||||
|
startedAt: session.startedAt.toISOString(),
|
||||||
|
finishedAt: session.finishedAt?.toISOString() || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all test sessions (optionally filter by worktree)
|
||||||
|
*/
|
||||||
|
listSessions(worktreePath?: string): {
|
||||||
|
success: boolean;
|
||||||
|
result: {
|
||||||
|
sessions: Array<{
|
||||||
|
sessionId: string;
|
||||||
|
worktreePath: string;
|
||||||
|
command: string;
|
||||||
|
status: TestRunStatus;
|
||||||
|
testFile?: string;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
exitCode: number | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
} {
|
||||||
|
let sessions = Array.from(this.sessions.values());
|
||||||
|
|
||||||
|
if (worktreePath) {
|
||||||
|
sessions = sessions.filter((s) => s.worktreePath === worktreePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
sessions: sessions.map((s) => ({
|
||||||
|
sessionId: s.id,
|
||||||
|
worktreePath: s.worktreePath,
|
||||||
|
command: s.command,
|
||||||
|
status: s.status,
|
||||||
|
testFile: s.testFile,
|
||||||
|
startedAt: s.startedAt.toISOString(),
|
||||||
|
finishedAt: s.finishedAt?.toISOString() || null,
|
||||||
|
exitCode: s.exitCode,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a worktree has an active test run
|
||||||
|
*/
|
||||||
|
isRunning(worktreePath: string): boolean {
|
||||||
|
return this.getActiveSession(worktreePath) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up old completed sessions (keep only recent ones)
|
||||||
|
*/
|
||||||
|
cleanupOldSessions(maxAgeMs: number = 30 * 60 * 1000): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [sessionId, session] of this.sessions.entries()) {
|
||||||
|
if (session.status !== 'running' && session.finishedAt) {
|
||||||
|
if (now - session.finishedAt.getTime() > maxAgeMs) {
|
||||||
|
this.sessions.delete(sessionId);
|
||||||
|
logger.debug(`Cleaned up old test session: ${sessionId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel all running test sessions (for cleanup)
|
||||||
|
*/
|
||||||
|
async cancelAll(): Promise<void> {
|
||||||
|
logger.info(`Cancelling all ${this.sessions.size} test sessions`);
|
||||||
|
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
if (session.status === 'running') {
|
||||||
|
await this.stopTests(session.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup service resources
|
||||||
|
*/
|
||||||
|
async cleanup(): Promise<void> {
|
||||||
|
await this.cancelAll();
|
||||||
|
this.sessions.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
let testRunnerServiceInstance: TestRunnerService | null = null;
|
||||||
|
|
||||||
|
export function getTestRunnerService(): TestRunnerService {
|
||||||
|
if (!testRunnerServiceInstance) {
|
||||||
|
testRunnerServiceInstance = new TestRunnerService();
|
||||||
|
}
|
||||||
|
return testRunnerServiceInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on process exit
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
if (testRunnerServiceInstance) {
|
||||||
|
testRunnerServiceInstance.cleanup().catch((err) => {
|
||||||
|
logger.error('Cleanup failed on SIGTERM:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
if (testRunnerServiceInstance) {
|
||||||
|
testRunnerServiceInstance.cleanup().catch((err) => {
|
||||||
|
logger.error('Cleanup failed on SIGINT:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export the class for testing purposes
|
||||||
|
export { TestRunnerService };
|
||||||
@@ -788,6 +788,367 @@ describe('pipeline-service.ts', () => {
|
|||||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
|
const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
|
||||||
expect(nextStatus).toBe('pipeline_step1'); // Should use step1 (order 0), not step2
|
expect(nextStatus).toBe('pipeline_step1'); // Should use step1 (order 0), not step2
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('with exclusions', () => {
|
||||||
|
it('should skip excluded step when coming from in_progress', () => {
|
||||||
|
const config: PipelineConfig = {
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'step1',
|
||||||
|
name: 'Step 1',
|
||||||
|
order: 0,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'blue',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step2',
|
||||||
|
name: 'Step 2',
|
||||||
|
order: 1,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'green',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, ['step1']);
|
||||||
|
expect(nextStatus).toBe('pipeline_step2'); // Should skip step1 and go to step2
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip excluded step when moving between steps', () => {
|
||||||
|
const config: PipelineConfig = {
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'step1',
|
||||||
|
name: 'Step 1',
|
||||||
|
order: 0,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'blue',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step2',
|
||||||
|
name: 'Step 2',
|
||||||
|
order: 1,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'green',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step3',
|
||||||
|
name: 'Step 3',
|
||||||
|
order: 2,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'red',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||||
|
'step2',
|
||||||
|
]);
|
||||||
|
expect(nextStatus).toBe('pipeline_step3'); // Should skip step2 and go to step3
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should go to final status when all remaining steps are excluded', () => {
|
||||||
|
const config: PipelineConfig = {
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'step1',
|
||||||
|
name: 'Step 1',
|
||||||
|
order: 0,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'blue',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step2',
|
||||||
|
name: 'Step 2',
|
||||||
|
order: 1,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'green',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||||
|
'step2',
|
||||||
|
]);
|
||||||
|
expect(nextStatus).toBe('verified'); // No more steps after exclusion
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should go to waiting_approval when all remaining steps excluded and skipTests is true', () => {
|
||||||
|
const config: PipelineConfig = {
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'step1',
|
||||||
|
name: 'Step 1',
|
||||||
|
order: 0,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'blue',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step2',
|
||||||
|
name: 'Step 2',
|
||||||
|
order: 1,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'green',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, true, ['step2']);
|
||||||
|
expect(nextStatus).toBe('waiting_approval');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should go to final status when all steps are excluded from in_progress', () => {
|
||||||
|
const config: PipelineConfig = {
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'step1',
|
||||||
|
name: 'Step 1',
|
||||||
|
order: 0,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'blue',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step2',
|
||||||
|
name: 'Step 2',
|
||||||
|
order: 1,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'green',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, [
|
||||||
|
'step1',
|
||||||
|
'step2',
|
||||||
|
]);
|
||||||
|
expect(nextStatus).toBe('verified');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty exclusions array like no exclusions', () => {
|
||||||
|
const config: PipelineConfig = {
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'step1',
|
||||||
|
name: 'Step 1',
|
||||||
|
order: 0,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'blue',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, []);
|
||||||
|
expect(nextStatus).toBe('pipeline_step1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined exclusions like no exclusions', () => {
|
||||||
|
const config: PipelineConfig = {
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'step1',
|
||||||
|
name: 'Step 1',
|
||||||
|
order: 0,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'blue',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, undefined);
|
||||||
|
expect(nextStatus).toBe('pipeline_step1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip multiple excluded steps in sequence', () => {
|
||||||
|
const config: PipelineConfig = {
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'step1',
|
||||||
|
name: 'Step 1',
|
||||||
|
order: 0,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'blue',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step2',
|
||||||
|
name: 'Step 2',
|
||||||
|
order: 1,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'green',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step3',
|
||||||
|
name: 'Step 3',
|
||||||
|
order: 2,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'red',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step4',
|
||||||
|
name: 'Step 4',
|
||||||
|
order: 3,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'yellow',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exclude step2 and step3
|
||||||
|
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||||
|
'step2',
|
||||||
|
'step3',
|
||||||
|
]);
|
||||||
|
expect(nextStatus).toBe('pipeline_step4'); // Should skip step2 and step3
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle exclusion of non-existent step IDs gracefully', () => {
|
||||||
|
const config: PipelineConfig = {
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'step1',
|
||||||
|
name: 'Step 1',
|
||||||
|
order: 0,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'blue',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step2',
|
||||||
|
name: 'Step 2',
|
||||||
|
order: 1,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'green',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exclude a non-existent step - should have no effect
|
||||||
|
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, [
|
||||||
|
'nonexistent',
|
||||||
|
]);
|
||||||
|
expect(nextStatus).toBe('pipeline_step1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find next valid step when current step becomes excluded mid-flow', () => {
|
||||||
|
const config: PipelineConfig = {
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'step1',
|
||||||
|
name: 'Step 1',
|
||||||
|
order: 0,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'blue',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step2',
|
||||||
|
name: 'Step 2',
|
||||||
|
order: 1,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'green',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step3',
|
||||||
|
name: 'Step 3',
|
||||||
|
order: 2,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'red',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Feature is at step1 but step1 is now excluded - should find next valid step
|
||||||
|
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||||
|
'step1',
|
||||||
|
'step2',
|
||||||
|
]);
|
||||||
|
expect(nextStatus).toBe('pipeline_step3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should go to final status when current step is excluded and no steps remain', () => {
|
||||||
|
const config: PipelineConfig = {
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'step1',
|
||||||
|
name: 'Step 1',
|
||||||
|
order: 0,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'blue',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step2',
|
||||||
|
name: 'Step 2',
|
||||||
|
order: 1,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'green',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Feature is at step1 but both steps are excluded
|
||||||
|
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||||
|
'step1',
|
||||||
|
'step2',
|
||||||
|
]);
|
||||||
|
expect(nextStatus).toBe('verified');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getStep', () => {
|
describe('getStep', () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/ui",
|
"name": "@automaker/ui",
|
||||||
"version": "0.12.0",
|
"version": "0.13.0",
|
||||||
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
426
apps/ui/src/components/ui/test-logs-panel.tsx
Normal file
426
apps/ui/src/components/ui/test-logs-panel.tsx
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Terminal,
|
||||||
|
ArrowDown,
|
||||||
|
Square,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
GitBranch,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
FlaskConical,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer';
|
||||||
|
import { useTestLogs } from '@/hooks/use-test-logs';
|
||||||
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
|
import type { TestRunStatus } from '@/types/electron';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface TestLogsPanelProps {
|
||||||
|
/** Whether the panel is open */
|
||||||
|
open: boolean;
|
||||||
|
/** Callback when the panel is closed */
|
||||||
|
onClose: () => void;
|
||||||
|
/** Path to the worktree to show test logs for */
|
||||||
|
worktreePath: string | null;
|
||||||
|
/** Branch name for display */
|
||||||
|
branch?: string;
|
||||||
|
/** Specific session ID to fetch logs for (optional) */
|
||||||
|
sessionId?: string;
|
||||||
|
/** Callback to stop the running tests */
|
||||||
|
onStopTests?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status indicator based on test run status
|
||||||
|
*/
|
||||||
|
function getStatusIndicator(status: TestRunStatus | null): {
|
||||||
|
text: string;
|
||||||
|
className: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
} {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return {
|
||||||
|
text: 'Running',
|
||||||
|
className: 'bg-blue-500/10 text-blue-500',
|
||||||
|
icon: <span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />,
|
||||||
|
};
|
||||||
|
case 'pending':
|
||||||
|
return {
|
||||||
|
text: 'Pending',
|
||||||
|
className: 'bg-amber-500/10 text-amber-500',
|
||||||
|
icon: <Clock className="w-3 h-3" />,
|
||||||
|
};
|
||||||
|
case 'passed':
|
||||||
|
return {
|
||||||
|
text: 'Passed',
|
||||||
|
className: 'bg-green-500/10 text-green-500',
|
||||||
|
icon: <CheckCircle2 className="w-3 h-3" />,
|
||||||
|
};
|
||||||
|
case 'failed':
|
||||||
|
return {
|
||||||
|
text: 'Failed',
|
||||||
|
className: 'bg-red-500/10 text-red-500',
|
||||||
|
icon: <XCircle className="w-3 h-3" />,
|
||||||
|
};
|
||||||
|
case 'cancelled':
|
||||||
|
return {
|
||||||
|
text: 'Cancelled',
|
||||||
|
className: 'bg-yellow-500/10 text-yellow-500',
|
||||||
|
icon: <AlertCircle className="w-3 h-3" />,
|
||||||
|
};
|
||||||
|
case 'error':
|
||||||
|
return {
|
||||||
|
text: 'Error',
|
||||||
|
className: 'bg-red-500/10 text-red-500',
|
||||||
|
icon: <AlertCircle className="w-3 h-3" />,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
text: 'Idle',
|
||||||
|
className: 'bg-muted text-muted-foreground',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format duration in milliseconds to human-readable string
|
||||||
|
*/
|
||||||
|
function formatDuration(ms: number | null): string | null {
|
||||||
|
if (ms === null) return null;
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
const seconds = ((ms % 60000) / 1000).toFixed(0);
|
||||||
|
return `${minutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format timestamp to localized time string
|
||||||
|
*/
|
||||||
|
function formatTime(timestamp: string | null): string | null {
|
||||||
|
if (!timestamp) return null;
|
||||||
|
try {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleTimeString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Inner Content Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface TestLogsPanelContentProps {
|
||||||
|
worktreePath: string | null;
|
||||||
|
branch?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
onStopTests?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestLogsPanelContent({
|
||||||
|
worktreePath,
|
||||||
|
branch,
|
||||||
|
sessionId,
|
||||||
|
onStopTests,
|
||||||
|
}: TestLogsPanelContentProps) {
|
||||||
|
const xtermRef = useRef<XtermLogViewerRef>(null);
|
||||||
|
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
|
||||||
|
const lastLogsLengthRef = useRef(0);
|
||||||
|
const lastSessionIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
logs,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
status,
|
||||||
|
sessionId: currentSessionId,
|
||||||
|
command,
|
||||||
|
testFile,
|
||||||
|
startedAt,
|
||||||
|
exitCode,
|
||||||
|
duration,
|
||||||
|
isRunning,
|
||||||
|
fetchLogs,
|
||||||
|
} = useTestLogs({
|
||||||
|
worktreePath,
|
||||||
|
sessionId,
|
||||||
|
autoSubscribe: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write logs to xterm when they change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!xtermRef.current || !logs) return;
|
||||||
|
|
||||||
|
// If session changed, reset the terminal and write all content
|
||||||
|
if (lastSessionIdRef.current !== currentSessionId) {
|
||||||
|
lastSessionIdRef.current = currentSessionId;
|
||||||
|
lastLogsLengthRef.current = 0;
|
||||||
|
xtermRef.current.write(logs);
|
||||||
|
lastLogsLengthRef.current = logs.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If logs got shorter (e.g., cleared), rewrite all
|
||||||
|
if (logs.length < lastLogsLengthRef.current) {
|
||||||
|
xtermRef.current.write(logs);
|
||||||
|
lastLogsLengthRef.current = logs.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append only the new content
|
||||||
|
if (logs.length > lastLogsLengthRef.current) {
|
||||||
|
const newContent = logs.slice(lastLogsLengthRef.current);
|
||||||
|
xtermRef.current.append(newContent);
|
||||||
|
lastLogsLengthRef.current = logs.length;
|
||||||
|
}
|
||||||
|
}, [logs, currentSessionId]);
|
||||||
|
|
||||||
|
// Reset auto-scroll when session changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentSessionId !== lastSessionIdRef.current) {
|
||||||
|
setAutoScrollEnabled(true);
|
||||||
|
lastLogsLengthRef.current = 0;
|
||||||
|
}
|
||||||
|
}, [currentSessionId]);
|
||||||
|
|
||||||
|
// Scroll to bottom handler
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
xtermRef.current?.scrollToBottom();
|
||||||
|
setAutoScrollEnabled(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const statusIndicator = getStatusIndicator(status);
|
||||||
|
const formattedStartTime = formatTime(startedAt);
|
||||||
|
const formattedDuration = formatDuration(duration);
|
||||||
|
const lineCount = logs ? logs.split('\n').length : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-base">
|
||||||
|
<FlaskConical className="w-4 h-4 text-primary" />
|
||||||
|
<span>Test Runner</span>
|
||||||
|
{status && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
statusIndicator.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusIndicator.icon}
|
||||||
|
{statusIndicator.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{formattedDuration && !isRunning && (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">{formattedDuration}</span>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isRunning && onStopTests && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={onStopTests}
|
||||||
|
>
|
||||||
|
<Square className="w-3 h-3 mr-1.5 fill-current" />
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={() => fetchLogs()}
|
||||||
|
title="Refresh logs"
|
||||||
|
>
|
||||||
|
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info bar */}
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||||||
|
{branch && (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<GitBranch className="w-3 h-3" />
|
||||||
|
<span className="font-medium text-foreground/80">{branch}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{command && (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="text-muted-foreground/60">Command</span>
|
||||||
|
<span className="font-mono text-primary truncate max-w-[200px]">{command}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{testFile && (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="text-muted-foreground/60">File</span>
|
||||||
|
<span className="font-mono truncate max-w-[150px]">{testFile}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{formattedStartTime && (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{formattedStartTime}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Error displays */}
|
||||||
|
{error && (
|
||||||
|
<div className="shrink-0 px-4 py-2 bg-destructive/5 border-b border-destructive/20">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-destructive">
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Log content area */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden bg-zinc-950" data-testid="test-logs-content">
|
||||||
|
{isLoading && !logs ? (
|
||||||
|
<div className="flex items-center justify-center h-full min-h-[300px] text-muted-foreground">
|
||||||
|
<Spinner size="md" className="mr-2" />
|
||||||
|
<span className="text-sm">Loading logs...</span>
|
||||||
|
</div>
|
||||||
|
) : !logs && !isRunning && !status ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
|
||||||
|
<Terminal className="w-10 h-10 mb-3 opacity-20" />
|
||||||
|
<p className="text-sm">No test run active</p>
|
||||||
|
<p className="text-xs mt-1 opacity-60">Start a test run to see logs here</p>
|
||||||
|
</div>
|
||||||
|
) : isRunning && !logs ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
|
||||||
|
<Spinner size="xl" className="mb-3" />
|
||||||
|
<p className="text-sm">Waiting for output...</p>
|
||||||
|
<p className="text-xs mt-1 opacity-60">Logs will appear as tests generate output</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<XtermLogViewer
|
||||||
|
ref={xtermRef}
|
||||||
|
className="h-full"
|
||||||
|
minHeight={280}
|
||||||
|
autoScroll={autoScrollEnabled}
|
||||||
|
onScrollAwayFromBottom={() => setAutoScrollEnabled(false)}
|
||||||
|
onScrollToBottom={() => setAutoScrollEnabled(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer status bar */}
|
||||||
|
<div className="shrink-0 flex items-center justify-between px-4 py-2 bg-muted/30 border-t border-border/50 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-mono">{lineCount > 0 ? `${lineCount} lines` : 'No output'}</span>
|
||||||
|
{exitCode !== null && (
|
||||||
|
<span className={cn('font-mono', exitCode === 0 ? 'text-green-500' : 'text-red-500')}>
|
||||||
|
Exit: {exitCode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!autoScrollEnabled && logs && (
|
||||||
|
<button
|
||||||
|
onClick={scrollToBottom}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2 py-1 rounded hover:bg-muted transition-colors text-primary"
|
||||||
|
>
|
||||||
|
<ArrowDown className="w-3 h-3" />
|
||||||
|
Scroll to bottom
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{autoScrollEnabled && logs && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 opacity-60">
|
||||||
|
<ArrowDown className="w-3 h-3" />
|
||||||
|
Auto-scroll
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panel component for displaying test runner logs with ANSI color rendering
|
||||||
|
* and real-time streaming support.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Real-time log streaming via WebSocket
|
||||||
|
* - Full ANSI color code rendering via xterm.js
|
||||||
|
* - Auto-scroll to bottom (can be paused by scrolling up)
|
||||||
|
* - Test status indicators (pending, running, passed, failed, etc.)
|
||||||
|
* - Dialog on desktop, Sheet on mobile
|
||||||
|
* - Quick actions (stop tests, refresh logs)
|
||||||
|
*/
|
||||||
|
export function TestLogsPanel({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
worktreePath,
|
||||||
|
branch,
|
||||||
|
sessionId,
|
||||||
|
onStopTests,
|
||||||
|
}: TestLogsPanelProps) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (!worktreePath) return null;
|
||||||
|
|
||||||
|
// Mobile: use Sheet (bottom drawer)
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||||
|
<SheetContent side="bottom" className="h-[80vh] p-0 flex flex-col">
|
||||||
|
<SheetHeader className="sr-only">
|
||||||
|
<SheetTitle>Test Logs</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<TestLogsPanelContent
|
||||||
|
worktreePath={worktreePath}
|
||||||
|
branch={branch}
|
||||||
|
sessionId={sessionId}
|
||||||
|
onStopTests={onStopTests}
|
||||||
|
/>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: use Dialog
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||||
|
<DialogContent
|
||||||
|
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden"
|
||||||
|
data-testid="test-logs-panel"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<TestLogsPanelContent
|
||||||
|
worktreePath={worktreePath}
|
||||||
|
branch={branch}
|
||||||
|
sessionId={sessionId}
|
||||||
|
onStopTests={onStopTests}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1489,6 +1489,7 @@ export function BoardView() {
|
|||||||
branchSuggestions={branchSuggestions}
|
branchSuggestions={branchSuggestions}
|
||||||
branchCardCounts={branchCardCounts}
|
branchCardCounts={branchCardCounts}
|
||||||
currentBranch={currentWorktreeBranch || undefined}
|
currentBranch={currentWorktreeBranch || undefined}
|
||||||
|
projectPath={currentProject?.path}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Board Background Modal */}
|
{/* Board Background Modal */}
|
||||||
@@ -1538,6 +1539,7 @@ export function BoardView() {
|
|||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
parentFeature={spawnParentFeature}
|
parentFeature={spawnParentFeature}
|
||||||
allFeatures={hookFeatures}
|
allFeatures={hookFeatures}
|
||||||
|
projectPath={currentProject?.path}
|
||||||
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
|
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
|
||||||
selectedNonMainWorktreeBranch={
|
selectedNonMainWorktreeBranch={
|
||||||
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
||||||
@@ -1568,6 +1570,7 @@ export function BoardView() {
|
|||||||
currentBranch={currentWorktreeBranch || undefined}
|
currentBranch={currentWorktreeBranch || undefined}
|
||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
allFeatures={hookFeatures}
|
allFeatures={hookFeatures}
|
||||||
|
projectPath={currentProject?.path}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Agent Output Modal */}
|
{/* Agent Output Modal */}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { memo, useEffect, useMemo, useState } from 'react';
|
|||||||
import { Feature, useAppStore } from '@/store/app-store';
|
import { Feature, useAppStore } from '@/store/app-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
|
import { AlertCircle, Lock, Hand, Sparkles, SkipForward } from 'lucide-react';
|
||||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { usePipelineConfig } from '@/hooks/queries/use-pipeline';
|
||||||
|
|
||||||
/** Uniform badge style for all card badges */
|
/** Uniform badge style for all card badges */
|
||||||
const uniformBadgeClass =
|
const uniformBadgeClass =
|
||||||
@@ -51,9 +52,13 @@ export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps)
|
|||||||
|
|
||||||
interface PriorityBadgesProps {
|
interface PriorityBadgesProps {
|
||||||
feature: Feature;
|
feature: Feature;
|
||||||
|
projectPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PriorityBadges = memo(function PriorityBadges({ feature }: PriorityBadgesProps) {
|
export const PriorityBadges = memo(function PriorityBadges({
|
||||||
|
feature,
|
||||||
|
projectPath,
|
||||||
|
}: PriorityBadgesProps) {
|
||||||
const { enableDependencyBlocking, features } = useAppStore(
|
const { enableDependencyBlocking, features } = useAppStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
enableDependencyBlocking: state.enableDependencyBlocking,
|
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||||
@@ -62,6 +67,9 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
|
|||||||
);
|
);
|
||||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||||
|
|
||||||
|
// Fetch pipeline config to check if there are pipelines to exclude
|
||||||
|
const { data: pipelineConfig } = usePipelineConfig(projectPath);
|
||||||
|
|
||||||
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
||||||
const blockingDependencies = useMemo(() => {
|
const blockingDependencies = useMemo(() => {
|
||||||
if (!enableDependencyBlocking || feature.status !== 'backlog') {
|
if (!enableDependencyBlocking || feature.status !== 'backlog') {
|
||||||
@@ -108,7 +116,19 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
|
|||||||
const showManualVerification =
|
const showManualVerification =
|
||||||
feature.skipTests && !feature.error && feature.status === 'backlog';
|
feature.skipTests && !feature.error && feature.status === 'backlog';
|
||||||
|
|
||||||
const showBadges = feature.priority || showManualVerification || isBlocked || isJustFinished;
|
// Check if feature has excluded pipeline steps
|
||||||
|
const excludedStepCount = feature.excludedPipelineSteps?.length || 0;
|
||||||
|
const totalPipelineSteps = pipelineConfig?.steps?.length || 0;
|
||||||
|
const hasPipelineExclusions =
|
||||||
|
excludedStepCount > 0 && totalPipelineSteps > 0 && feature.status === 'backlog';
|
||||||
|
const allPipelinesExcluded = hasPipelineExclusions && excludedStepCount >= totalPipelineSteps;
|
||||||
|
|
||||||
|
const showBadges =
|
||||||
|
feature.priority ||
|
||||||
|
showManualVerification ||
|
||||||
|
isBlocked ||
|
||||||
|
isJustFinished ||
|
||||||
|
hasPipelineExclusions;
|
||||||
|
|
||||||
if (!showBadges) {
|
if (!showBadges) {
|
||||||
return null;
|
return null;
|
||||||
@@ -227,6 +247,39 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pipeline exclusion badge */}
|
||||||
|
{hasPipelineExclusions && (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
uniformBadgeClass,
|
||||||
|
allPipelinesExcluded
|
||||||
|
? 'bg-violet-500/20 border-violet-500/50 text-violet-500'
|
||||||
|
: 'bg-violet-500/10 border-violet-500/30 text-violet-400'
|
||||||
|
)}
|
||||||
|
data-testid={`pipeline-exclusion-badge-${feature.id}`}
|
||||||
|
>
|
||||||
|
<SkipForward className="w-3.5 h-3.5" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
|
||||||
|
<p className="font-medium mb-1">
|
||||||
|
{allPipelinesExcluded
|
||||||
|
? 'All pipelines skipped'
|
||||||
|
: `${excludedStepCount} of ${totalPipelineSteps} pipeline${totalPipelineSteps !== 1 ? 's' : ''} skipped`}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{allPipelinesExcluded
|
||||||
|
? 'This feature will skip all custom pipeline steps'
|
||||||
|
: 'Some custom pipeline steps will be skipped for this feature'}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Priority and Manual Verification badges */}
|
{/* Priority and Manual Verification badges */}
|
||||||
<PriorityBadges feature={feature} />
|
<PriorityBadges feature={feature} projectPath={currentProject?.path} />
|
||||||
|
|
||||||
{/* Card Header */}
|
{/* Card Header */}
|
||||||
<CardHeaderSection
|
<CardHeaderSection
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
AncestorContextSection,
|
AncestorContextSection,
|
||||||
EnhanceWithAI,
|
EnhanceWithAI,
|
||||||
EnhancementHistoryButton,
|
EnhancementHistoryButton,
|
||||||
|
PipelineExclusionControls,
|
||||||
type BaseHistoryEntry,
|
type BaseHistoryEntry,
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
import type { WorkMode } from '../shared';
|
import type { WorkMode } from '../shared';
|
||||||
@@ -101,6 +102,7 @@ type FeatureData = {
|
|||||||
requirePlanApproval: boolean;
|
requirePlanApproval: boolean;
|
||||||
dependencies?: string[];
|
dependencies?: string[];
|
||||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||||
|
excludedPipelineSteps?: string[]; // Pipeline step IDs to skip for this feature
|
||||||
workMode: WorkMode;
|
workMode: WorkMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,6 +120,10 @@ interface AddFeatureDialogProps {
|
|||||||
isMaximized: boolean;
|
isMaximized: boolean;
|
||||||
parentFeature?: Feature | null;
|
parentFeature?: Feature | null;
|
||||||
allFeatures?: Feature[];
|
allFeatures?: Feature[];
|
||||||
|
/**
|
||||||
|
* Path to the current project for loading pipeline config.
|
||||||
|
*/
|
||||||
|
projectPath?: string;
|
||||||
/**
|
/**
|
||||||
* When a non-main worktree is selected in the board header, this will be set to that worktree's branch.
|
* When a non-main worktree is selected in the board header, this will be set to that worktree's branch.
|
||||||
* When set, the dialog will default to 'custom' work mode with this branch pre-filled.
|
* When set, the dialog will default to 'custom' work mode with this branch pre-filled.
|
||||||
@@ -151,6 +157,7 @@ export function AddFeatureDialog({
|
|||||||
isMaximized,
|
isMaximized,
|
||||||
parentFeature = null,
|
parentFeature = null,
|
||||||
allFeatures = [],
|
allFeatures = [],
|
||||||
|
projectPath,
|
||||||
selectedNonMainWorktreeBranch,
|
selectedNonMainWorktreeBranch,
|
||||||
forceCurrentBranchMode,
|
forceCurrentBranchMode,
|
||||||
}: AddFeatureDialogProps) {
|
}: AddFeatureDialogProps) {
|
||||||
@@ -194,9 +201,20 @@ export function AddFeatureDialog({
|
|||||||
const [parentDependencies, setParentDependencies] = useState<string[]>([]);
|
const [parentDependencies, setParentDependencies] = useState<string[]>([]);
|
||||||
const [childDependencies, setChildDependencies] = useState<string[]>([]);
|
const [childDependencies, setChildDependencies] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Pipeline exclusion state
|
||||||
|
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>([]);
|
||||||
|
|
||||||
// Get defaults from store
|
// Get defaults from store
|
||||||
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees, defaultFeatureModel } =
|
const {
|
||||||
useAppStore();
|
defaultPlanningMode,
|
||||||
|
defaultRequirePlanApproval,
|
||||||
|
useWorktrees,
|
||||||
|
defaultFeatureModel,
|
||||||
|
currentProject,
|
||||||
|
} = useAppStore();
|
||||||
|
|
||||||
|
// Use project-level default feature model if set, otherwise fall back to global
|
||||||
|
const effectiveDefaultFeatureModel = currentProject?.defaultFeatureModel ?? defaultFeatureModel;
|
||||||
|
|
||||||
// Track previous open state to detect when dialog opens
|
// Track previous open state to detect when dialog opens
|
||||||
const wasOpenRef = useRef(false);
|
const wasOpenRef = useRef(false);
|
||||||
@@ -216,7 +234,7 @@ export function AddFeatureDialog({
|
|||||||
);
|
);
|
||||||
setPlanningMode(defaultPlanningMode);
|
setPlanningMode(defaultPlanningMode);
|
||||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
setModelEntry(defaultFeatureModel);
|
setModelEntry(effectiveDefaultFeatureModel);
|
||||||
|
|
||||||
// Initialize description history (empty for new feature)
|
// Initialize description history (empty for new feature)
|
||||||
setDescriptionHistory([]);
|
setDescriptionHistory([]);
|
||||||
@@ -234,6 +252,9 @@ export function AddFeatureDialog({
|
|||||||
// Reset dependency selections
|
// Reset dependency selections
|
||||||
setParentDependencies([]);
|
setParentDependencies([]);
|
||||||
setChildDependencies([]);
|
setChildDependencies([]);
|
||||||
|
|
||||||
|
// Reset pipeline exclusions (all pipelines enabled by default)
|
||||||
|
setExcludedPipelineSteps([]);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
open,
|
open,
|
||||||
@@ -241,7 +262,7 @@ export function AddFeatureDialog({
|
|||||||
defaultBranch,
|
defaultBranch,
|
||||||
defaultPlanningMode,
|
defaultPlanningMode,
|
||||||
defaultRequirePlanApproval,
|
defaultRequirePlanApproval,
|
||||||
defaultFeatureModel,
|
effectiveDefaultFeatureModel,
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
selectedNonMainWorktreeBranch,
|
selectedNonMainWorktreeBranch,
|
||||||
forceCurrentBranchMode,
|
forceCurrentBranchMode,
|
||||||
@@ -328,6 +349,7 @@ export function AddFeatureDialog({
|
|||||||
requirePlanApproval,
|
requirePlanApproval,
|
||||||
dependencies: finalDependencies,
|
dependencies: finalDependencies,
|
||||||
childDependencies: childDependencies.length > 0 ? childDependencies : undefined,
|
childDependencies: childDependencies.length > 0 ? childDependencies : undefined,
|
||||||
|
excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined,
|
||||||
workMode,
|
workMode,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -343,7 +365,7 @@ export function AddFeatureDialog({
|
|||||||
// When a non-main worktree is selected, use its branch name for custom mode
|
// When a non-main worktree is selected, use its branch name for custom mode
|
||||||
setBranchName(selectedNonMainWorktreeBranch || '');
|
setBranchName(selectedNonMainWorktreeBranch || '');
|
||||||
setPriority(2);
|
setPriority(2);
|
||||||
setModelEntry(defaultFeatureModel);
|
setModelEntry(effectiveDefaultFeatureModel);
|
||||||
setWorkMode(
|
setWorkMode(
|
||||||
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
|
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
|
||||||
);
|
);
|
||||||
@@ -354,6 +376,7 @@ export function AddFeatureDialog({
|
|||||||
setDescriptionHistory([]);
|
setDescriptionHistory([]);
|
||||||
setParentDependencies([]);
|
setParentDependencies([]);
|
||||||
setChildDependencies([]);
|
setChildDependencies([]);
|
||||||
|
setExcludedPipelineSteps([]);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -696,6 +719,16 @@ export function AddFeatureDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pipeline Exclusion Controls */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<PipelineExclusionControls
|
||||||
|
projectPath={projectPath}
|
||||||
|
excludedPipelineSteps={excludedPipelineSteps}
|
||||||
|
onExcludedStepsChange={setExcludedPipelineSteps}
|
||||||
|
testIdPrefix="add-feature-pipeline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
PlanningModeSelect,
|
PlanningModeSelect,
|
||||||
EnhanceWithAI,
|
EnhanceWithAI,
|
||||||
EnhancementHistoryButton,
|
EnhancementHistoryButton,
|
||||||
|
PipelineExclusionControls,
|
||||||
type EnhancementMode,
|
type EnhancementMode,
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
import type { WorkMode } from '../shared';
|
import type { WorkMode } from '../shared';
|
||||||
@@ -67,6 +68,7 @@ interface EditFeatureDialogProps {
|
|||||||
requirePlanApproval: boolean;
|
requirePlanApproval: boolean;
|
||||||
dependencies?: string[];
|
dependencies?: string[];
|
||||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||||
|
excludedPipelineSteps?: string[]; // Pipeline step IDs to skip for this feature
|
||||||
},
|
},
|
||||||
descriptionHistorySource?: 'enhance' | 'edit',
|
descriptionHistorySource?: 'enhance' | 'edit',
|
||||||
enhancementMode?: EnhancementMode,
|
enhancementMode?: EnhancementMode,
|
||||||
@@ -78,6 +80,7 @@ interface EditFeatureDialogProps {
|
|||||||
currentBranch?: string;
|
currentBranch?: string;
|
||||||
isMaximized: boolean;
|
isMaximized: boolean;
|
||||||
allFeatures: Feature[];
|
allFeatures: Feature[];
|
||||||
|
projectPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditFeatureDialog({
|
export function EditFeatureDialog({
|
||||||
@@ -90,6 +93,7 @@ export function EditFeatureDialog({
|
|||||||
currentBranch,
|
currentBranch,
|
||||||
isMaximized,
|
isMaximized,
|
||||||
allFeatures,
|
allFeatures,
|
||||||
|
projectPath,
|
||||||
}: EditFeatureDialogProps) {
|
}: EditFeatureDialogProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
||||||
@@ -146,6 +150,11 @@ export function EditFeatureDialog({
|
|||||||
return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id);
|
return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pipeline exclusion state
|
||||||
|
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>(
|
||||||
|
feature?.excludedPipelineSteps ?? []
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditingFeature(feature);
|
setEditingFeature(feature);
|
||||||
if (feature) {
|
if (feature) {
|
||||||
@@ -171,6 +180,8 @@ export function EditFeatureDialog({
|
|||||||
.map((f) => f.id);
|
.map((f) => f.id);
|
||||||
setChildDependencies(childDeps);
|
setChildDependencies(childDeps);
|
||||||
setOriginalChildDependencies(childDeps);
|
setOriginalChildDependencies(childDeps);
|
||||||
|
// Reset pipeline exclusion state
|
||||||
|
setExcludedPipelineSteps(feature.excludedPipelineSteps ?? []);
|
||||||
} else {
|
} else {
|
||||||
setEditFeaturePreviewMap(new Map());
|
setEditFeaturePreviewMap(new Map());
|
||||||
setDescriptionChangeSource(null);
|
setDescriptionChangeSource(null);
|
||||||
@@ -179,6 +190,7 @@ export function EditFeatureDialog({
|
|||||||
setParentDependencies([]);
|
setParentDependencies([]);
|
||||||
setChildDependencies([]);
|
setChildDependencies([]);
|
||||||
setOriginalChildDependencies([]);
|
setOriginalChildDependencies([]);
|
||||||
|
setExcludedPipelineSteps([]);
|
||||||
}
|
}
|
||||||
}, [feature, allFeatures]);
|
}, [feature, allFeatures]);
|
||||||
|
|
||||||
@@ -232,6 +244,7 @@ export function EditFeatureDialog({
|
|||||||
workMode,
|
workMode,
|
||||||
dependencies: parentDependencies,
|
dependencies: parentDependencies,
|
||||||
childDependencies: childDepsChanged ? childDependencies : undefined,
|
childDependencies: childDepsChanged ? childDependencies : undefined,
|
||||||
|
excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine if description changed and what source to use
|
// Determine if description changed and what source to use
|
||||||
@@ -618,6 +631,16 @@ export function EditFeatureDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pipeline Exclusion Controls */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<PipelineExclusionControls
|
||||||
|
projectPath={projectPath}
|
||||||
|
excludedPipelineSteps={excludedPipelineSteps}
|
||||||
|
onExcludedStepsChange={setExcludedPipelineSteps}
|
||||||
|
testIdPrefix="edit-feature-pipeline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle } from 'lucide-react';
|
||||||
import { modelSupportsThinking } from '@/lib/utils';
|
import { modelSupportsThinking } from '@/lib/utils';
|
||||||
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
|
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
|
||||||
import { TestingTabContent, PrioritySelect, PlanningModeSelect, WorkModeSelector } from '../shared';
|
import {
|
||||||
|
TestingTabContent,
|
||||||
|
PrioritySelect,
|
||||||
|
PlanningModeSelect,
|
||||||
|
WorkModeSelector,
|
||||||
|
PipelineExclusionControls,
|
||||||
|
} from '../shared';
|
||||||
import type { WorkMode } from '../shared';
|
import type { WorkMode } from '../shared';
|
||||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||||
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
|
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
|
||||||
@@ -28,6 +34,7 @@ interface MassEditDialogProps {
|
|||||||
branchSuggestions: string[];
|
branchSuggestions: string[];
|
||||||
branchCardCounts?: Record<string, number>;
|
branchCardCounts?: Record<string, number>;
|
||||||
currentBranch?: string;
|
currentBranch?: string;
|
||||||
|
projectPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApplyState {
|
interface ApplyState {
|
||||||
@@ -38,11 +45,13 @@ interface ApplyState {
|
|||||||
priority: boolean;
|
priority: boolean;
|
||||||
skipTests: boolean;
|
skipTests: boolean;
|
||||||
branchName: boolean;
|
branchName: boolean;
|
||||||
|
excludedPipelineSteps: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMixedValues(features: Feature[]): Record<string, boolean> {
|
function getMixedValues(features: Feature[]): Record<string, boolean> {
|
||||||
if (features.length === 0) return {};
|
if (features.length === 0) return {};
|
||||||
const first = features[0];
|
const first = features[0];
|
||||||
|
const firstExcludedSteps = JSON.stringify(first.excludedPipelineSteps || []);
|
||||||
return {
|
return {
|
||||||
model: !features.every((f) => f.model === first.model),
|
model: !features.every((f) => f.model === first.model),
|
||||||
thinkingLevel: !features.every((f) => f.thinkingLevel === first.thinkingLevel),
|
thinkingLevel: !features.every((f) => f.thinkingLevel === first.thinkingLevel),
|
||||||
@@ -53,6 +62,9 @@ function getMixedValues(features: Feature[]): Record<string, boolean> {
|
|||||||
priority: !features.every((f) => f.priority === first.priority),
|
priority: !features.every((f) => f.priority === first.priority),
|
||||||
skipTests: !features.every((f) => f.skipTests === first.skipTests),
|
skipTests: !features.every((f) => f.skipTests === first.skipTests),
|
||||||
branchName: !features.every((f) => f.branchName === first.branchName),
|
branchName: !features.every((f) => f.branchName === first.branchName),
|
||||||
|
excludedPipelineSteps: !features.every(
|
||||||
|
(f) => JSON.stringify(f.excludedPipelineSteps || []) === firstExcludedSteps
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +123,7 @@ export function MassEditDialog({
|
|||||||
branchSuggestions,
|
branchSuggestions,
|
||||||
branchCardCounts,
|
branchCardCounts,
|
||||||
currentBranch,
|
currentBranch,
|
||||||
|
projectPath,
|
||||||
}: MassEditDialogProps) {
|
}: MassEditDialogProps) {
|
||||||
const [isApplying, setIsApplying] = useState(false);
|
const [isApplying, setIsApplying] = useState(false);
|
||||||
|
|
||||||
@@ -123,6 +136,7 @@ export function MassEditDialog({
|
|||||||
priority: false,
|
priority: false,
|
||||||
skipTests: false,
|
skipTests: false,
|
||||||
branchName: false,
|
branchName: false,
|
||||||
|
excludedPipelineSteps: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Field values
|
// Field values
|
||||||
@@ -146,6 +160,11 @@ export function MassEditDialog({
|
|||||||
return getInitialValue(selectedFeatures, 'branchName', '') as string;
|
return getInitialValue(selectedFeatures, 'branchName', '') as string;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pipeline exclusion state
|
||||||
|
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>(() => {
|
||||||
|
return getInitialValue(selectedFeatures, 'excludedPipelineSteps', []) as string[];
|
||||||
|
});
|
||||||
|
|
||||||
// Calculate mixed values
|
// Calculate mixed values
|
||||||
const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]);
|
const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]);
|
||||||
|
|
||||||
@@ -160,6 +179,7 @@ export function MassEditDialog({
|
|||||||
priority: false,
|
priority: false,
|
||||||
skipTests: false,
|
skipTests: false,
|
||||||
branchName: false,
|
branchName: false,
|
||||||
|
excludedPipelineSteps: false,
|
||||||
});
|
});
|
||||||
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
||||||
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
||||||
@@ -172,6 +192,10 @@ export function MassEditDialog({
|
|||||||
const initialBranchName = getInitialValue(selectedFeatures, 'branchName', '') as string;
|
const initialBranchName = getInitialValue(selectedFeatures, 'branchName', '') as string;
|
||||||
setBranchName(initialBranchName);
|
setBranchName(initialBranchName);
|
||||||
setWorkMode(initialBranchName ? 'custom' : 'current');
|
setWorkMode(initialBranchName ? 'custom' : 'current');
|
||||||
|
// Reset pipeline exclusions
|
||||||
|
setExcludedPipelineSteps(
|
||||||
|
getInitialValue(selectedFeatures, 'excludedPipelineSteps', []) as string[]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [open, selectedFeatures]);
|
}, [open, selectedFeatures]);
|
||||||
|
|
||||||
@@ -190,6 +214,10 @@ export function MassEditDialog({
|
|||||||
// For 'custom' mode, use the specified branch name
|
// For 'custom' mode, use the specified branch name
|
||||||
updates.branchName = workMode === 'custom' ? branchName : '';
|
updates.branchName = workMode === 'custom' ? branchName : '';
|
||||||
}
|
}
|
||||||
|
if (applyState.excludedPipelineSteps) {
|
||||||
|
updates.excludedPipelineSteps =
|
||||||
|
excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
if (Object.keys(updates).length === 0) {
|
||||||
onClose();
|
onClose();
|
||||||
@@ -353,6 +381,23 @@ export function MassEditDialog({
|
|||||||
testIdPrefix="mass-edit-work-mode"
|
testIdPrefix="mass-edit-work-mode"
|
||||||
/>
|
/>
|
||||||
</FieldWrapper>
|
</FieldWrapper>
|
||||||
|
|
||||||
|
{/* Pipeline Exclusion */}
|
||||||
|
<FieldWrapper
|
||||||
|
label="Pipeline Steps"
|
||||||
|
isMixed={mixedValues.excludedPipelineSteps}
|
||||||
|
willApply={applyState.excludedPipelineSteps}
|
||||||
|
onApplyChange={(apply) =>
|
||||||
|
setApplyState((prev) => ({ ...prev, excludedPipelineSteps: apply }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PipelineExclusionControls
|
||||||
|
projectPath={projectPath}
|
||||||
|
excludedPipelineSteps={excludedPipelineSteps}
|
||||||
|
onExcludedStepsChange={setExcludedPipelineSteps}
|
||||||
|
testIdPrefix="mass-edit-pipeline"
|
||||||
|
/>
|
||||||
|
</FieldWrapper>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export * from './planning-mode-select';
|
|||||||
export * from './ancestor-context-section';
|
export * from './ancestor-context-section';
|
||||||
export * from './work-mode-selector';
|
export * from './work-mode-selector';
|
||||||
export * from './enhancement';
|
export * from './enhancement';
|
||||||
|
export * from './pipeline-exclusion-controls';
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { GitBranch, Workflow } from 'lucide-react';
|
||||||
|
import { usePipelineConfig } from '@/hooks/queries/use-pipeline';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface PipelineExclusionControlsProps {
|
||||||
|
projectPath: string | undefined;
|
||||||
|
excludedPipelineSteps: string[];
|
||||||
|
onExcludedStepsChange: (excludedSteps: string[]) => void;
|
||||||
|
testIdPrefix?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for selecting which custom pipeline steps should be excluded for a feature.
|
||||||
|
* Each pipeline step is shown as a toggleable switch, defaulting to enabled (included).
|
||||||
|
* Disabling a step adds it to the exclusion list.
|
||||||
|
*/
|
||||||
|
export function PipelineExclusionControls({
|
||||||
|
projectPath,
|
||||||
|
excludedPipelineSteps,
|
||||||
|
onExcludedStepsChange,
|
||||||
|
testIdPrefix = 'pipeline-exclusion',
|
||||||
|
disabled = false,
|
||||||
|
}: PipelineExclusionControlsProps) {
|
||||||
|
const { data: pipelineConfig, isLoading } = usePipelineConfig(projectPath);
|
||||||
|
|
||||||
|
// Sort steps by order
|
||||||
|
const sortedSteps = [...(pipelineConfig?.steps || [])].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
// If no pipeline steps exist or loading, don't render anything
|
||||||
|
if (isLoading || sortedSteps.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleStep = (stepId: string) => {
|
||||||
|
const isCurrentlyExcluded = excludedPipelineSteps.includes(stepId);
|
||||||
|
if (isCurrentlyExcluded) {
|
||||||
|
// Remove from exclusions (enable the step)
|
||||||
|
onExcludedStepsChange(excludedPipelineSteps.filter((id) => id !== stepId));
|
||||||
|
} else {
|
||||||
|
// Add to exclusions (disable the step)
|
||||||
|
onExcludedStepsChange([...excludedPipelineSteps, stepId]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const allExcluded = sortedSteps.every((step) => excludedPipelineSteps.includes(step.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Workflow className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<Label className="text-sm font-medium">Custom Pipeline Steps</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sortedSteps.map((step) => {
|
||||||
|
const isIncluded = !excludedPipelineSteps.includes(step.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between gap-3 px-3 py-2 rounded-md border',
|
||||||
|
isIncluded
|
||||||
|
? 'border-border/50 bg-muted/30'
|
||||||
|
: 'border-border/30 bg-muted/10 opacity-60'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full flex-shrink-0',
|
||||||
|
step.colorClass || 'bg-gray-400'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: step.colorClass?.startsWith('#') ? step.colorClass : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm truncate',
|
||||||
|
isIncluded ? 'text-foreground' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={isIncluded}
|
||||||
|
onCheckedChange={() => toggleStep(step.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
data-testid={`${testIdPrefix}-step-${step.id}`}
|
||||||
|
aria-label={`${isIncluded ? 'Disable' : 'Enable'} ${step.name} pipeline step`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allExcluded && (
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<GitBranch className="w-3.5 h-3.5" />
|
||||||
|
All pipeline steps disabled. Feature will skip directly to verification.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enabled steps will run after implementation. Disable steps to skip them for this feature.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,10 +33,11 @@ import {
|
|||||||
SplitSquareHorizontal,
|
SplitSquareHorizontal,
|
||||||
Undo2,
|
Undo2,
|
||||||
Zap,
|
Zap,
|
||||||
|
FlaskConical,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus, TestSessionInfo } from '../types';
|
||||||
import { TooltipWrapper } from './tooltip-wrapper';
|
import { TooltipWrapper } from './tooltip-wrapper';
|
||||||
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
|
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
|
||||||
import {
|
import {
|
||||||
@@ -63,6 +64,14 @@ interface WorktreeActionsDropdownProps {
|
|||||||
standalone?: boolean;
|
standalone?: boolean;
|
||||||
/** Whether auto mode is running for this worktree */
|
/** Whether auto mode is running for this worktree */
|
||||||
isAutoModeRunning?: boolean;
|
isAutoModeRunning?: boolean;
|
||||||
|
/** Whether a test command is configured in project settings */
|
||||||
|
hasTestCommand?: boolean;
|
||||||
|
/** Whether tests are being started for this worktree */
|
||||||
|
isStartingTests?: boolean;
|
||||||
|
/** Whether tests are currently running for this worktree */
|
||||||
|
isTestRunning?: boolean;
|
||||||
|
/** Active test session info for this worktree */
|
||||||
|
testSessionInfo?: TestSessionInfo;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onPull: (worktree: WorktreeInfo) => void;
|
onPull: (worktree: WorktreeInfo) => void;
|
||||||
onPush: (worktree: WorktreeInfo) => void;
|
onPush: (worktree: WorktreeInfo) => void;
|
||||||
@@ -84,6 +93,12 @@ interface WorktreeActionsDropdownProps {
|
|||||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||||
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
||||||
onMerge: (worktree: WorktreeInfo) => void;
|
onMerge: (worktree: WorktreeInfo) => void;
|
||||||
|
/** Start running tests for this worktree */
|
||||||
|
onStartTests?: (worktree: WorktreeInfo) => void;
|
||||||
|
/** Stop running tests for this worktree */
|
||||||
|
onStopTests?: (worktree: WorktreeInfo) => void;
|
||||||
|
/** View test logs for this worktree */
|
||||||
|
onViewTestLogs?: (worktree: WorktreeInfo) => void;
|
||||||
hasInitScript: boolean;
|
hasInitScript: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +116,10 @@ export function WorktreeActionsDropdown({
|
|||||||
gitRepoStatus,
|
gitRepoStatus,
|
||||||
standalone = false,
|
standalone = false,
|
||||||
isAutoModeRunning = false,
|
isAutoModeRunning = false,
|
||||||
|
hasTestCommand = false,
|
||||||
|
isStartingTests = false,
|
||||||
|
isTestRunning = false,
|
||||||
|
testSessionInfo,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onPull,
|
onPull,
|
||||||
onPush,
|
onPush,
|
||||||
@@ -122,6 +141,9 @@ export function WorktreeActionsDropdown({
|
|||||||
onRunInitScript,
|
onRunInitScript,
|
||||||
onToggleAutoMode,
|
onToggleAutoMode,
|
||||||
onMerge,
|
onMerge,
|
||||||
|
onStartTests,
|
||||||
|
onStopTests,
|
||||||
|
onViewTestLogs,
|
||||||
hasInitScript,
|
hasInitScript,
|
||||||
}: WorktreeActionsDropdownProps) {
|
}: WorktreeActionsDropdownProps) {
|
||||||
// Get available editors for the "Open In" submenu
|
// Get available editors for the "Open In" submenu
|
||||||
@@ -231,6 +253,65 @@ export function WorktreeActionsDropdown({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* Test Runner section - only show when test command is configured */}
|
||||||
|
{hasTestCommand && onStartTests && (
|
||||||
|
<>
|
||||||
|
{isTestRunning ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
|
||||||
|
Tests Running
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{onViewTestLogs && (
|
||||||
|
<DropdownMenuItem onClick={() => onViewTestLogs(worktree)} className="text-xs">
|
||||||
|
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||||
|
View Test Logs
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{onStopTests && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onStopTests(worktree)}
|
||||||
|
className="text-xs text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Square className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Stop Tests
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onStartTests(worktree)}
|
||||||
|
disabled={isStartingTests}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<FlaskConical
|
||||||
|
className={cn('w-3.5 h-3.5 mr-2', isStartingTests && 'animate-pulse')}
|
||||||
|
/>
|
||||||
|
{isStartingTests ? 'Starting Tests...' : 'Run Tests'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{onViewTestLogs && testSessionInfo && (
|
||||||
|
<DropdownMenuItem onClick={() => onViewTestLogs(worktree)} className="text-xs">
|
||||||
|
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||||
|
View Last Test Results
|
||||||
|
{testSessionInfo.status === 'passed' && (
|
||||||
|
<span className="ml-auto text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded">
|
||||||
|
passed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{testSessionInfo.status === 'failed' && (
|
||||||
|
<span className="ml-auto text-[10px] bg-red-500/20 text-red-600 px-1.5 py-0.5 rounded">
|
||||||
|
failed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{/* Auto Mode toggle */}
|
{/* Auto Mode toggle */}
|
||||||
{onToggleAutoMode && (
|
{onToggleAutoMode && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import { Spinner } from '@/components/ui/spinner';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
import type {
|
||||||
|
WorktreeInfo,
|
||||||
|
BranchInfo,
|
||||||
|
DevServerInfo,
|
||||||
|
PRInfo,
|
||||||
|
GitRepoStatus,
|
||||||
|
TestSessionInfo,
|
||||||
|
} from '../types';
|
||||||
import { BranchSwitchDropdown } from './branch-switch-dropdown';
|
import { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||||
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||||
|
|
||||||
@@ -33,6 +40,12 @@ interface WorktreeTabProps {
|
|||||||
gitRepoStatus: GitRepoStatus;
|
gitRepoStatus: GitRepoStatus;
|
||||||
/** Whether auto mode is running for this worktree */
|
/** Whether auto mode is running for this worktree */
|
||||||
isAutoModeRunning?: boolean;
|
isAutoModeRunning?: boolean;
|
||||||
|
/** Whether tests are being started for this worktree */
|
||||||
|
isStartingTests?: boolean;
|
||||||
|
/** Whether tests are currently running for this worktree */
|
||||||
|
isTestRunning?: boolean;
|
||||||
|
/** Active test session info for this worktree */
|
||||||
|
testSessionInfo?: TestSessionInfo;
|
||||||
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
||||||
onBranchDropdownOpenChange: (open: boolean) => void;
|
onBranchDropdownOpenChange: (open: boolean) => void;
|
||||||
onActionsDropdownOpenChange: (open: boolean) => void;
|
onActionsDropdownOpenChange: (open: boolean) => void;
|
||||||
@@ -59,7 +72,15 @@ interface WorktreeTabProps {
|
|||||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||||
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
||||||
|
/** Start running tests for this worktree */
|
||||||
|
onStartTests?: (worktree: WorktreeInfo) => void;
|
||||||
|
/** Stop running tests for this worktree */
|
||||||
|
onStopTests?: (worktree: WorktreeInfo) => void;
|
||||||
|
/** View test logs for this worktree */
|
||||||
|
onViewTestLogs?: (worktree: WorktreeInfo) => void;
|
||||||
hasInitScript: boolean;
|
hasInitScript: boolean;
|
||||||
|
/** Whether a test command is configured in project settings */
|
||||||
|
hasTestCommand?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorktreeTab({
|
export function WorktreeTab({
|
||||||
@@ -85,6 +106,9 @@ export function WorktreeTab({
|
|||||||
hasRemoteBranch,
|
hasRemoteBranch,
|
||||||
gitRepoStatus,
|
gitRepoStatus,
|
||||||
isAutoModeRunning = false,
|
isAutoModeRunning = false,
|
||||||
|
isStartingTests = false,
|
||||||
|
isTestRunning = false,
|
||||||
|
testSessionInfo,
|
||||||
onSelectWorktree,
|
onSelectWorktree,
|
||||||
onBranchDropdownOpenChange,
|
onBranchDropdownOpenChange,
|
||||||
onActionsDropdownOpenChange,
|
onActionsDropdownOpenChange,
|
||||||
@@ -111,7 +135,11 @@ export function WorktreeTab({
|
|||||||
onViewDevServerLogs,
|
onViewDevServerLogs,
|
||||||
onRunInitScript,
|
onRunInitScript,
|
||||||
onToggleAutoMode,
|
onToggleAutoMode,
|
||||||
|
onStartTests,
|
||||||
|
onStopTests,
|
||||||
|
onViewTestLogs,
|
||||||
hasInitScript,
|
hasInitScript,
|
||||||
|
hasTestCommand = false,
|
||||||
}: WorktreeTabProps) {
|
}: WorktreeTabProps) {
|
||||||
// Make the worktree tab a drop target for feature cards
|
// Make the worktree tab a drop target for feature cards
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
@@ -395,6 +423,10 @@ export function WorktreeTab({
|
|||||||
devServerInfo={devServerInfo}
|
devServerInfo={devServerInfo}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
isAutoModeRunning={isAutoModeRunning}
|
isAutoModeRunning={isAutoModeRunning}
|
||||||
|
hasTestCommand={hasTestCommand}
|
||||||
|
isStartingTests={isStartingTests}
|
||||||
|
isTestRunning={isTestRunning}
|
||||||
|
testSessionInfo={testSessionInfo}
|
||||||
onOpenChange={onActionsDropdownOpenChange}
|
onOpenChange={onActionsDropdownOpenChange}
|
||||||
onPull={onPull}
|
onPull={onPull}
|
||||||
onPush={onPush}
|
onPush={onPush}
|
||||||
@@ -416,6 +448,9 @@ export function WorktreeTab({
|
|||||||
onViewDevServerLogs={onViewDevServerLogs}
|
onViewDevServerLogs={onViewDevServerLogs}
|
||||||
onRunInitScript={onRunInitScript}
|
onRunInitScript={onRunInitScript}
|
||||||
onToggleAutoMode={onToggleAutoMode}
|
onToggleAutoMode={onToggleAutoMode}
|
||||||
|
onStartTests={onStartTests}
|
||||||
|
onStopTests={onStopTests}
|
||||||
|
onViewTestLogs={onViewTestLogs}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ export interface DevServerInfo {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TestSessionInfo {
|
||||||
|
sessionId: string;
|
||||||
|
worktreePath: string;
|
||||||
|
/** The test command being run (from project settings) */
|
||||||
|
command: string;
|
||||||
|
status: 'pending' | 'running' | 'passed' | 'failed' | 'cancelled';
|
||||||
|
testFile?: string;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
exitCode?: number | null;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FeatureInfo {
|
export interface FeatureInfo {
|
||||||
id: string;
|
id: string;
|
||||||
branchName?: string;
|
branchName?: string;
|
||||||
|
|||||||
@@ -6,8 +6,15 @@ import { pathsEqual } from '@/lib/utils';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { useIsMobile } from '@/hooks/use-media-query';
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
import { useWorktreeInitScript } from '@/hooks/queries';
|
import { useWorktreeInitScript, useProjectSettings } from '@/hooks/queries';
|
||||||
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
import { useTestRunnerEvents } from '@/hooks/use-test-runners';
|
||||||
|
import { useTestRunnersStore } from '@/store/test-runners-store';
|
||||||
|
import type {
|
||||||
|
TestRunnerStartedEvent,
|
||||||
|
TestRunnerOutputEvent,
|
||||||
|
TestRunnerCompletedEvent,
|
||||||
|
} from '@/types/electron';
|
||||||
|
import type { WorktreePanelProps, WorktreeInfo, TestSessionInfo } from './types';
|
||||||
import {
|
import {
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
useDevServers,
|
useDevServers,
|
||||||
@@ -25,6 +32,7 @@ import {
|
|||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
|
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
|
||||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
|
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
|
||||||
import { Undo2 } from 'lucide-react';
|
import { Undo2 } from 'lucide-react';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
|
||||||
@@ -161,6 +169,194 @@ export function WorktreePanel({
|
|||||||
const { data: initScriptData } = useWorktreeInitScript(projectPath);
|
const { data: initScriptData } = useWorktreeInitScript(projectPath);
|
||||||
const hasInitScript = initScriptData?.exists ?? false;
|
const hasInitScript = initScriptData?.exists ?? false;
|
||||||
|
|
||||||
|
// Check if test command is configured in project settings
|
||||||
|
const { data: projectSettings } = useProjectSettings(projectPath);
|
||||||
|
const hasTestCommand = !!projectSettings?.testCommand;
|
||||||
|
|
||||||
|
// Test runner state management
|
||||||
|
// Use the test runners store to get global state for all worktrees
|
||||||
|
const testRunnersStore = useTestRunnersStore();
|
||||||
|
const [isStartingTests, setIsStartingTests] = useState(false);
|
||||||
|
|
||||||
|
// Subscribe to test runner events to update store state in real-time
|
||||||
|
// This ensures the UI updates when tests start, output is received, or tests complete
|
||||||
|
useTestRunnerEvents(
|
||||||
|
// onStarted - a new test run has begun
|
||||||
|
useCallback(
|
||||||
|
(event: TestRunnerStartedEvent) => {
|
||||||
|
testRunnersStore.startSession({
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
worktreePath: event.worktreePath,
|
||||||
|
command: event.command,
|
||||||
|
status: 'running',
|
||||||
|
testFile: event.testFile,
|
||||||
|
startedAt: event.timestamp,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[testRunnersStore]
|
||||||
|
),
|
||||||
|
// onOutput - test output received
|
||||||
|
useCallback(
|
||||||
|
(event: TestRunnerOutputEvent) => {
|
||||||
|
testRunnersStore.appendOutput(event.sessionId, event.content);
|
||||||
|
},
|
||||||
|
[testRunnersStore]
|
||||||
|
),
|
||||||
|
// onCompleted - test run finished
|
||||||
|
useCallback(
|
||||||
|
(event: TestRunnerCompletedEvent) => {
|
||||||
|
testRunnersStore.completeSession(
|
||||||
|
event.sessionId,
|
||||||
|
event.status,
|
||||||
|
event.exitCode,
|
||||||
|
event.duration
|
||||||
|
);
|
||||||
|
// Show toast notification for test completion
|
||||||
|
const statusEmoji =
|
||||||
|
event.status === 'passed' ? '✅' : event.status === 'failed' ? '❌' : '⏹️';
|
||||||
|
const statusText =
|
||||||
|
event.status === 'passed' ? 'passed' : event.status === 'failed' ? 'failed' : 'stopped';
|
||||||
|
toast(`${statusEmoji} Tests ${statusText}`, {
|
||||||
|
description: `Exit code: ${event.exitCode ?? 'N/A'}`,
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[testRunnersStore]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test logs panel state
|
||||||
|
const [testLogsPanelOpen, setTestLogsPanelOpen] = useState(false);
|
||||||
|
const [testLogsPanelWorktree, setTestLogsPanelWorktree] = useState<WorktreeInfo | null>(null);
|
||||||
|
|
||||||
|
// Helper to check if tests are running for a specific worktree
|
||||||
|
const isTestRunningForWorktree = useCallback(
|
||||||
|
(worktree: WorktreeInfo): boolean => {
|
||||||
|
return testRunnersStore.isWorktreeRunning(worktree.path);
|
||||||
|
},
|
||||||
|
[testRunnersStore]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper to get test session info for a specific worktree
|
||||||
|
const getTestSessionInfo = useCallback(
|
||||||
|
(worktree: WorktreeInfo): TestSessionInfo | undefined => {
|
||||||
|
const session = testRunnersStore.getActiveSession(worktree.path);
|
||||||
|
if (!session) {
|
||||||
|
// Check for completed sessions to show last result
|
||||||
|
const allSessions = Object.values(testRunnersStore.sessions).filter(
|
||||||
|
(s) => s.worktreePath === worktree.path
|
||||||
|
);
|
||||||
|
const lastSession = allSessions.sort(
|
||||||
|
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
||||||
|
)[0];
|
||||||
|
if (lastSession) {
|
||||||
|
return {
|
||||||
|
sessionId: lastSession.sessionId,
|
||||||
|
worktreePath: lastSession.worktreePath,
|
||||||
|
command: lastSession.command,
|
||||||
|
status: lastSession.status as TestSessionInfo['status'],
|
||||||
|
testFile: lastSession.testFile,
|
||||||
|
startedAt: lastSession.startedAt,
|
||||||
|
finishedAt: lastSession.finishedAt,
|
||||||
|
exitCode: lastSession.exitCode,
|
||||||
|
duration: lastSession.duration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
worktreePath: session.worktreePath,
|
||||||
|
command: session.command,
|
||||||
|
status: session.status as TestSessionInfo['status'],
|
||||||
|
testFile: session.testFile,
|
||||||
|
startedAt: session.startedAt,
|
||||||
|
finishedAt: session.finishedAt,
|
||||||
|
exitCode: session.exitCode,
|
||||||
|
duration: session.duration,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[testRunnersStore]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handler to start tests for a worktree
|
||||||
|
const handleStartTests = useCallback(
|
||||||
|
async (worktree: WorktreeInfo) => {
|
||||||
|
setIsStartingTests(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.startTests) {
|
||||||
|
toast.error('Test runner API not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.worktree.startTests(worktree.path, { projectPath });
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('Tests started', {
|
||||||
|
description: `Running tests in ${worktree.branch}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to start tests', {
|
||||||
|
description: result.error || 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to start tests', {
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsStartingTests(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectPath]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handler to stop tests for a worktree
|
||||||
|
const handleStopTests = useCallback(
|
||||||
|
async (worktree: WorktreeInfo) => {
|
||||||
|
try {
|
||||||
|
const session = testRunnersStore.getActiveSession(worktree.path);
|
||||||
|
if (!session) {
|
||||||
|
toast.error('No active test session to stop');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.stopTests) {
|
||||||
|
toast.error('Test runner API not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.worktree.stopTests(session.sessionId);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('Tests stopped', {
|
||||||
|
description: `Stopped tests in ${worktree.branch}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to stop tests', {
|
||||||
|
description: result.error || 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to stop tests', {
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[testRunnersStore]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handler to view test logs for a worktree
|
||||||
|
const handleViewTestLogs = useCallback((worktree: WorktreeInfo) => {
|
||||||
|
setTestLogsPanelWorktree(worktree);
|
||||||
|
setTestLogsPanelOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handler to close test logs panel
|
||||||
|
const handleCloseTestLogsPanel = useCallback(() => {
|
||||||
|
setTestLogsPanelOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// View changes dialog state
|
// View changes dialog state
|
||||||
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
|
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
|
||||||
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
|
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
|
||||||
@@ -392,6 +588,10 @@ export function WorktreePanel({
|
|||||||
devServerInfo={getDevServerInfo(selectedWorktree)}
|
devServerInfo={getDevServerInfo(selectedWorktree)}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
|
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
|
||||||
|
hasTestCommand={hasTestCommand}
|
||||||
|
isStartingTests={isStartingTests}
|
||||||
|
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
|
||||||
|
testSessionInfo={getTestSessionInfo(selectedWorktree)}
|
||||||
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
||||||
onPull={handlePull}
|
onPull={handlePull}
|
||||||
onPush={handlePush}
|
onPush={handlePush}
|
||||||
@@ -413,6 +613,9 @@ export function WorktreePanel({
|
|||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
onRunInitScript={handleRunInitScript}
|
onRunInitScript={handleRunInitScript}
|
||||||
onToggleAutoMode={handleToggleAutoMode}
|
onToggleAutoMode={handleToggleAutoMode}
|
||||||
|
onStartTests={handleStartTests}
|
||||||
|
onStopTests={handleStopTests}
|
||||||
|
onViewTestLogs={handleViewTestLogs}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -494,6 +697,17 @@ export function WorktreePanel({
|
|||||||
onMerged={handleMerged}
|
onMerged={handleMerged}
|
||||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Test Logs Panel */}
|
||||||
|
<TestLogsPanel
|
||||||
|
open={testLogsPanelOpen}
|
||||||
|
onClose={handleCloseTestLogsPanel}
|
||||||
|
worktreePath={testLogsPanelWorktree?.path ?? null}
|
||||||
|
branch={testLogsPanelWorktree?.branch}
|
||||||
|
onStopTests={
|
||||||
|
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -530,6 +744,9 @@ export function WorktreePanel({
|
|||||||
hasRemoteBranch={hasRemoteBranch}
|
hasRemoteBranch={hasRemoteBranch}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||||
|
isStartingTests={isStartingTests}
|
||||||
|
isTestRunning={isTestRunningForWorktree(mainWorktree)}
|
||||||
|
testSessionInfo={getTestSessionInfo(mainWorktree)}
|
||||||
onSelectWorktree={handleSelectWorktree}
|
onSelectWorktree={handleSelectWorktree}
|
||||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||||
@@ -556,7 +773,11 @@ export function WorktreePanel({
|
|||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
onRunInitScript={handleRunInitScript}
|
onRunInitScript={handleRunInitScript}
|
||||||
onToggleAutoMode={handleToggleAutoMode}
|
onToggleAutoMode={handleToggleAutoMode}
|
||||||
|
onStartTests={handleStartTests}
|
||||||
|
onStopTests={handleStopTests}
|
||||||
|
onViewTestLogs={handleViewTestLogs}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
|
hasTestCommand={hasTestCommand}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -596,6 +817,9 @@ export function WorktreePanel({
|
|||||||
hasRemoteBranch={hasRemoteBranch}
|
hasRemoteBranch={hasRemoteBranch}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||||
|
isStartingTests={isStartingTests}
|
||||||
|
isTestRunning={isTestRunningForWorktree(worktree)}
|
||||||
|
testSessionInfo={getTestSessionInfo(worktree)}
|
||||||
onSelectWorktree={handleSelectWorktree}
|
onSelectWorktree={handleSelectWorktree}
|
||||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||||
@@ -622,7 +846,11 @@ export function WorktreePanel({
|
|||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
onRunInitScript={handleRunInitScript}
|
onRunInitScript={handleRunInitScript}
|
||||||
onToggleAutoMode={handleToggleAutoMode}
|
onToggleAutoMode={handleToggleAutoMode}
|
||||||
|
onStartTests={handleStartTests}
|
||||||
|
onStopTests={handleStopTests}
|
||||||
|
onViewTestLogs={handleViewTestLogs}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
|
hasTestCommand={hasTestCommand}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -703,6 +931,17 @@ export function WorktreePanel({
|
|||||||
onMerged={handleMerged}
|
onMerged={handleMerged}
|
||||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Test Logs Panel */}
|
||||||
|
<TestLogsPanel
|
||||||
|
open={testLogsPanelOpen}
|
||||||
|
onClose={handleCloseTestLogsPanel}
|
||||||
|
worktreePath={testLogsPanelWorktree?.path ?? null}
|
||||||
|
branch={testLogsPanelWorktree?.branch}
|
||||||
|
onStopTests={
|
||||||
|
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,6 +392,7 @@ export function GraphViewPage() {
|
|||||||
currentBranch={currentWorktreeBranch || undefined}
|
currentBranch={currentWorktreeBranch || undefined}
|
||||||
isMaximized={false}
|
isMaximized={false}
|
||||||
allFeatures={hookFeatures}
|
allFeatures={hookFeatures}
|
||||||
|
projectPath={currentProject?.path}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Add Feature Dialog (for spawning) */}
|
{/* Add Feature Dialog (for spawning) */}
|
||||||
@@ -414,6 +415,7 @@ export function GraphViewPage() {
|
|||||||
isMaximized={false}
|
isMaximized={false}
|
||||||
parentFeature={spawnParentFeature}
|
parentFeature={spawnParentFeature}
|
||||||
allFeatures={hookFeatures}
|
allFeatures={hookFeatures}
|
||||||
|
projectPath={currentProject?.path}
|
||||||
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
|
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
|
||||||
selectedNonMainWorktreeBranch={
|
selectedNonMainWorktreeBranch={
|
||||||
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { User, GitBranch, Palette, AlertTriangle, Workflow, Database } from 'lucide-react';
|
import {
|
||||||
|
User,
|
||||||
|
GitBranch,
|
||||||
|
Palette,
|
||||||
|
AlertTriangle,
|
||||||
|
Workflow,
|
||||||
|
Database,
|
||||||
|
FlaskConical,
|
||||||
|
} from 'lucide-react';
|
||||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||||
|
|
||||||
export interface ProjectNavigationItem {
|
export interface ProjectNavigationItem {
|
||||||
@@ -11,6 +19,7 @@ export interface ProjectNavigationItem {
|
|||||||
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
||||||
{ id: 'identity', label: 'Identity', icon: User },
|
{ id: 'identity', label: 'Identity', icon: User },
|
||||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||||
|
{ id: 'testing', label: 'Testing', icon: FlaskConical },
|
||||||
{ id: 'theme', label: 'Theme', icon: Palette },
|
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||||
{ id: 'claude', label: 'Models', icon: Workflow },
|
{ id: 'claude', label: 'Models', icon: Workflow },
|
||||||
{ id: 'data', label: 'Data', icon: Database },
|
{ id: 'data', label: 'Data', icon: Database },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type ProjectSettingsViewId =
|
|||||||
| 'identity'
|
| 'identity'
|
||||||
| 'theme'
|
| 'theme'
|
||||||
| 'worktrees'
|
| 'worktrees'
|
||||||
|
| 'testing'
|
||||||
| 'claude'
|
| 'claude'
|
||||||
| 'data'
|
| 'data'
|
||||||
| 'danger';
|
| 'danger';
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ export { ProjectSettingsView } from './project-settings-view';
|
|||||||
export { ProjectIdentitySection } from './project-identity-section';
|
export { ProjectIdentitySection } from './project-identity-section';
|
||||||
export { ProjectThemeSection } from './project-theme-section';
|
export { ProjectThemeSection } from './project-theme-section';
|
||||||
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||||
|
export { TestingSection } from './testing-section';
|
||||||
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
||||||
export { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
export { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import type {
|
|||||||
ClaudeCompatibleProvider,
|
ClaudeCompatibleProvider,
|
||||||
ClaudeModelAlias,
|
ClaudeModelAlias,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types';
|
||||||
|
|
||||||
interface ProjectBulkReplaceDialogProps {
|
interface ProjectBulkReplaceDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -50,6 +50,10 @@ const PHASE_LABELS: Record<PhaseModelKey, string> = {
|
|||||||
|
|
||||||
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
||||||
|
|
||||||
|
// Special key for default feature model (not a phase but included in bulk replace)
|
||||||
|
const DEFAULT_FEATURE_MODEL_KEY = '__defaultFeatureModel__' as const;
|
||||||
|
type ExtendedPhaseKey = PhaseModelKey | typeof DEFAULT_FEATURE_MODEL_KEY;
|
||||||
|
|
||||||
// Claude model display names
|
// Claude model display names
|
||||||
const CLAUDE_MODEL_DISPLAY: Record<ClaudeModelAlias, string> = {
|
const CLAUDE_MODEL_DISPLAY: Record<ClaudeModelAlias, string> = {
|
||||||
haiku: 'Claude Haiku',
|
haiku: 'Claude Haiku',
|
||||||
@@ -62,11 +66,18 @@ export function ProjectBulkReplaceDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
project,
|
project,
|
||||||
}: ProjectBulkReplaceDialogProps) {
|
}: ProjectBulkReplaceDialogProps) {
|
||||||
const { phaseModels, setProjectPhaseModelOverride, claudeCompatibleProviders } = useAppStore();
|
const {
|
||||||
|
phaseModels,
|
||||||
|
setProjectPhaseModelOverride,
|
||||||
|
claudeCompatibleProviders,
|
||||||
|
defaultFeatureModel,
|
||||||
|
setProjectDefaultFeatureModel,
|
||||||
|
} = useAppStore();
|
||||||
const [selectedProvider, setSelectedProvider] = useState<string>('anthropic');
|
const [selectedProvider, setSelectedProvider] = useState<string>('anthropic');
|
||||||
|
|
||||||
// Get project-level overrides
|
// Get project-level overrides
|
||||||
const projectOverrides = project.phaseModelOverrides || {};
|
const projectOverrides = project.phaseModelOverrides || {};
|
||||||
|
const projectDefaultFeatureModel = project.defaultFeatureModel;
|
||||||
|
|
||||||
// Get enabled providers
|
// Get enabled providers
|
||||||
const enabledProviders = useMemo(() => {
|
const enabledProviders = useMemo(() => {
|
||||||
@@ -122,11 +133,15 @@ export function ProjectBulkReplaceDialog({
|
|||||||
const findModelForClaudeAlias = (
|
const findModelForClaudeAlias = (
|
||||||
provider: ClaudeCompatibleProvider | null,
|
provider: ClaudeCompatibleProvider | null,
|
||||||
claudeAlias: ClaudeModelAlias,
|
claudeAlias: ClaudeModelAlias,
|
||||||
phase: PhaseModelKey
|
key: ExtendedPhaseKey
|
||||||
): PhaseModelEntry => {
|
): PhaseModelEntry => {
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
// Anthropic Direct - reset to default phase model (includes correct thinking levels)
|
// Anthropic Direct - reset to default phase model (includes correct thinking levels)
|
||||||
return DEFAULT_PHASE_MODELS[phase];
|
// For default feature model, use the default from global settings
|
||||||
|
if (key === DEFAULT_FEATURE_MODEL_KEY) {
|
||||||
|
return DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel;
|
||||||
|
}
|
||||||
|
return DEFAULT_PHASE_MODELS[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find model that maps to this Claude alias
|
// Find model that maps to this Claude alias
|
||||||
@@ -146,60 +161,91 @@ export function ProjectBulkReplaceDialog({
|
|||||||
return { model: claudeAlias };
|
return { model: claudeAlias };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to generate preview item for any entry
|
||||||
|
const generatePreviewItem = (
|
||||||
|
key: ExtendedPhaseKey,
|
||||||
|
label: string,
|
||||||
|
currentEntry: PhaseModelEntry
|
||||||
|
) => {
|
||||||
|
const claudeAlias = getClaudeModelAlias(currentEntry);
|
||||||
|
const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, key);
|
||||||
|
|
||||||
|
// Get display names
|
||||||
|
const getCurrentDisplay = (): string => {
|
||||||
|
if (currentEntry.providerId) {
|
||||||
|
const provider = enabledProviders.find((p) => p.id === currentEntry.providerId);
|
||||||
|
if (provider) {
|
||||||
|
const model = provider.models?.find((m) => m.id === currentEntry.model);
|
||||||
|
return model?.displayName || currentEntry.model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNewDisplay = (): string => {
|
||||||
|
if (newEntry.providerId && selectedProviderConfig) {
|
||||||
|
const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model);
|
||||||
|
return model?.displayName || newEntry.model;
|
||||||
|
}
|
||||||
|
return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isChanged =
|
||||||
|
currentEntry.model !== newEntry.model ||
|
||||||
|
currentEntry.providerId !== newEntry.providerId ||
|
||||||
|
currentEntry.thinkingLevel !== newEntry.thinkingLevel;
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
claudeAlias,
|
||||||
|
currentDisplay: getCurrentDisplay(),
|
||||||
|
newDisplay: getNewDisplay(),
|
||||||
|
newEntry,
|
||||||
|
isChanged,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Generate preview of changes
|
// Generate preview of changes
|
||||||
const preview = useMemo(() => {
|
const preview = useMemo(() => {
|
||||||
return ALL_PHASES.map((phase) => {
|
// Default feature model entry (first in the list)
|
||||||
// Current effective value (project override or global)
|
const globalDefaultFeature = defaultFeatureModel ?? DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel;
|
||||||
|
const currentDefaultFeature = projectDefaultFeatureModel || globalDefaultFeature;
|
||||||
|
const defaultFeaturePreview = generatePreviewItem(
|
||||||
|
DEFAULT_FEATURE_MODEL_KEY,
|
||||||
|
'Default Feature Model',
|
||||||
|
currentDefaultFeature
|
||||||
|
);
|
||||||
|
|
||||||
|
// Phase model entries
|
||||||
|
const phasePreview = ALL_PHASES.map((phase) => {
|
||||||
const globalEntry = phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase];
|
const globalEntry = phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase];
|
||||||
const currentEntry = projectOverrides[phase] || globalEntry;
|
const currentEntry = projectOverrides[phase] || globalEntry;
|
||||||
const claudeAlias = getClaudeModelAlias(currentEntry);
|
return generatePreviewItem(phase, PHASE_LABELS[phase], currentEntry);
|
||||||
const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, phase);
|
|
||||||
|
|
||||||
// Get display names
|
|
||||||
const getCurrentDisplay = (): string => {
|
|
||||||
if (currentEntry.providerId) {
|
|
||||||
const provider = enabledProviders.find((p) => p.id === currentEntry.providerId);
|
|
||||||
if (provider) {
|
|
||||||
const model = provider.models?.find((m) => m.id === currentEntry.model);
|
|
||||||
return model?.displayName || currentEntry.model;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNewDisplay = (): string => {
|
|
||||||
if (newEntry.providerId && selectedProviderConfig) {
|
|
||||||
const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model);
|
|
||||||
return model?.displayName || newEntry.model;
|
|
||||||
}
|
|
||||||
return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isChanged =
|
|
||||||
currentEntry.model !== newEntry.model ||
|
|
||||||
currentEntry.providerId !== newEntry.providerId ||
|
|
||||||
currentEntry.thinkingLevel !== newEntry.thinkingLevel;
|
|
||||||
|
|
||||||
return {
|
|
||||||
phase,
|
|
||||||
label: PHASE_LABELS[phase],
|
|
||||||
claudeAlias,
|
|
||||||
currentDisplay: getCurrentDisplay(),
|
|
||||||
newDisplay: getNewDisplay(),
|
|
||||||
newEntry,
|
|
||||||
isChanged,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}, [phaseModels, projectOverrides, selectedProviderConfig, enabledProviders]);
|
|
||||||
|
return [defaultFeaturePreview, ...phasePreview];
|
||||||
|
}, [
|
||||||
|
phaseModels,
|
||||||
|
projectOverrides,
|
||||||
|
selectedProviderConfig,
|
||||||
|
enabledProviders,
|
||||||
|
defaultFeatureModel,
|
||||||
|
projectDefaultFeatureModel,
|
||||||
|
]);
|
||||||
|
|
||||||
// Count how many will change
|
// Count how many will change
|
||||||
const changeCount = preview.filter((p) => p.isChanged).length;
|
const changeCount = preview.filter((p) => p.isChanged).length;
|
||||||
|
|
||||||
// Apply the bulk replace as project overrides
|
// Apply the bulk replace as project overrides
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
preview.forEach(({ phase, newEntry, isChanged }) => {
|
preview.forEach(({ key, newEntry, isChanged }) => {
|
||||||
if (isChanged) {
|
if (isChanged) {
|
||||||
setProjectPhaseModelOverride(project.id, phase, newEntry);
|
if (key === DEFAULT_FEATURE_MODEL_KEY) {
|
||||||
|
setProjectDefaultFeatureModel(project.id, newEntry);
|
||||||
|
} else {
|
||||||
|
setProjectPhaseModelOverride(project.id, key as PhaseModelKey, newEntry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -295,7 +341,7 @@ export function ProjectBulkReplaceDialog({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-sm font-medium">Preview Changes</label>
|
<label className="text-sm font-medium">Preview Changes</label>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{changeCount} of {ALL_PHASES.length} will be overridden
|
{changeCount} of {preview.length} will be overridden
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto">
|
<div className="border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto">
|
||||||
@@ -311,15 +357,23 @@ export function ProjectBulkReplaceDialog({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => (
|
{preview.map(({ key, label, currentDisplay, newDisplay, isChanged }) => (
|
||||||
<tr
|
<tr
|
||||||
key={phase}
|
key={key}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-t border-border/50',
|
'border-t border-border/50',
|
||||||
isChanged ? 'bg-brand-500/5' : 'opacity-50'
|
isChanged ? 'bg-brand-500/5' : 'opacity-50',
|
||||||
|
key === DEFAULT_FEATURE_MODEL_KEY && 'bg-accent/30'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<td className="p-2 font-medium">{label}</td>
|
<td className="p-2 font-medium">
|
||||||
|
{label}
|
||||||
|
{key === DEFAULT_FEATURE_MODEL_KEY && (
|
||||||
|
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-brand-500/20 text-brand-500">
|
||||||
|
Feature Default
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="p-2 text-muted-foreground">{currentDisplay}</td>
|
<td className="p-2 text-muted-foreground">{currentDisplay}</td>
|
||||||
<td className="p-2 text-center">
|
<td className="p-2 text-center">
|
||||||
{isChanged ? (
|
{isChanged ? (
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Workflow, RotateCcw, Globe, Check, Replace } from 'lucide-react';
|
import { Workflow, RotateCcw, Globe, Check, Replace, Sparkles } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||||
import { ProjectBulkReplaceDialog } from './project-bulk-replace-dialog';
|
import { ProjectBulkReplaceDialog } from './project-bulk-replace-dialog';
|
||||||
import type { PhaseModelKey, PhaseModelEntry } from '@automaker/types';
|
import type { PhaseModelKey, PhaseModelEntry } from '@automaker/types';
|
||||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types';
|
||||||
|
|
||||||
interface ProjectModelsSectionProps {
|
interface ProjectModelsSectionProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -88,6 +88,127 @@ const MEMORY_TASKS: PhaseConfig[] = [
|
|||||||
|
|
||||||
const ALL_PHASES = [...QUICK_TASKS, ...VALIDATION_TASKS, ...GENERATION_TASKS, ...MEMORY_TASKS];
|
const ALL_PHASES = [...QUICK_TASKS, ...VALIDATION_TASKS, ...GENERATION_TASKS, ...MEMORY_TASKS];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default feature model override section for per-project settings.
|
||||||
|
*/
|
||||||
|
function FeatureDefaultModelOverrideSection({ project }: { project: Project }) {
|
||||||
|
const {
|
||||||
|
defaultFeatureModel: globalDefaultFeatureModel,
|
||||||
|
setProjectDefaultFeatureModel,
|
||||||
|
claudeCompatibleProviders,
|
||||||
|
} = useAppStore();
|
||||||
|
|
||||||
|
const globalValue: PhaseModelEntry =
|
||||||
|
globalDefaultFeatureModel ?? DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel;
|
||||||
|
const projectOverride = project.defaultFeatureModel;
|
||||||
|
const hasOverride = !!projectOverride;
|
||||||
|
const effectiveValue = projectOverride || globalValue;
|
||||||
|
|
||||||
|
// Get display name for a model
|
||||||
|
const getModelDisplayName = (entry: PhaseModelEntry): string => {
|
||||||
|
if (entry.providerId) {
|
||||||
|
const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId);
|
||||||
|
if (provider) {
|
||||||
|
const model = provider.models?.find((m) => m.id === entry.model);
|
||||||
|
if (model) {
|
||||||
|
return `${model.displayName} (${provider.name})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default to model ID for built-in models (both short aliases and canonical IDs)
|
||||||
|
const modelMap: Record<string, string> = {
|
||||||
|
haiku: 'Claude Haiku',
|
||||||
|
sonnet: 'Claude Sonnet',
|
||||||
|
opus: 'Claude Opus',
|
||||||
|
'claude-haiku': 'Claude Haiku',
|
||||||
|
'claude-sonnet': 'Claude Sonnet',
|
||||||
|
'claude-opus': 'Claude Opus',
|
||||||
|
};
|
||||||
|
return modelMap[entry.model] || entry.model;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearOverride = () => {
|
||||||
|
setProjectDefaultFeatureModel(project.id, null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetOverride = (entry: PhaseModelEntry) => {
|
||||||
|
setProjectDefaultFeatureModel(project.id, entry);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Feature Defaults</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Default model for new feature cards in this project
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between p-4 rounded-xl',
|
||||||
|
'bg-accent/20 border',
|
||||||
|
hasOverride ? 'border-brand-500/30 bg-brand-500/5' : 'border-border/30',
|
||||||
|
'hover:bg-accent/30 transition-colors'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex-1 pr-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-brand-500/10 flex items-center justify-center">
|
||||||
|
<Sparkles className="w-4 h-4 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-sm font-medium text-foreground">Default Feature Model</h4>
|
||||||
|
{hasOverride ? (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-brand-500/20 text-brand-500">
|
||||||
|
Override
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground">
|
||||||
|
<Globe className="w-3 h-3" />
|
||||||
|
Global
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 ml-10">
|
||||||
|
Model and thinking level used when creating new feature cards
|
||||||
|
</p>
|
||||||
|
{hasOverride && (
|
||||||
|
<p className="text-xs text-brand-500 mt-1 ml-10">
|
||||||
|
Using: {getModelDisplayName(effectiveValue)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!hasOverride && (
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-1 ml-10">
|
||||||
|
Using global: {getModelDisplayName(globalValue)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasOverride && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClearOverride}
|
||||||
|
className="h-8 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5 mr-1" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<PhaseModelSelector
|
||||||
|
compact
|
||||||
|
value={effectiveValue}
|
||||||
|
onChange={handleSetOverride}
|
||||||
|
align="end"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PhaseOverrideItem({
|
function PhaseOverrideItem({
|
||||||
phase,
|
phase,
|
||||||
project,
|
project,
|
||||||
@@ -234,8 +355,10 @@ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
|
|||||||
useAppStore();
|
useAppStore();
|
||||||
const [showBulkReplace, setShowBulkReplace] = useState(false);
|
const [showBulkReplace, setShowBulkReplace] = useState(false);
|
||||||
|
|
||||||
// Count how many overrides are set
|
// Count how many overrides are set (including defaultFeatureModel)
|
||||||
const overrideCount = Object.keys(project.phaseModelOverrides || {}).length;
|
const phaseOverrideCount = Object.keys(project.phaseModelOverrides || {}).length;
|
||||||
|
const hasDefaultFeatureModelOverride = !!project.defaultFeatureModel;
|
||||||
|
const overrideCount = phaseOverrideCount + (hasDefaultFeatureModelOverride ? 1 : 0);
|
||||||
|
|
||||||
// Check if Claude is available
|
// Check if Claude is available
|
||||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||||
@@ -328,6 +451,9 @@ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-6 space-y-8">
|
<div className="p-6 space-y-8">
|
||||||
|
{/* Feature Defaults */}
|
||||||
|
<FeatureDefaultModelOverrideSection project={project} />
|
||||||
|
|
||||||
{/* Quick Tasks */}
|
{/* Quick Tasks */}
|
||||||
<PhaseGroup
|
<PhaseGroup
|
||||||
title="Quick Tasks"
|
title="Quick Tasks"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { ProjectIdentitySection } from './project-identity-section';
|
import { ProjectIdentitySection } from './project-identity-section';
|
||||||
import { ProjectThemeSection } from './project-theme-section';
|
import { ProjectThemeSection } from './project-theme-section';
|
||||||
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||||
|
import { TestingSection } from './testing-section';
|
||||||
import { ProjectModelsSection } from './project-models-section';
|
import { ProjectModelsSection } from './project-models-section';
|
||||||
import { DataManagementSection } from './data-management-section';
|
import { DataManagementSection } from './data-management-section';
|
||||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||||
@@ -86,6 +87,8 @@ export function ProjectSettingsView() {
|
|||||||
return <ProjectThemeSection project={currentProject} />;
|
return <ProjectThemeSection project={currentProject} />;
|
||||||
case 'worktrees':
|
case 'worktrees':
|
||||||
return <WorktreePreferencesSection project={currentProject} />;
|
return <WorktreePreferencesSection project={currentProject} />;
|
||||||
|
case 'testing':
|
||||||
|
return <TestingSection project={currentProject} />;
|
||||||
case 'claude':
|
case 'claude':
|
||||||
return <ProjectModelsSection project={currentProject} />;
|
return <ProjectModelsSection project={currentProject} />;
|
||||||
case 'data':
|
case 'data':
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FlaskConical, Save, RotateCcw, Info } from 'lucide-react';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { Project } from '@/lib/electron';
|
||||||
|
|
||||||
|
interface TestingSectionProps {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TestingSection({ project }: TestingSectionProps) {
|
||||||
|
const [testCommand, setTestCommand] = useState('');
|
||||||
|
const [originalTestCommand, setOriginalTestCommand] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Check if there are unsaved changes
|
||||||
|
const hasChanges = testCommand !== originalTestCommand;
|
||||||
|
|
||||||
|
// Load project settings when project changes
|
||||||
|
useEffect(() => {
|
||||||
|
let isCancelled = false;
|
||||||
|
const currentPath = project.path;
|
||||||
|
|
||||||
|
const loadProjectSettings = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const httpClient = getHttpApiClient();
|
||||||
|
const response = await httpClient.settings.getProject(currentPath);
|
||||||
|
|
||||||
|
// Avoid updating state if component unmounted or project changed
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
if (response.success && response.settings) {
|
||||||
|
const command = response.settings.testCommand || '';
|
||||||
|
setTestCommand(command);
|
||||||
|
setOriginalTestCommand(command);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCancelled) {
|
||||||
|
console.error('Failed to load project settings:', error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadProjectSettings();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [project.path]);
|
||||||
|
|
||||||
|
// Save test command
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const httpClient = getHttpApiClient();
|
||||||
|
const normalizedCommand = testCommand.trim();
|
||||||
|
const response = await httpClient.settings.updateProject(project.path, {
|
||||||
|
testCommand: normalizedCommand || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setTestCommand(normalizedCommand);
|
||||||
|
setOriginalTestCommand(normalizedCommand);
|
||||||
|
toast.success('Test command saved');
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to save test command', {
|
||||||
|
description: response.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save test command:', error);
|
||||||
|
toast.error('Failed to save test command');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [project.path, testCommand]);
|
||||||
|
|
||||||
|
// Reset to original value
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setTestCommand(originalTestCommand);
|
||||||
|
}, [originalTestCommand]);
|
||||||
|
|
||||||
|
// Use a preset command
|
||||||
|
const handleUsePreset = useCallback((command: string) => {
|
||||||
|
setTestCommand(command);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl overflow-hidden',
|
||||||
|
'border border-border/50',
|
||||||
|
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||||
|
'shadow-sm shadow-black/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||||
|
<FlaskConical className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||||
|
Testing Configuration
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
|
Configure how tests are run for this project.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Spinner size="md" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Test Command Input */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="test-command" className="text-foreground font-medium">
|
||||||
|
Test Command
|
||||||
|
</Label>
|
||||||
|
{hasChanges && (
|
||||||
|
<span className="text-xs text-amber-500 font-medium">(unsaved changes)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="test-command"
|
||||||
|
value={testCommand}
|
||||||
|
onChange={(e) => setTestCommand(e.target.value)}
|
||||||
|
placeholder="e.g., npm test, yarn test, pytest, go test ./..."
|
||||||
|
className="font-mono text-sm"
|
||||||
|
data-testid="test-command-input"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||||
|
The command to run tests for this project. If not specified, the test runner will
|
||||||
|
auto-detect based on your project structure (package.json, Cargo.toml, go.mod,
|
||||||
|
etc.).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-detection Info */}
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
|
||||||
|
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<p className="font-medium text-foreground mb-1">Auto-detection</p>
|
||||||
|
<p>
|
||||||
|
When no custom command is set, the test runner automatically detects and uses the
|
||||||
|
appropriate test framework based on your project files (Vitest, Jest, Pytest,
|
||||||
|
Cargo, Go Test, etc.).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Presets */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-foreground font-medium">Quick Presets</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[
|
||||||
|
{ label: 'npm test', command: 'npm test' },
|
||||||
|
{ label: 'yarn test', command: 'yarn test' },
|
||||||
|
{ label: 'pnpm test', command: 'pnpm test' },
|
||||||
|
{ label: 'bun test', command: 'bun test' },
|
||||||
|
{ label: 'pytest', command: 'pytest' },
|
||||||
|
{ label: 'cargo test', command: 'cargo test' },
|
||||||
|
{ label: 'go test', command: 'go test ./...' },
|
||||||
|
].map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.command}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleUsePreset(preset.command)}
|
||||||
|
className="text-xs font-mono"
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
Click a preset to use it as your test command.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={!hasChanges || isSaving}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || isSaving}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,9 +12,18 @@ const LOG_LEVEL_OPTIONS: { value: ServerLogLevel; label: string; description: st
|
|||||||
{ value: 'debug', label: 'Debug', description: 'Show all messages including debug' },
|
{ value: 'debug', label: 'Debug', description: 'Show all messages including debug' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Check if we're in development mode
|
||||||
|
const IS_DEV = import.meta.env.DEV;
|
||||||
|
|
||||||
export function DeveloperSection() {
|
export function DeveloperSection() {
|
||||||
const { serverLogLevel, setServerLogLevel, enableRequestLogging, setEnableRequestLogging } =
|
const {
|
||||||
useAppStore();
|
serverLogLevel,
|
||||||
|
setServerLogLevel,
|
||||||
|
enableRequestLogging,
|
||||||
|
setEnableRequestLogging,
|
||||||
|
showQueryDevtools,
|
||||||
|
setShowQueryDevtools,
|
||||||
|
} = useAppStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -85,6 +94,28 @@ export function DeveloperSection() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* React Query DevTools - only shown in development mode */}
|
||||||
|
{IS_DEV && (
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-border/30">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-foreground font-medium">React Query DevTools</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Show React Query DevTools panel in the bottom-right corner for debugging queries and
|
||||||
|
cache.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={showQueryDevtools}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setShowQueryDevtools(checked);
|
||||||
|
toast.success(checked ? 'Query DevTools enabled' : 'Query DevTools disabled', {
|
||||||
|
description: 'React Query DevTools visibility updated',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import type {
|
|||||||
ClaudeCompatibleProvider,
|
ClaudeCompatibleProvider,
|
||||||
ClaudeModelAlias,
|
ClaudeModelAlias,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types';
|
||||||
|
|
||||||
interface BulkReplaceDialogProps {
|
interface BulkReplaceDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -48,6 +48,10 @@ const PHASE_LABELS: Record<PhaseModelKey, string> = {
|
|||||||
|
|
||||||
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
||||||
|
|
||||||
|
// Special key for default feature model (not a phase but included in bulk replace)
|
||||||
|
const DEFAULT_FEATURE_MODEL_KEY = '__defaultFeatureModel__' as const;
|
||||||
|
type ExtendedPhaseKey = PhaseModelKey | typeof DEFAULT_FEATURE_MODEL_KEY;
|
||||||
|
|
||||||
// Claude model display names
|
// Claude model display names
|
||||||
const CLAUDE_MODEL_DISPLAY: Record<ClaudeModelAlias, string> = {
|
const CLAUDE_MODEL_DISPLAY: Record<ClaudeModelAlias, string> = {
|
||||||
haiku: 'Claude Haiku',
|
haiku: 'Claude Haiku',
|
||||||
@@ -56,7 +60,13 @@ const CLAUDE_MODEL_DISPLAY: Record<ClaudeModelAlias, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps) {
|
export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps) {
|
||||||
const { phaseModels, setPhaseModel, claudeCompatibleProviders } = useAppStore();
|
const {
|
||||||
|
phaseModels,
|
||||||
|
setPhaseModel,
|
||||||
|
claudeCompatibleProviders,
|
||||||
|
defaultFeatureModel,
|
||||||
|
setDefaultFeatureModel,
|
||||||
|
} = useAppStore();
|
||||||
const [selectedProvider, setSelectedProvider] = useState<string>('anthropic');
|
const [selectedProvider, setSelectedProvider] = useState<string>('anthropic');
|
||||||
|
|
||||||
// Get enabled providers
|
// Get enabled providers
|
||||||
@@ -113,11 +123,15 @@ export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps
|
|||||||
const findModelForClaudeAlias = (
|
const findModelForClaudeAlias = (
|
||||||
provider: ClaudeCompatibleProvider | null,
|
provider: ClaudeCompatibleProvider | null,
|
||||||
claudeAlias: ClaudeModelAlias,
|
claudeAlias: ClaudeModelAlias,
|
||||||
phase: PhaseModelKey
|
key: ExtendedPhaseKey
|
||||||
): PhaseModelEntry => {
|
): PhaseModelEntry => {
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
// Anthropic Direct - reset to default phase model (includes correct thinking levels)
|
// Anthropic Direct - reset to default phase model (includes correct thinking levels)
|
||||||
return DEFAULT_PHASE_MODELS[phase];
|
// For default feature model, use the default from global settings
|
||||||
|
if (key === DEFAULT_FEATURE_MODEL_KEY) {
|
||||||
|
return DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel;
|
||||||
|
}
|
||||||
|
return DEFAULT_PHASE_MODELS[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find model that maps to this Claude alias
|
// Find model that maps to this Claude alias
|
||||||
@@ -137,58 +151,83 @@ export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps
|
|||||||
return { model: claudeAlias };
|
return { model: claudeAlias };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to generate preview item for any entry
|
||||||
|
const generatePreviewItem = (
|
||||||
|
key: ExtendedPhaseKey,
|
||||||
|
label: string,
|
||||||
|
currentEntry: PhaseModelEntry
|
||||||
|
) => {
|
||||||
|
const claudeAlias = getClaudeModelAlias(currentEntry);
|
||||||
|
const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, key);
|
||||||
|
|
||||||
|
// Get display names
|
||||||
|
const getCurrentDisplay = (): string => {
|
||||||
|
if (currentEntry.providerId) {
|
||||||
|
const provider = enabledProviders.find((p) => p.id === currentEntry.providerId);
|
||||||
|
if (provider) {
|
||||||
|
const model = provider.models?.find((m) => m.id === currentEntry.model);
|
||||||
|
return model?.displayName || currentEntry.model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNewDisplay = (): string => {
|
||||||
|
if (newEntry.providerId && selectedProviderConfig) {
|
||||||
|
const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model);
|
||||||
|
return model?.displayName || newEntry.model;
|
||||||
|
}
|
||||||
|
return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isChanged =
|
||||||
|
currentEntry.model !== newEntry.model ||
|
||||||
|
currentEntry.providerId !== newEntry.providerId ||
|
||||||
|
currentEntry.thinkingLevel !== newEntry.thinkingLevel;
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
claudeAlias,
|
||||||
|
currentDisplay: getCurrentDisplay(),
|
||||||
|
newDisplay: getNewDisplay(),
|
||||||
|
newEntry,
|
||||||
|
isChanged,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Generate preview of changes
|
// Generate preview of changes
|
||||||
const preview = useMemo(() => {
|
const preview = useMemo(() => {
|
||||||
return ALL_PHASES.map((phase) => {
|
// Default feature model entry (first in the list)
|
||||||
|
const defaultFeatureModelEntry =
|
||||||
|
defaultFeatureModel ?? DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel;
|
||||||
|
const defaultFeaturePreview = generatePreviewItem(
|
||||||
|
DEFAULT_FEATURE_MODEL_KEY,
|
||||||
|
'Default Feature Model',
|
||||||
|
defaultFeatureModelEntry
|
||||||
|
);
|
||||||
|
|
||||||
|
// Phase model entries
|
||||||
|
const phasePreview = ALL_PHASES.map((phase) => {
|
||||||
const currentEntry = phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase];
|
const currentEntry = phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase];
|
||||||
const claudeAlias = getClaudeModelAlias(currentEntry);
|
return generatePreviewItem(phase, PHASE_LABELS[phase], currentEntry);
|
||||||
const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, phase);
|
|
||||||
|
|
||||||
// Get display names
|
|
||||||
const getCurrentDisplay = (): string => {
|
|
||||||
if (currentEntry.providerId) {
|
|
||||||
const provider = enabledProviders.find((p) => p.id === currentEntry.providerId);
|
|
||||||
if (provider) {
|
|
||||||
const model = provider.models?.find((m) => m.id === currentEntry.model);
|
|
||||||
return model?.displayName || currentEntry.model;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNewDisplay = (): string => {
|
|
||||||
if (newEntry.providerId && selectedProviderConfig) {
|
|
||||||
const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model);
|
|
||||||
return model?.displayName || newEntry.model;
|
|
||||||
}
|
|
||||||
return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isChanged =
|
|
||||||
currentEntry.model !== newEntry.model ||
|
|
||||||
currentEntry.providerId !== newEntry.providerId ||
|
|
||||||
currentEntry.thinkingLevel !== newEntry.thinkingLevel;
|
|
||||||
|
|
||||||
return {
|
|
||||||
phase,
|
|
||||||
label: PHASE_LABELS[phase],
|
|
||||||
claudeAlias,
|
|
||||||
currentDisplay: getCurrentDisplay(),
|
|
||||||
newDisplay: getNewDisplay(),
|
|
||||||
newEntry,
|
|
||||||
isChanged,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}, [phaseModels, selectedProviderConfig, enabledProviders]);
|
|
||||||
|
return [defaultFeaturePreview, ...phasePreview];
|
||||||
|
}, [phaseModels, selectedProviderConfig, enabledProviders, defaultFeatureModel]);
|
||||||
|
|
||||||
// Count how many will change
|
// Count how many will change
|
||||||
const changeCount = preview.filter((p) => p.isChanged).length;
|
const changeCount = preview.filter((p) => p.isChanged).length;
|
||||||
|
|
||||||
// Apply the bulk replace
|
// Apply the bulk replace
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
preview.forEach(({ phase, newEntry, isChanged }) => {
|
preview.forEach(({ key, newEntry, isChanged }) => {
|
||||||
if (isChanged) {
|
if (isChanged) {
|
||||||
setPhaseModel(phase, newEntry);
|
if (key === DEFAULT_FEATURE_MODEL_KEY) {
|
||||||
|
setDefaultFeatureModel(newEntry);
|
||||||
|
} else {
|
||||||
|
setPhaseModel(key as PhaseModelKey, newEntry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -284,7 +323,7 @@ export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-sm font-medium">Preview Changes</label>
|
<label className="text-sm font-medium">Preview Changes</label>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{changeCount} of {ALL_PHASES.length} will change
|
{changeCount} of {preview.length} will change
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto">
|
<div className="border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto">
|
||||||
@@ -298,15 +337,23 @@ export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => (
|
{preview.map(({ key, label, currentDisplay, newDisplay, isChanged }) => (
|
||||||
<tr
|
<tr
|
||||||
key={phase}
|
key={key}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-t border-border/50',
|
'border-t border-border/50',
|
||||||
isChanged ? 'bg-brand-500/5' : 'opacity-50'
|
isChanged ? 'bg-brand-500/5' : 'opacity-50',
|
||||||
|
key === DEFAULT_FEATURE_MODEL_KEY && 'bg-accent/30'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<td className="p-2 font-medium">{label}</td>
|
<td className="p-2 font-medium">
|
||||||
|
{label}
|
||||||
|
{key === DEFAULT_FEATURE_MODEL_KEY && (
|
||||||
|
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-brand-500/20 text-brand-500">
|
||||||
|
Feature Default
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="p-2 text-muted-foreground">{currentDisplay}</td>
|
<td className="p-2 text-muted-foreground">{currentDisplay}</td>
|
||||||
<td className="p-2 text-center">
|
<td className="p-2 text-center">
|
||||||
{isChanged ? (
|
{isChanged ? (
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Workflow, RotateCcw, Replace } from 'lucide-react';
|
import { Workflow, RotateCcw, Replace, Sparkles } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PhaseModelSelector } from './phase-model-selector';
|
import { PhaseModelSelector } from './phase-model-selector';
|
||||||
import { BulkReplaceDialog } from './bulk-replace-dialog';
|
import { BulkReplaceDialog } from './bulk-replace-dialog';
|
||||||
import type { PhaseModelKey } from '@automaker/types';
|
import type { PhaseModelKey, PhaseModelEntry } from '@automaker/types';
|
||||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types';
|
||||||
|
|
||||||
interface PhaseConfig {
|
interface PhaseConfig {
|
||||||
key: PhaseModelKey;
|
key: PhaseModelKey;
|
||||||
@@ -113,6 +113,54 @@ function PhaseGroup({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default model for new feature cards section.
|
||||||
|
* This is separate from phase models but logically belongs with model configuration.
|
||||||
|
*/
|
||||||
|
function FeatureDefaultModelSection() {
|
||||||
|
const { defaultFeatureModel, setDefaultFeatureModel } = useAppStore();
|
||||||
|
const defaultValue: PhaseModelEntry =
|
||||||
|
defaultFeatureModel ?? DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Feature Defaults</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Default model for new feature cards when created
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between p-4 rounded-xl',
|
||||||
|
'bg-accent/20 border border-border/30',
|
||||||
|
'hover:bg-accent/30 transition-colors'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 pr-4">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-brand-500/10 flex items-center justify-center">
|
||||||
|
<Sparkles className="w-4 h-4 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-foreground">Default Feature Model</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Model and thinking level used when creating new feature cards
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PhaseModelSelector
|
||||||
|
compact
|
||||||
|
value={defaultValue}
|
||||||
|
onChange={setDefaultFeatureModel}
|
||||||
|
align="end"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ModelDefaultsSection() {
|
export function ModelDefaultsSection() {
|
||||||
const { resetPhaseModels, claudeCompatibleProviders } = useAppStore();
|
const { resetPhaseModels, claudeCompatibleProviders } = useAppStore();
|
||||||
const [showBulkReplace, setShowBulkReplace] = useState(false);
|
const [showBulkReplace, setShowBulkReplace] = useState(false);
|
||||||
@@ -171,6 +219,9 @@ export function ModelDefaultsSection() {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-6 space-y-8">
|
<div className="p-6 space-y-8">
|
||||||
|
{/* Feature Defaults */}
|
||||||
|
<FeatureDefaultModelSection />
|
||||||
|
|
||||||
{/* Quick Tasks */}
|
{/* Quick Tasks */}
|
||||||
<PhaseGroup
|
<PhaseGroup
|
||||||
title="Quick Tasks"
|
title="Quick Tasks"
|
||||||
|
|||||||
@@ -522,6 +522,9 @@ export function PhaseModelSelector({
|
|||||||
return [...staticModels, ...uniqueDynamic];
|
return [...staticModels, ...uniqueDynamic];
|
||||||
}, [dynamicOpencodeModels, enabledDynamicModelIds]);
|
}, [dynamicOpencodeModels, enabledDynamicModelIds]);
|
||||||
|
|
||||||
|
// Check if providers are disabled (needed for rendering conditions)
|
||||||
|
const isCursorDisabled = disabledProviders.includes('cursor');
|
||||||
|
|
||||||
// Group models (filtering out disabled providers)
|
// Group models (filtering out disabled providers)
|
||||||
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
|
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
|
||||||
const favs: typeof CLAUDE_MODELS = [];
|
const favs: typeof CLAUDE_MODELS = [];
|
||||||
@@ -531,7 +534,6 @@ export function PhaseModelSelector({
|
|||||||
const ocModels: ModelOption[] = [];
|
const ocModels: ModelOption[] = [];
|
||||||
|
|
||||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||||
const isCursorDisabled = disabledProviders.includes('cursor');
|
|
||||||
const isCodexDisabled = disabledProviders.includes('codex');
|
const isCodexDisabled = disabledProviders.includes('codex');
|
||||||
const isOpencodeDisabled = disabledProviders.includes('opencode');
|
const isOpencodeDisabled = disabledProviders.includes('opencode');
|
||||||
|
|
||||||
@@ -1900,7 +1902,7 @@ export function PhaseModelSelector({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{(groupedModels.length > 0 || standaloneCursorModels.length > 0) && (
|
{!isCursorDisabled && (groupedModels.length > 0 || standaloneCursorModels.length > 0) && (
|
||||||
<CommandGroup heading="Cursor Models">
|
<CommandGroup heading="Cursor Models">
|
||||||
{/* Grouped models with secondary popover */}
|
{/* Grouped models with secondary popover */}
|
||||||
{groupedModels.map((group) => renderGroupedModelItem(group))}
|
{groupedModels.map((group) => renderGroupedModelItem(group))}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { useRef, useState, useEffect } from 'react';
|
import { useRef, useState, useEffect } from 'react';
|
||||||
|
import { generateUUID } from '@/lib/utils';
|
||||||
|
|
||||||
interface ArrayFieldEditorProps {
|
interface ArrayFieldEditorProps {
|
||||||
values: string[];
|
values: string[];
|
||||||
@@ -17,10 +18,6 @@ interface ItemWithId {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateId(): string {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ArrayFieldEditor({
|
export function ArrayFieldEditor({
|
||||||
values,
|
values,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -30,7 +27,7 @@ export function ArrayFieldEditor({
|
|||||||
}: ArrayFieldEditorProps) {
|
}: ArrayFieldEditorProps) {
|
||||||
// Track items with stable IDs
|
// Track items with stable IDs
|
||||||
const [items, setItems] = useState<ItemWithId[]>(() =>
|
const [items, setItems] = useState<ItemWithId[]>(() =>
|
||||||
values.map((value) => ({ id: generateId(), value }))
|
values.map((value) => ({ id: generateUUID(), value }))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track if we're making an internal change to avoid sync loops
|
// Track if we're making an internal change to avoid sync loops
|
||||||
@@ -44,11 +41,11 @@ export function ArrayFieldEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// External change - rebuild items with new IDs
|
// External change - rebuild items with new IDs
|
||||||
setItems(values.map((value) => ({ id: generateId(), value })));
|
setItems(values.map((value) => ({ id: generateUUID(), value })));
|
||||||
}, [values]);
|
}, [values]);
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
const newItems = [...items, { id: generateId(), value: '' }];
|
const newItems = [...items, { id: generateUUID(), value: '' }];
|
||||||
setItems(newItems);
|
setItems(newItems);
|
||||||
isInternalChange.current = true;
|
isInternalChange.current = true;
|
||||||
onChange(newItems.map((item) => item.value));
|
onChange(newItems.map((item) => item.value));
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { ListChecks } from 'lucide-react';
|
import { ListChecks } from 'lucide-react';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import type { SpecOutput } from '@automaker/spec-parser';
|
import type { SpecOutput } from '@automaker/spec-parser';
|
||||||
|
import { generateUUID } from '@/lib/utils';
|
||||||
|
|
||||||
type Feature = SpecOutput['implemented_features'][number];
|
type Feature = SpecOutput['implemented_features'][number];
|
||||||
|
|
||||||
@@ -22,15 +23,11 @@ interface FeatureWithId extends Feature {
|
|||||||
_locationIds?: string[];
|
_locationIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateId(): string {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
function featureToInternal(feature: Feature): FeatureWithId {
|
function featureToInternal(feature: Feature): FeatureWithId {
|
||||||
return {
|
return {
|
||||||
...feature,
|
...feature,
|
||||||
_id: generateId(),
|
_id: generateUUID(),
|
||||||
_locationIds: feature.file_locations?.map(() => generateId()),
|
_locationIds: feature.file_locations?.map(() => generateUUID()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +60,7 @@ function FeatureCard({ feature, index, onChange, onRemove }: FeatureCardProps) {
|
|||||||
onChange({
|
onChange({
|
||||||
...feature,
|
...feature,
|
||||||
file_locations: [...locations, ''],
|
file_locations: [...locations, ''],
|
||||||
_locationIds: [...locationIds, generateId()],
|
_locationIds: [...locationIds, generateUUID()],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import type { SpecOutput } from '@automaker/spec-parser';
|
import type { SpecOutput } from '@automaker/spec-parser';
|
||||||
|
import { generateUUID } from '@/lib/utils';
|
||||||
|
|
||||||
type RoadmapPhase = NonNullable<SpecOutput['implementation_roadmap']>[number];
|
type RoadmapPhase = NonNullable<SpecOutput['implementation_roadmap']>[number];
|
||||||
type PhaseStatus = 'completed' | 'in_progress' | 'pending';
|
type PhaseStatus = 'completed' | 'in_progress' | 'pending';
|
||||||
@@ -21,12 +22,8 @@ interface PhaseWithId extends RoadmapPhase {
|
|||||||
_id: string;
|
_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateId(): string {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
function phaseToInternal(phase: RoadmapPhase): PhaseWithId {
|
function phaseToInternal(phase: RoadmapPhase): PhaseWithId {
|
||||||
return { ...phase, _id: generateId() };
|
return { ...phase, _id: generateUUID() };
|
||||||
}
|
}
|
||||||
|
|
||||||
function internalToPhase(internal: PhaseWithId): RoadmapPhase {
|
function internalToPhase(internal: PhaseWithId): RoadmapPhase {
|
||||||
|
|||||||
@@ -8,4 +8,18 @@ export { useOSDetection, type OperatingSystem, type OSDetectionResult } from './
|
|||||||
export { useResponsiveKanban } from './use-responsive-kanban';
|
export { useResponsiveKanban } from './use-responsive-kanban';
|
||||||
export { useScrollTracking } from './use-scroll-tracking';
|
export { useScrollTracking } from './use-scroll-tracking';
|
||||||
export { useSettingsMigration } from './use-settings-migration';
|
export { useSettingsMigration } from './use-settings-migration';
|
||||||
|
export {
|
||||||
|
useTestRunners,
|
||||||
|
useTestRunnerEvents,
|
||||||
|
type StartTestOptions,
|
||||||
|
type StartTestResult,
|
||||||
|
type StopTestResult,
|
||||||
|
type TestSession,
|
||||||
|
} from './use-test-runners';
|
||||||
|
export {
|
||||||
|
useTestLogs,
|
||||||
|
useTestLogEvents,
|
||||||
|
type TestLogState,
|
||||||
|
type UseTestLogsOptions,
|
||||||
|
} from './use-test-logs';
|
||||||
export { useWindowState } from './use-window-state';
|
export { useWindowState } from './use-window-state';
|
||||||
|
|||||||
@@ -713,6 +713,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
muteDoneSound: settings.muteDoneSound ?? false,
|
muteDoneSound: settings.muteDoneSound ?? false,
|
||||||
serverLogLevel: settings.serverLogLevel ?? 'info',
|
serverLogLevel: settings.serverLogLevel ?? 'info',
|
||||||
enableRequestLogging: settings.enableRequestLogging ?? true,
|
enableRequestLogging: settings.enableRequestLogging ?? true,
|
||||||
|
showQueryDevtools: settings.showQueryDevtools ?? true,
|
||||||
enhancementModel: settings.enhancementModel ?? 'claude-sonnet',
|
enhancementModel: settings.enhancementModel ?? 'claude-sonnet',
|
||||||
validationModel: settings.validationModel ?? 'claude-opus',
|
validationModel: settings.validationModel ?? 'claude-opus',
|
||||||
phaseModels: settings.phaseModels ?? current.phaseModels,
|
phaseModels: settings.phaseModels ?? current.phaseModels,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'muteDoneSound',
|
'muteDoneSound',
|
||||||
'serverLogLevel',
|
'serverLogLevel',
|
||||||
'enableRequestLogging',
|
'enableRequestLogging',
|
||||||
|
'showQueryDevtools',
|
||||||
'enhancementModel',
|
'enhancementModel',
|
||||||
'validationModel',
|
'validationModel',
|
||||||
'phaseModels',
|
'phaseModels',
|
||||||
|
|||||||
396
apps/ui/src/hooks/use-test-logs.ts
Normal file
396
apps/ui/src/hooks/use-test-logs.ts
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
/**
|
||||||
|
* useTestLogs - Hook for test log streaming and retrieval
|
||||||
|
*
|
||||||
|
* This hook provides a focused interface for:
|
||||||
|
* - Fetching initial buffered test logs
|
||||||
|
* - Subscribing to real-time log streaming
|
||||||
|
* - Managing log state for display components
|
||||||
|
*
|
||||||
|
* Unlike useTestRunners, this hook focuses solely on log retrieval
|
||||||
|
* and streaming, making it ideal for log display components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { pathsEqual } from '@/lib/utils';
|
||||||
|
import type {
|
||||||
|
TestRunStatus,
|
||||||
|
TestRunnerStartedEvent,
|
||||||
|
TestRunnerOutputEvent,
|
||||||
|
TestRunnerCompletedEvent,
|
||||||
|
} from '@/types/electron';
|
||||||
|
|
||||||
|
const logger = createLogger('TestLogs');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for test log management
|
||||||
|
*/
|
||||||
|
export interface TestLogState {
|
||||||
|
/** The accumulated log content */
|
||||||
|
logs: string;
|
||||||
|
/** Whether initial logs are being fetched */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Error message if fetching logs failed */
|
||||||
|
error: string | null;
|
||||||
|
/** Current status of the test run */
|
||||||
|
status: TestRunStatus | null;
|
||||||
|
/** Session ID of the current test run */
|
||||||
|
sessionId: string | null;
|
||||||
|
/** The test command being run (from project settings) */
|
||||||
|
command: string | null;
|
||||||
|
/** Specific test file being run (if applicable) */
|
||||||
|
testFile: string | null;
|
||||||
|
/** Timestamp when the test run started */
|
||||||
|
startedAt: string | null;
|
||||||
|
/** Timestamp when the test run finished (if completed) */
|
||||||
|
finishedAt: string | null;
|
||||||
|
/** Exit code (if test run completed) */
|
||||||
|
exitCode: number | null;
|
||||||
|
/** Duration in milliseconds (if completed) */
|
||||||
|
duration: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for the useTestLogs hook
|
||||||
|
*/
|
||||||
|
export interface UseTestLogsOptions {
|
||||||
|
/** Path to the worktree to monitor logs for */
|
||||||
|
worktreePath: string | null;
|
||||||
|
/** Specific session ID to fetch logs for (optional - will get active/recent if not provided) */
|
||||||
|
sessionId?: string;
|
||||||
|
/** Whether to automatically subscribe to log events (default: true) */
|
||||||
|
autoSubscribe?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Initial State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const initialState: TestLogState = {
|
||||||
|
logs: '',
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
status: null,
|
||||||
|
sessionId: null,
|
||||||
|
command: null,
|
||||||
|
testFile: null,
|
||||||
|
startedAt: null,
|
||||||
|
finishedAt: null,
|
||||||
|
exitCode: null,
|
||||||
|
duration: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hook
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to subscribe to test log events and manage log state.
|
||||||
|
*
|
||||||
|
* This hook:
|
||||||
|
* 1. Fetches initial buffered logs from the server
|
||||||
|
* 2. Subscribes to WebSocket events for real-time log streaming
|
||||||
|
* 3. Handles test runner started/output/completed events
|
||||||
|
* 4. Provides log state for rendering in a panel
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { logs, status, isLoading, isRunning } = useTestLogs({
|
||||||
|
* worktreePath: '/path/to/worktree'
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <div>
|
||||||
|
* {isLoading && <Spinner />}
|
||||||
|
* {isRunning && <Badge>Running</Badge>}
|
||||||
|
* <pre>{logs}</pre>
|
||||||
|
* </div>
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useTestLogs({
|
||||||
|
worktreePath,
|
||||||
|
sessionId: targetSessionId,
|
||||||
|
autoSubscribe = true,
|
||||||
|
}: UseTestLogsOptions) {
|
||||||
|
const [state, setState] = useState<TestLogState>(initialState);
|
||||||
|
|
||||||
|
// Keep track of whether we've fetched initial logs
|
||||||
|
const hasFetchedInitialLogs = useRef(false);
|
||||||
|
|
||||||
|
// Track the current session ID for filtering events
|
||||||
|
const currentSessionId = useRef<string | null>(targetSessionId ?? null);
|
||||||
|
|
||||||
|
// Guard against stale fetch results when switching worktrees/sessions
|
||||||
|
const fetchSeq = useRef(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived state: whether tests are currently running
|
||||||
|
*/
|
||||||
|
const isRunning = state.status === 'running' || state.status === 'pending';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch buffered logs from the server
|
||||||
|
*/
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
if (!worktreePath && !targetSessionId) return;
|
||||||
|
|
||||||
|
// Increment sequence to guard against stale responses
|
||||||
|
const seq = ++fetchSeq.current;
|
||||||
|
|
||||||
|
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.getTestLogs) {
|
||||||
|
// Check if this request is still current
|
||||||
|
if (seq !== fetchSeq.current) return;
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
error: 'Test logs API not available',
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.worktree.getTestLogs(worktreePath ?? undefined, targetSessionId);
|
||||||
|
|
||||||
|
// Check if this request is still current (prevent stale updates)
|
||||||
|
if (seq !== fetchSeq.current) return;
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
const { sessionId, command, status, testFile, logs, startedAt, finishedAt, exitCode } =
|
||||||
|
result.result;
|
||||||
|
|
||||||
|
// Update current session ID for event filtering
|
||||||
|
currentSessionId.current = sessionId;
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
logs,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
status,
|
||||||
|
sessionId,
|
||||||
|
command,
|
||||||
|
testFile: testFile ?? null,
|
||||||
|
startedAt,
|
||||||
|
finishedAt,
|
||||||
|
exitCode,
|
||||||
|
duration: null, // Not provided by getTestLogs
|
||||||
|
}));
|
||||||
|
hasFetchedInitialLogs.current = true;
|
||||||
|
} else {
|
||||||
|
// No active session - this is not necessarily an error
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
error: result.error || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Check if this request is still current
|
||||||
|
if (seq !== fetchSeq.current) return;
|
||||||
|
logger.error('Failed to fetch test logs:', error);
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch logs',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [worktreePath, targetSessionId]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear logs and reset state
|
||||||
|
*/
|
||||||
|
const clearLogs = useCallback(() => {
|
||||||
|
setState(initialState);
|
||||||
|
hasFetchedInitialLogs.current = false;
|
||||||
|
currentSessionId.current = targetSessionId ?? null;
|
||||||
|
}, [targetSessionId]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append content to logs
|
||||||
|
*/
|
||||||
|
const appendLogs = useCallback((content: string) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
logs: prev.logs + content,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch initial logs when worktreePath or sessionId changes
|
||||||
|
useEffect(() => {
|
||||||
|
if ((worktreePath || targetSessionId) && autoSubscribe) {
|
||||||
|
hasFetchedInitialLogs.current = false;
|
||||||
|
fetchLogs();
|
||||||
|
} else {
|
||||||
|
clearLogs();
|
||||||
|
}
|
||||||
|
}, [worktreePath, targetSessionId, autoSubscribe, fetchLogs, clearLogs]);
|
||||||
|
|
||||||
|
// Subscribe to WebSocket events
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoSubscribe) return;
|
||||||
|
if (!worktreePath && !targetSessionId) return;
|
||||||
|
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.onTestRunnerEvent) {
|
||||||
|
logger.warn('Test runner event subscription not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = api.worktree.onTestRunnerEvent((event) => {
|
||||||
|
// Filter events based on worktree path or session ID
|
||||||
|
const eventWorktreePath = event.payload.worktreePath;
|
||||||
|
const eventSessionId = event.payload.sessionId;
|
||||||
|
|
||||||
|
// If we have a specific session ID target, only accept events for that session
|
||||||
|
if (targetSessionId && eventSessionId !== targetSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a worktree path, filter by that
|
||||||
|
if (worktreePath && !pathsEqual(eventWorktreePath, worktreePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'test-runner:started': {
|
||||||
|
const payload = event.payload as TestRunnerStartedEvent;
|
||||||
|
logger.info('Test run started:', payload);
|
||||||
|
|
||||||
|
// Update current session ID for future event filtering
|
||||||
|
currentSessionId.current = payload.sessionId;
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'running',
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
command: payload.command,
|
||||||
|
testFile: payload.testFile ?? null,
|
||||||
|
startedAt: payload.timestamp,
|
||||||
|
finishedAt: null,
|
||||||
|
exitCode: null,
|
||||||
|
duration: null,
|
||||||
|
// Clear logs on new test run start
|
||||||
|
logs: '',
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
hasFetchedInitialLogs.current = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'test-runner:output': {
|
||||||
|
const payload = event.payload as TestRunnerOutputEvent;
|
||||||
|
|
||||||
|
// Only append if this is for our current session
|
||||||
|
if (currentSessionId.current && payload.sessionId !== currentSessionId.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the new output to existing logs
|
||||||
|
if (payload.content) {
|
||||||
|
appendLogs(payload.content);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'test-runner:completed': {
|
||||||
|
const payload = event.payload as TestRunnerCompletedEvent;
|
||||||
|
logger.info('Test run completed:', payload);
|
||||||
|
|
||||||
|
// Only update if this is for our current session
|
||||||
|
if (currentSessionId.current && payload.sessionId !== currentSessionId.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: payload.status,
|
||||||
|
finishedAt: payload.timestamp,
|
||||||
|
exitCode: payload.exitCode,
|
||||||
|
duration: payload.duration,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [worktreePath, targetSessionId, autoSubscribe, appendLogs]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
...state,
|
||||||
|
|
||||||
|
// Derived state
|
||||||
|
/** Whether tests are currently running */
|
||||||
|
isRunning,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
/** Fetch/refresh logs from the server */
|
||||||
|
fetchLogs,
|
||||||
|
/** Clear logs and reset state */
|
||||||
|
clearLogs,
|
||||||
|
/** Manually append content to logs */
|
||||||
|
appendLogs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for subscribing to test log output events globally (across all sessions)
|
||||||
|
*
|
||||||
|
* Useful for notification systems or global log monitoring.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* useTestLogEvents({
|
||||||
|
* onOutput: (sessionId, content) => {
|
||||||
|
* console.log(`[${sessionId}] ${content}`);
|
||||||
|
* },
|
||||||
|
* onCompleted: (sessionId, status) => {
|
||||||
|
* toast(`Tests ${status}!`);
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useTestLogEvents(handlers: {
|
||||||
|
onStarted?: (event: TestRunnerStartedEvent) => void;
|
||||||
|
onOutput?: (event: TestRunnerOutputEvent) => void;
|
||||||
|
onCompleted?: (event: TestRunnerCompletedEvent) => void;
|
||||||
|
}) {
|
||||||
|
const { onStarted, onOutput, onCompleted } = handlers;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.onTestRunnerEvent) {
|
||||||
|
logger.warn('Test runner event subscription not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = api.worktree.onTestRunnerEvent((event) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'test-runner:started':
|
||||||
|
onStarted?.(event.payload as TestRunnerStartedEvent);
|
||||||
|
break;
|
||||||
|
case 'test-runner:output':
|
||||||
|
onOutput?.(event.payload as TestRunnerOutputEvent);
|
||||||
|
break;
|
||||||
|
case 'test-runner:completed':
|
||||||
|
onCompleted?.(event.payload as TestRunnerCompletedEvent);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [onStarted, onOutput, onCompleted]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export types for convenience
|
||||||
|
export type { TestRunStatus };
|
||||||
393
apps/ui/src/hooks/use-test-runners.ts
Normal file
393
apps/ui/src/hooks/use-test-runners.ts
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
/**
|
||||||
|
* useTestRunners - Hook for test runner lifecycle management
|
||||||
|
*
|
||||||
|
* This hook provides a complete interface for:
|
||||||
|
* - Starting and stopping test runs
|
||||||
|
* - Subscribing to test runner events (started, output, completed)
|
||||||
|
* - Managing test session state per worktree
|
||||||
|
* - Fetching existing test logs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { useTestRunnersStore, type TestSession } from '@/store/test-runners-store';
|
||||||
|
import type {
|
||||||
|
TestRunStatus,
|
||||||
|
TestRunnerStartedEvent,
|
||||||
|
TestRunnerOutputEvent,
|
||||||
|
TestRunnerCompletedEvent,
|
||||||
|
} from '@/types/electron';
|
||||||
|
|
||||||
|
const logger = createLogger('TestRunners');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for starting a test run
|
||||||
|
*/
|
||||||
|
export interface StartTestOptions {
|
||||||
|
/** Project path to get test command from settings */
|
||||||
|
projectPath?: string;
|
||||||
|
/** Specific test file to run (runs all tests if not provided) */
|
||||||
|
testFile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result from starting a test run
|
||||||
|
*/
|
||||||
|
export interface StartTestResult {
|
||||||
|
success: boolean;
|
||||||
|
sessionId?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result from stopping a test run
|
||||||
|
*/
|
||||||
|
export interface StopTestResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing test runners with full lifecycle support
|
||||||
|
*
|
||||||
|
* @param worktreePath - The worktree path to scope the hook to (optional for global event handling)
|
||||||
|
* @returns Test runner state and actions
|
||||||
|
*/
|
||||||
|
export function useTestRunners(worktreePath?: string) {
|
||||||
|
// Get store state and actions
|
||||||
|
const {
|
||||||
|
sessions,
|
||||||
|
activeSessionByWorktree,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
startSession,
|
||||||
|
appendOutput,
|
||||||
|
completeSession,
|
||||||
|
getActiveSession,
|
||||||
|
getSession,
|
||||||
|
isWorktreeRunning,
|
||||||
|
removeSession,
|
||||||
|
clearWorktreeSessions,
|
||||||
|
setLoading,
|
||||||
|
setError,
|
||||||
|
} = useTestRunnersStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
sessions: state.sessions,
|
||||||
|
activeSessionByWorktree: state.activeSessionByWorktree,
|
||||||
|
isLoading: state.isLoading,
|
||||||
|
error: state.error,
|
||||||
|
startSession: state.startSession,
|
||||||
|
appendOutput: state.appendOutput,
|
||||||
|
completeSession: state.completeSession,
|
||||||
|
getActiveSession: state.getActiveSession,
|
||||||
|
getSession: state.getSession,
|
||||||
|
isWorktreeRunning: state.isWorktreeRunning,
|
||||||
|
removeSession: state.removeSession,
|
||||||
|
clearWorktreeSessions: state.clearWorktreeSessions,
|
||||||
|
setLoading: state.setLoading,
|
||||||
|
setError: state.setError,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derived state for the current worktree
|
||||||
|
const activeSession = useMemo(() => {
|
||||||
|
if (!worktreePath) return null;
|
||||||
|
return getActiveSession(worktreePath);
|
||||||
|
}, [worktreePath, getActiveSession, activeSessionByWorktree]);
|
||||||
|
|
||||||
|
const isRunning = useMemo(() => {
|
||||||
|
if (!worktreePath) return false;
|
||||||
|
return isWorktreeRunning(worktreePath);
|
||||||
|
}, [worktreePath, isWorktreeRunning, activeSessionByWorktree, sessions]);
|
||||||
|
|
||||||
|
// Get all sessions for the current worktree
|
||||||
|
const worktreeSessions = useMemo(() => {
|
||||||
|
if (!worktreePath) return [];
|
||||||
|
return Object.values(sessions).filter((s) => s.worktreePath === worktreePath);
|
||||||
|
}, [worktreePath, sessions]);
|
||||||
|
|
||||||
|
// Subscribe to test runner events
|
||||||
|
useEffect(() => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.onTestRunnerEvent) {
|
||||||
|
logger.warn('Test runner event subscription not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = api.worktree.onTestRunnerEvent((event) => {
|
||||||
|
// If worktreePath is specified, only handle events for that worktree
|
||||||
|
if (worktreePath && event.payload.worktreePath !== worktreePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'test-runner:started': {
|
||||||
|
const payload = event.payload as TestRunnerStartedEvent;
|
||||||
|
logger.info(`Test run started: ${payload.sessionId} in ${payload.worktreePath}`);
|
||||||
|
|
||||||
|
startSession({
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
worktreePath: payload.worktreePath,
|
||||||
|
command: payload.command,
|
||||||
|
status: 'running',
|
||||||
|
testFile: payload.testFile,
|
||||||
|
startedAt: payload.timestamp,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'test-runner:output': {
|
||||||
|
const payload = event.payload as TestRunnerOutputEvent;
|
||||||
|
appendOutput(payload.sessionId, payload.content);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'test-runner:completed': {
|
||||||
|
const payload = event.payload as TestRunnerCompletedEvent;
|
||||||
|
logger.info(
|
||||||
|
`Test run completed: ${payload.sessionId} with status ${payload.status} (exit code: ${payload.exitCode})`
|
||||||
|
);
|
||||||
|
|
||||||
|
completeSession(payload.sessionId, payload.status, payload.exitCode, payload.duration);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [worktreePath, startSession, appendOutput, completeSession]);
|
||||||
|
|
||||||
|
// Load existing test logs on mount (if worktreePath is provided)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!worktreePath) return;
|
||||||
|
|
||||||
|
const loadExistingLogs = async () => {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.getTestLogs) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const result = await api.worktree.getTestLogs(worktreePath);
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
const { sessionId, command, status, testFile, logs, startedAt, finishedAt, exitCode } =
|
||||||
|
result.result;
|
||||||
|
|
||||||
|
// Only add if we don't already have this session
|
||||||
|
const existingSession = getSession(sessionId);
|
||||||
|
if (!existingSession) {
|
||||||
|
startSession({
|
||||||
|
sessionId,
|
||||||
|
worktreePath,
|
||||||
|
command,
|
||||||
|
status,
|
||||||
|
testFile,
|
||||||
|
startedAt,
|
||||||
|
finishedAt: finishedAt || undefined,
|
||||||
|
exitCode: exitCode ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add existing logs
|
||||||
|
if (logs) {
|
||||||
|
appendOutput(sessionId, logs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error loading test logs:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load test logs');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadExistingLogs();
|
||||||
|
}, [worktreePath, setLoading, setError, getSession, startSession, appendOutput]);
|
||||||
|
|
||||||
|
// Start a test run
|
||||||
|
const start = useCallback(
|
||||||
|
async (options?: StartTestOptions): Promise<StartTestResult> => {
|
||||||
|
if (!worktreePath) {
|
||||||
|
return { success: false, error: 'No worktree path provided' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.startTests) {
|
||||||
|
return { success: false, error: 'Test runner API not available' };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Starting tests in ${worktreePath}`, options);
|
||||||
|
|
||||||
|
const result = await api.worktree.startTests(worktreePath, {
|
||||||
|
projectPath: options?.projectPath,
|
||||||
|
testFile: options?.testFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
logger.error('Failed to start tests:', result.error);
|
||||||
|
return { success: false, error: result.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Tests started with session: ${result.result?.sessionId}`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
sessionId: result.result?.sessionId,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error starting tests';
|
||||||
|
logger.error('Error starting tests:', err);
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[worktreePath]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stop a test run
|
||||||
|
const stop = useCallback(
|
||||||
|
async (sessionId?: string): Promise<StopTestResult> => {
|
||||||
|
// Use provided sessionId or get the active session for this worktree
|
||||||
|
const targetSessionId = sessionId || (worktreePath && activeSession?.sessionId);
|
||||||
|
|
||||||
|
if (!targetSessionId) {
|
||||||
|
return { success: false, error: 'No active test session to stop' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.stopTests) {
|
||||||
|
return { success: false, error: 'Test runner API not available' };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Stopping test session: ${targetSessionId}`);
|
||||||
|
|
||||||
|
const result = await api.worktree.stopTests(targetSessionId);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
logger.error('Failed to stop tests:', result.error);
|
||||||
|
return { success: false, error: result.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Tests stopped successfully');
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error stopping tests';
|
||||||
|
logger.error('Error stopping tests:', err);
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[worktreePath, activeSession]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refresh logs for the current session
|
||||||
|
const refreshLogs = useCallback(
|
||||||
|
async (sessionId?: string): Promise<{ success: boolean; logs?: string; error?: string }> => {
|
||||||
|
const targetSessionId = sessionId || (worktreePath && activeSession?.sessionId);
|
||||||
|
|
||||||
|
if (!targetSessionId && !worktreePath) {
|
||||||
|
return { success: false, error: 'No session or worktree to refresh' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.getTestLogs) {
|
||||||
|
return { success: false, error: 'Test logs API not available' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.worktree.getTestLogs(worktreePath, targetSessionId);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return { success: false, error: result.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
logs: result.result?.logs,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error fetching logs';
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[worktreePath, activeSession]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear session history for the current worktree
|
||||||
|
const clearHistory = useCallback(() => {
|
||||||
|
if (worktreePath) {
|
||||||
|
clearWorktreeSessions(worktreePath);
|
||||||
|
}
|
||||||
|
}, [worktreePath, clearWorktreeSessions]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
/** The currently active test session for this worktree */
|
||||||
|
activeSession,
|
||||||
|
/** Whether tests are currently running in this worktree */
|
||||||
|
isRunning,
|
||||||
|
/** All test sessions for this worktree (including completed) */
|
||||||
|
sessions: worktreeSessions,
|
||||||
|
/** Loading state */
|
||||||
|
isLoading,
|
||||||
|
/** Error state */
|
||||||
|
error,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
/** Start a test run */
|
||||||
|
start,
|
||||||
|
/** Stop a test run */
|
||||||
|
stop,
|
||||||
|
/** Refresh logs for a session */
|
||||||
|
refreshLogs,
|
||||||
|
/** Clear session history for this worktree */
|
||||||
|
clearHistory,
|
||||||
|
|
||||||
|
// Lower-level access (for advanced use cases)
|
||||||
|
/** Get a specific session by ID */
|
||||||
|
getSession,
|
||||||
|
/** Remove a specific session */
|
||||||
|
removeSession,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for subscribing to test runner events globally (across all worktrees)
|
||||||
|
*
|
||||||
|
* Useful for global status displays or notifications
|
||||||
|
*/
|
||||||
|
export function useTestRunnerEvents(
|
||||||
|
onStarted?: (event: TestRunnerStartedEvent) => void,
|
||||||
|
onOutput?: (event: TestRunnerOutputEvent) => void,
|
||||||
|
onCompleted?: (event: TestRunnerCompletedEvent) => void
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.onTestRunnerEvent) {
|
||||||
|
logger.warn('Test runner event subscription not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = api.worktree.onTestRunnerEvent((event) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'test-runner:started':
|
||||||
|
onStarted?.(event.payload as TestRunnerStartedEvent);
|
||||||
|
break;
|
||||||
|
case 'test-runner:output':
|
||||||
|
onOutput?.(event.payload as TestRunnerOutputEvent);
|
||||||
|
break;
|
||||||
|
case 'test-runner:completed':
|
||||||
|
onCompleted?.(event.payload as TestRunnerCompletedEvent);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [onStarted, onOutput, onCompleted]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export types for convenience
|
||||||
|
export type { TestSession, TestRunStatus };
|
||||||
@@ -2063,6 +2063,52 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Test runner methods
|
||||||
|
startTests: async (
|
||||||
|
worktreePath: string,
|
||||||
|
options?: { projectPath?: string; testFile?: string }
|
||||||
|
) => {
|
||||||
|
console.log('[Mock] Starting tests:', { worktreePath, options });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
sessionId: 'mock-session-123',
|
||||||
|
worktreePath,
|
||||||
|
command: 'npm run test',
|
||||||
|
status: 'running' as const,
|
||||||
|
testFile: options?.testFile,
|
||||||
|
message: 'Tests started (mock)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
stopTests: async (sessionId: string) => {
|
||||||
|
console.log('[Mock] Stopping tests:', { sessionId });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
sessionId,
|
||||||
|
message: 'Tests stopped (mock)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getTestLogs: async (worktreePath?: string, sessionId?: string) => {
|
||||||
|
console.log('[Mock] Getting test logs:', { worktreePath, sessionId });
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No test sessions found (mock)',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
onTestRunnerEvent: (callback) => {
|
||||||
|
console.log('[Mock] Subscribing to test runner events');
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => {
|
||||||
|
console.log('[Mock] Unsubscribing from test runner events');
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3412,6 +3458,11 @@ export interface Project {
|
|||||||
* If a phase is not present, the global setting is used.
|
* If a phase is not present, the global setting is used.
|
||||||
*/
|
*/
|
||||||
phaseModelOverrides?: Partial<import('@automaker/types').PhaseModelConfig>;
|
phaseModelOverrides?: Partial<import('@automaker/types').PhaseModelConfig>;
|
||||||
|
/**
|
||||||
|
* Override the default model for new feature cards in this project.
|
||||||
|
* If not specified, falls back to the global defaultFeatureModel setting.
|
||||||
|
*/
|
||||||
|
defaultFeatureModel?: import('@automaker/types').PhaseModelEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrashedProject extends Project {
|
export interface TrashedProject extends Project {
|
||||||
|
|||||||
@@ -562,6 +562,9 @@ type EventType =
|
|||||||
| 'dev-server:started'
|
| 'dev-server:started'
|
||||||
| 'dev-server:output'
|
| 'dev-server:output'
|
||||||
| 'dev-server:stopped'
|
| 'dev-server:stopped'
|
||||||
|
| 'test-runner:started'
|
||||||
|
| 'test-runner:output'
|
||||||
|
| 'test-runner:completed'
|
||||||
| 'notification:created';
|
| 'notification:created';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -593,6 +596,44 @@ export type DevServerLogEvent =
|
|||||||
| { type: 'dev-server:output'; payload: DevServerOutputEvent }
|
| { type: 'dev-server:output'; payload: DevServerOutputEvent }
|
||||||
| { type: 'dev-server:stopped'; payload: DevServerStoppedEvent };
|
| { type: 'dev-server:stopped'; payload: DevServerStoppedEvent };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test runner event payloads for WebSocket streaming
|
||||||
|
*/
|
||||||
|
export type TestRunStatus = 'pending' | 'running' | 'passed' | 'failed' | 'cancelled' | 'error';
|
||||||
|
|
||||||
|
export interface TestRunnerStartedEvent {
|
||||||
|
sessionId: string;
|
||||||
|
worktreePath: string;
|
||||||
|
/** The test command being run (from project settings) */
|
||||||
|
command: string;
|
||||||
|
testFile?: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestRunnerOutputEvent {
|
||||||
|
sessionId: string;
|
||||||
|
worktreePath: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestRunnerCompletedEvent {
|
||||||
|
sessionId: string;
|
||||||
|
worktreePath: string;
|
||||||
|
/** The test command that was run */
|
||||||
|
command: string;
|
||||||
|
status: TestRunStatus;
|
||||||
|
testFile?: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
duration: number;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TestRunnerEvent =
|
||||||
|
| { type: 'test-runner:started'; payload: TestRunnerStartedEvent }
|
||||||
|
| { type: 'test-runner:output'; payload: TestRunnerOutputEvent }
|
||||||
|
| { type: 'test-runner:completed'; payload: TestRunnerCompletedEvent };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Response type for fetching dev server logs
|
* Response type for fetching dev server logs
|
||||||
*/
|
*/
|
||||||
@@ -608,6 +649,26 @@ export interface DevServerLogsResponse {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response type for fetching test logs
|
||||||
|
*/
|
||||||
|
export interface TestLogsResponse {
|
||||||
|
success: boolean;
|
||||||
|
result?: {
|
||||||
|
sessionId: string;
|
||||||
|
worktreePath: string;
|
||||||
|
/** The test command that was/is being run */
|
||||||
|
command: string;
|
||||||
|
status: TestRunStatus;
|
||||||
|
testFile?: string;
|
||||||
|
logs: string;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
exitCode: number | null;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
type EventCallback = (payload: unknown) => void;
|
type EventCallback = (payload: unknown) => void;
|
||||||
|
|
||||||
interface EnhancePromptResult {
|
interface EnhancePromptResult {
|
||||||
@@ -2001,6 +2062,32 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
unsub3();
|
unsub3();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
// Test runner methods
|
||||||
|
startTests: (worktreePath: string, options?: { projectPath?: string; testFile?: string }) =>
|
||||||
|
this.post('/api/worktree/start-tests', { worktreePath, ...options }),
|
||||||
|
stopTests: (sessionId: string) => this.post('/api/worktree/stop-tests', { sessionId }),
|
||||||
|
getTestLogs: (worktreePath?: string, sessionId?: string): Promise<TestLogsResponse> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (worktreePath) params.append('worktreePath', worktreePath);
|
||||||
|
if (sessionId) params.append('sessionId', sessionId);
|
||||||
|
return this.get(`/api/worktree/test-logs?${params.toString()}`);
|
||||||
|
},
|
||||||
|
onTestRunnerEvent: (callback: (event: TestRunnerEvent) => void) => {
|
||||||
|
const unsub1 = this.subscribeToEvent('test-runner:started', (payload) =>
|
||||||
|
callback({ type: 'test-runner:started', payload: payload as TestRunnerStartedEvent })
|
||||||
|
);
|
||||||
|
const unsub2 = this.subscribeToEvent('test-runner:output', (payload) =>
|
||||||
|
callback({ type: 'test-runner:output', payload: payload as TestRunnerOutputEvent })
|
||||||
|
);
|
||||||
|
const unsub3 = this.subscribeToEvent('test-runner:completed', (payload) =>
|
||||||
|
callback({ type: 'test-runner:completed', payload: payload as TestRunnerCompletedEvent })
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
unsub1();
|
||||||
|
unsub2();
|
||||||
|
unsub3();
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Git API
|
// Git API
|
||||||
@@ -2362,6 +2449,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
defaultDeleteBranchWithWorktree?: boolean;
|
defaultDeleteBranchWithWorktree?: boolean;
|
||||||
autoDismissInitScriptIndicator?: boolean;
|
autoDismissInitScriptIndicator?: boolean;
|
||||||
lastSelectedSessionId?: string;
|
lastSelectedSessionId?: string;
|
||||||
|
testCommand?: string;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.post('/api/settings/project', { projectPath }),
|
}> => this.post('/api/settings/project', { projectPath }),
|
||||||
|
|||||||
@@ -156,35 +156,23 @@ export function sanitizeForTestId(name: string): string {
|
|||||||
/**
|
/**
|
||||||
* Generate a UUID v4 string.
|
* Generate a UUID v4 string.
|
||||||
*
|
*
|
||||||
* Uses crypto.randomUUID() when available (secure contexts: HTTPS or localhost).
|
* Uses crypto.getRandomValues() which works in all modern browsers,
|
||||||
* Falls back to crypto.getRandomValues() for non-secure contexts (e.g., Docker via HTTP).
|
* including non-secure contexts (e.g., Docker via HTTP).
|
||||||
*
|
*
|
||||||
* @returns A RFC 4122 compliant UUID v4 string (e.g., "550e8400-e29b-41d4-a716-446655440000")
|
* @returns A RFC 4122 compliant UUID v4 string (e.g., "550e8400-e29b-41d4-a716-446655440000")
|
||||||
*/
|
*/
|
||||||
export function generateUUID(): string {
|
export function generateUUID(): string {
|
||||||
// Use native randomUUID if available (secure contexts: HTTPS or localhost)
|
if (typeof crypto === 'undefined' || typeof crypto.getRandomValues === 'undefined') {
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
throw new Error('Cryptographically secure random number generator not available.');
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
}
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
|
||||||
// Fallback using crypto.getRandomValues() (works in all modern browsers, including non-secure contexts)
|
// Set version (4) and variant (RFC 4122) bits
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
|
||||||
const bytes = new Uint8Array(16);
|
bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant RFC 4122
|
||||||
crypto.getRandomValues(bytes);
|
|
||||||
|
|
||||||
// Set version (4) and variant (RFC 4122) bits
|
// Convert to hex string with proper UUID format
|
||||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
|
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant RFC 4122
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||||
|
|
||||||
// Convert to hex string with proper UUID format
|
|
||||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last resort fallback using Math.random() - less secure but ensures functionality
|
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
||||||
const r = (Math.random() * 16) | 0;
|
|
||||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
||||||
return v.toString(16);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import { useIsCompact } from '@/hooks/use-media-query';
|
|||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
|
|
||||||
const logger = createLogger('RootLayout');
|
const logger = createLogger('RootLayout');
|
||||||
const SHOW_QUERY_DEVTOOLS = import.meta.env.DEV;
|
const IS_DEV = import.meta.env.DEV;
|
||||||
const SERVER_READY_MAX_ATTEMPTS = 8;
|
const SERVER_READY_MAX_ATTEMPTS = 8;
|
||||||
const SERVER_READY_BACKOFF_BASE_MS = 250;
|
const SERVER_READY_BACKOFF_BASE_MS = 250;
|
||||||
const SERVER_READY_MAX_DELAY_MS = 1500;
|
const SERVER_READY_MAX_DELAY_MS = 1500;
|
||||||
@@ -895,17 +895,22 @@ function RootLayoutContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
// Hide devtools on compact screens (mobile/tablet) to avoid overlap with sidebar settings
|
// Hide devtools on compact screens (mobile/tablet) to avoid overlap with UI controls
|
||||||
const isCompact = useIsCompact();
|
const isCompact = useIsCompact();
|
||||||
|
// Get the user's preference for showing devtools from the app store
|
||||||
|
const showQueryDevtools = useAppStore((state) => state.showQueryDevtools);
|
||||||
|
|
||||||
|
// Show devtools only if: in dev mode, user setting enabled, and not compact screen
|
||||||
|
const shouldShowDevtools = IS_DEV && showQueryDevtools && !isCompact;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<FileBrowserProvider>
|
<FileBrowserProvider>
|
||||||
<RootLayoutContent />
|
<RootLayoutContent />
|
||||||
</FileBrowserProvider>
|
</FileBrowserProvider>
|
||||||
{SHOW_QUERY_DEVTOOLS && !isCompact ? (
|
{shouldShowDevtools && (
|
||||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
|
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-right" />
|
||||||
) : null}
|
)}
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
DEFAULT_PHASE_MODELS,
|
DEFAULT_PHASE_MODELS,
|
||||||
DEFAULT_OPENCODE_MODEL,
|
DEFAULT_OPENCODE_MODEL,
|
||||||
DEFAULT_MAX_CONCURRENCY,
|
DEFAULT_MAX_CONCURRENCY,
|
||||||
|
DEFAULT_GLOBAL_SETTINGS,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
|
|
||||||
const logger = createLogger('AppStore');
|
const logger = createLogger('AppStore');
|
||||||
@@ -505,6 +506,7 @@ export interface ProjectAnalysis {
|
|||||||
// Terminal panel layout types (recursive for splits)
|
// Terminal panel layout types (recursive for splits)
|
||||||
export type TerminalPanelContent =
|
export type TerminalPanelContent =
|
||||||
| { type: 'terminal'; sessionId: string; size?: number; fontSize?: number; branchName?: string }
|
| { type: 'terminal'; sessionId: string; size?: number; fontSize?: number; branchName?: string }
|
||||||
|
| { type: 'testRunner'; sessionId: string; size?: number; worktreePath: string }
|
||||||
| {
|
| {
|
||||||
type: 'split';
|
type: 'split';
|
||||||
id: string; // Stable ID for React key stability
|
id: string; // Stable ID for React key stability
|
||||||
@@ -542,6 +544,7 @@ export interface TerminalState {
|
|||||||
// Used to restore terminal layout structure when switching projects
|
// Used to restore terminal layout structure when switching projects
|
||||||
export type PersistedTerminalPanel =
|
export type PersistedTerminalPanel =
|
||||||
| { type: 'terminal'; size?: number; fontSize?: number; sessionId?: string; branchName?: string }
|
| { type: 'terminal'; size?: number; fontSize?: number; sessionId?: string; branchName?: string }
|
||||||
|
| { type: 'testRunner'; size?: number; sessionId?: string; worktreePath?: string }
|
||||||
| {
|
| {
|
||||||
type: 'split';
|
type: 'split';
|
||||||
id?: string; // Optional for backwards compatibility with older persisted layouts
|
id?: string; // Optional for backwards compatibility with older persisted layouts
|
||||||
@@ -681,6 +684,9 @@ export interface AppState {
|
|||||||
serverLogLevel: ServerLogLevel; // Log level for the API server (error, warn, info, debug)
|
serverLogLevel: ServerLogLevel; // Log level for the API server (error, warn, info, debug)
|
||||||
enableRequestLogging: boolean; // Enable HTTP request logging (Morgan)
|
enableRequestLogging: boolean; // Enable HTTP request logging (Morgan)
|
||||||
|
|
||||||
|
// Developer Tools Settings
|
||||||
|
showQueryDevtools: boolean; // Show React Query DevTools panel (only in development mode)
|
||||||
|
|
||||||
// Enhancement Model Settings
|
// Enhancement Model Settings
|
||||||
enhancementModel: ModelAlias; // Model used for feature enhancement (default: sonnet)
|
enhancementModel: ModelAlias; // Model used for feature enhancement (default: sonnet)
|
||||||
|
|
||||||
@@ -1055,6 +1061,12 @@ export interface AppActions {
|
|||||||
) => void;
|
) => void;
|
||||||
clearAllProjectPhaseModelOverrides: (projectId: string) => void;
|
clearAllProjectPhaseModelOverrides: (projectId: string) => void;
|
||||||
|
|
||||||
|
// Project Default Feature Model Override
|
||||||
|
setProjectDefaultFeatureModel: (
|
||||||
|
projectId: string,
|
||||||
|
entry: import('@automaker/types').PhaseModelEntry | null // null = use global
|
||||||
|
) => void;
|
||||||
|
|
||||||
// Feature actions
|
// Feature actions
|
||||||
setFeatures: (features: Feature[]) => void;
|
setFeatures: (features: Feature[]) => void;
|
||||||
updateFeature: (id: string, updates: Partial<Feature>) => void;
|
updateFeature: (id: string, updates: Partial<Feature>) => void;
|
||||||
@@ -1161,6 +1173,9 @@ export interface AppActions {
|
|||||||
setServerLogLevel: (level: ServerLogLevel) => void;
|
setServerLogLevel: (level: ServerLogLevel) => void;
|
||||||
setEnableRequestLogging: (enabled: boolean) => void;
|
setEnableRequestLogging: (enabled: boolean) => void;
|
||||||
|
|
||||||
|
// Developer Tools actions
|
||||||
|
setShowQueryDevtools: (show: boolean) => void;
|
||||||
|
|
||||||
// Enhancement Model actions
|
// Enhancement Model actions
|
||||||
setEnhancementModel: (model: ModelAlias) => void;
|
setEnhancementModel: (model: ModelAlias) => void;
|
||||||
|
|
||||||
@@ -1465,6 +1480,7 @@ const initialState: AppState = {
|
|||||||
muteDoneSound: false, // Default to sound enabled (not muted)
|
muteDoneSound: false, // Default to sound enabled (not muted)
|
||||||
serverLogLevel: 'info', // Default to info level for server logs
|
serverLogLevel: 'info', // Default to info level for server logs
|
||||||
enableRequestLogging: true, // Default to enabled for HTTP request logging
|
enableRequestLogging: true, // Default to enabled for HTTP request logging
|
||||||
|
showQueryDevtools: true, // Default to enabled (only shown in dev mode anyway)
|
||||||
enhancementModel: 'claude-sonnet', // Default to sonnet for feature enhancement
|
enhancementModel: 'claude-sonnet', // Default to sonnet for feature enhancement
|
||||||
validationModel: 'claude-opus', // Default to opus for GitHub issue validation
|
validationModel: 'claude-opus', // Default to opus for GitHub issue validation
|
||||||
phaseModels: DEFAULT_PHASE_MODELS, // Phase-specific model configuration
|
phaseModels: DEFAULT_PHASE_MODELS, // Phase-specific model configuration
|
||||||
@@ -1527,7 +1543,7 @@ const initialState: AppState = {
|
|||||||
specCreatingForProject: null,
|
specCreatingForProject: null,
|
||||||
defaultPlanningMode: 'skip' as PlanningMode,
|
defaultPlanningMode: 'skip' as PlanningMode,
|
||||||
defaultRequirePlanApproval: false,
|
defaultRequirePlanApproval: false,
|
||||||
defaultFeatureModel: { model: 'opus' } as PhaseModelEntry,
|
defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel,
|
||||||
pendingPlanApproval: null,
|
pendingPlanApproval: null,
|
||||||
claudeRefreshInterval: 60,
|
claudeRefreshInterval: 60,
|
||||||
claudeUsage: null,
|
claudeUsage: null,
|
||||||
@@ -2105,9 +2121,11 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear overrides from project
|
// Clear all model overrides from project (phaseModelOverrides + defaultFeatureModel)
|
||||||
const projects = get().projects.map((p) =>
|
const projects = get().projects.map((p) =>
|
||||||
p.id === projectId ? { ...p, phaseModelOverrides: undefined } : p
|
p.id === projectId
|
||||||
|
? { ...p, phaseModelOverrides: undefined, defaultFeatureModel: undefined }
|
||||||
|
: p
|
||||||
);
|
);
|
||||||
set({ projects });
|
set({ projects });
|
||||||
|
|
||||||
@@ -2118,6 +2136,49 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
currentProject: {
|
currentProject: {
|
||||||
...currentProject,
|
...currentProject,
|
||||||
phaseModelOverrides: undefined,
|
phaseModelOverrides: undefined,
|
||||||
|
defaultFeatureModel: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist to server (clear both)
|
||||||
|
const httpClient = getHttpApiClient();
|
||||||
|
httpClient.settings
|
||||||
|
.updateProject(project.path, {
|
||||||
|
phaseModelOverrides: '__CLEAR__',
|
||||||
|
defaultFeatureModel: '__CLEAR__',
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to clear model overrides:', error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setProjectDefaultFeatureModel: (projectId, entry) => {
|
||||||
|
// Find the project to get its path for server sync
|
||||||
|
const project = get().projects.find((p) => p.id === projectId);
|
||||||
|
if (!project) {
|
||||||
|
console.error('Cannot set default feature model: project not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the project's defaultFeatureModel
|
||||||
|
const projects = get().projects.map((p) =>
|
||||||
|
p.id === projectId
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
defaultFeatureModel: entry ?? undefined,
|
||||||
|
}
|
||||||
|
: p
|
||||||
|
);
|
||||||
|
set({ projects });
|
||||||
|
|
||||||
|
// Also update currentProject if it's the same project
|
||||||
|
const currentProject = get().currentProject;
|
||||||
|
if (currentProject?.id === projectId) {
|
||||||
|
set({
|
||||||
|
currentProject: {
|
||||||
|
...currentProject,
|
||||||
|
defaultFeatureModel: entry ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2126,10 +2187,10 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
const httpClient = getHttpApiClient();
|
const httpClient = getHttpApiClient();
|
||||||
httpClient.settings
|
httpClient.settings
|
||||||
.updateProject(project.path, {
|
.updateProject(project.path, {
|
||||||
phaseModelOverrides: '__CLEAR__',
|
defaultFeatureModel: entry ?? '__CLEAR__',
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to clear phaseModelOverrides:', error);
|
console.error('Failed to persist defaultFeatureModel:', error);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -2541,6 +2602,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
setServerLogLevel: (level) => set({ serverLogLevel: level }),
|
setServerLogLevel: (level) => set({ serverLogLevel: level }),
|
||||||
setEnableRequestLogging: (enabled) => set({ enableRequestLogging: enabled }),
|
setEnableRequestLogging: (enabled) => set({ enableRequestLogging: enabled }),
|
||||||
|
|
||||||
|
// Developer Tools actions
|
||||||
|
setShowQueryDevtools: (show) => set({ showQueryDevtools: show }),
|
||||||
|
|
||||||
// Enhancement Model actions
|
// Enhancement Model actions
|
||||||
setEnhancementModel: (model) => set({ enhancementModel: model }),
|
setEnhancementModel: (model) => set({ enhancementModel: model }),
|
||||||
|
|
||||||
@@ -2571,7 +2635,10 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
await syncSettingsToServer();
|
await syncSettingsToServer();
|
||||||
},
|
},
|
||||||
resetPhaseModels: async () => {
|
resetPhaseModels: async () => {
|
||||||
set({ phaseModels: DEFAULT_PHASE_MODELS });
|
set({
|
||||||
|
phaseModels: DEFAULT_PHASE_MODELS,
|
||||||
|
defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel,
|
||||||
|
});
|
||||||
// Sync to server settings file
|
// Sync to server settings file
|
||||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||||
await syncSettingsToServer();
|
await syncSettingsToServer();
|
||||||
@@ -3106,7 +3173,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
targetId: string,
|
targetId: string,
|
||||||
targetDirection: 'horizontal' | 'vertical'
|
targetDirection: 'horizontal' | 'vertical'
|
||||||
): TerminalPanelContent => {
|
): TerminalPanelContent => {
|
||||||
if (node.type === 'terminal') {
|
if (node.type === 'terminal' || node.type === 'testRunner') {
|
||||||
if (node.sessionId === targetId) {
|
if (node.sessionId === targetId) {
|
||||||
// Found the target - split it
|
// Found the target - split it
|
||||||
return {
|
return {
|
||||||
@@ -3131,7 +3198,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
node: TerminalPanelContent,
|
node: TerminalPanelContent,
|
||||||
targetDirection: 'horizontal' | 'vertical'
|
targetDirection: 'horizontal' | 'vertical'
|
||||||
): TerminalPanelContent => {
|
): TerminalPanelContent => {
|
||||||
if (node.type === 'terminal') {
|
if (node.type === 'terminal' || node.type === 'testRunner') {
|
||||||
return {
|
return {
|
||||||
type: 'split',
|
type: 'split',
|
||||||
id: generateSplitId(),
|
id: generateSplitId(),
|
||||||
@@ -3139,7 +3206,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
panels: [{ ...node, size: 50 }, newTerminal],
|
panels: [{ ...node, size: 50 }, newTerminal],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// If same direction, add to existing split
|
// It's a split - if same direction, add to existing split
|
||||||
if (node.direction === targetDirection) {
|
if (node.direction === targetDirection) {
|
||||||
const newSize = 100 / (node.panels.length + 1);
|
const newSize = 100 / (node.panels.length + 1);
|
||||||
return {
|
return {
|
||||||
@@ -3188,7 +3255,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// Find which tab contains this session
|
// Find which tab contains this session
|
||||||
const findFirstTerminal = (node: TerminalPanelContent | null): string | null => {
|
const findFirstTerminal = (node: TerminalPanelContent | null): string | null => {
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
if (node.type === 'terminal') return node.sessionId;
|
if (node.type === 'terminal' || node.type === 'testRunner') return node.sessionId;
|
||||||
for (const panel of node.panels) {
|
for (const panel of node.panels) {
|
||||||
const found = findFirstTerminal(panel);
|
const found = findFirstTerminal(panel);
|
||||||
if (found) return found;
|
if (found) return found;
|
||||||
@@ -3197,7 +3264,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => {
|
const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => {
|
||||||
if (node.type === 'terminal') {
|
if (node.type === 'terminal' || node.type === 'testRunner') {
|
||||||
return node.sessionId === sessionId ? null : node;
|
return node.sessionId === sessionId ? null : node;
|
||||||
}
|
}
|
||||||
const newPanels: TerminalPanelContent[] = [];
|
const newPanels: TerminalPanelContent[] = [];
|
||||||
@@ -3256,6 +3323,10 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
if (node.sessionId === sessionId2) return { ...node, sessionId: sessionId1 };
|
if (node.sessionId === sessionId2) return { ...node, sessionId: sessionId1 };
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
if (node.type === 'testRunner') {
|
||||||
|
// testRunner panels don't participate in swapping
|
||||||
|
return node;
|
||||||
|
}
|
||||||
return { ...node, panels: node.panels.map(swapInLayout) };
|
return { ...node, panels: node.panels.map(swapInLayout) };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3308,6 +3379,10 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
}
|
}
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
if (node.type === 'testRunner') {
|
||||||
|
// testRunner panels don't have fontSize
|
||||||
|
return node;
|
||||||
|
}
|
||||||
return { ...node, panels: node.panels.map(updateFontSize) };
|
return { ...node, panels: node.panels.map(updateFontSize) };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3421,7 +3496,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
if (newActiveTabId) {
|
if (newActiveTabId) {
|
||||||
const newActiveTab = newTabs.find((t) => t.id === newActiveTabId);
|
const newActiveTab = newTabs.find((t) => t.id === newActiveTabId);
|
||||||
const findFirst = (node: TerminalPanelContent): string | null => {
|
const findFirst = (node: TerminalPanelContent): string | null => {
|
||||||
if (node.type === 'terminal') return node.sessionId;
|
if (node.type === 'terminal' || node.type === 'testRunner') return node.sessionId;
|
||||||
for (const p of node.panels) {
|
for (const p of node.panels) {
|
||||||
const f = findFirst(p);
|
const f = findFirst(p);
|
||||||
if (f) return f;
|
if (f) return f;
|
||||||
@@ -3452,7 +3527,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
let newActiveSessionId = current.activeSessionId;
|
let newActiveSessionId = current.activeSessionId;
|
||||||
if (tab.layout) {
|
if (tab.layout) {
|
||||||
const findFirst = (node: TerminalPanelContent): string | null => {
|
const findFirst = (node: TerminalPanelContent): string | null => {
|
||||||
if (node.type === 'terminal') return node.sessionId;
|
if (node.type === 'terminal' || node.type === 'testRunner') return node.sessionId;
|
||||||
for (const p of node.panels) {
|
for (const p of node.panels) {
|
||||||
const f = findFirst(p);
|
const f = findFirst(p);
|
||||||
if (f) return f;
|
if (f) return f;
|
||||||
@@ -3513,6 +3588,10 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
if (node.type === 'terminal') {
|
if (node.type === 'terminal') {
|
||||||
return node.sessionId === sessionId ? node : null;
|
return node.sessionId === sessionId ? node : null;
|
||||||
}
|
}
|
||||||
|
if (node.type === 'testRunner') {
|
||||||
|
// testRunner panels don't participate in moveTerminalToTab
|
||||||
|
return null;
|
||||||
|
}
|
||||||
for (const panel of node.panels) {
|
for (const panel of node.panels) {
|
||||||
const found = findTerminal(panel);
|
const found = findTerminal(panel);
|
||||||
if (found) return found;
|
if (found) return found;
|
||||||
@@ -3537,7 +3616,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
if (!sourceTab?.layout) return;
|
if (!sourceTab?.layout) return;
|
||||||
|
|
||||||
const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => {
|
const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => {
|
||||||
if (node.type === 'terminal') {
|
if (node.type === 'terminal' || node.type === 'testRunner') {
|
||||||
return node.sessionId === sessionId ? null : node;
|
return node.sessionId === sessionId ? null : node;
|
||||||
}
|
}
|
||||||
const newPanels: TerminalPanelContent[] = [];
|
const newPanels: TerminalPanelContent[] = [];
|
||||||
@@ -3598,7 +3677,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
size: 100,
|
size: 100,
|
||||||
fontSize: originalTerminalNode.fontSize,
|
fontSize: originalTerminalNode.fontSize,
|
||||||
};
|
};
|
||||||
} else if (targetTab.layout.type === 'terminal') {
|
} else if (targetTab.layout.type === 'terminal' || targetTab.layout.type === 'testRunner') {
|
||||||
newTargetLayout = {
|
newTargetLayout = {
|
||||||
type: 'split',
|
type: 'split',
|
||||||
id: generateSplitId(),
|
id: generateSplitId(),
|
||||||
@@ -3606,6 +3685,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
panels: [{ ...targetTab.layout, size: 50 }, terminalNode],
|
panels: [{ ...targetTab.layout, size: 50 }, terminalNode],
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
// It's a split
|
||||||
newTargetLayout = {
|
newTargetLayout = {
|
||||||
...targetTab.layout,
|
...targetTab.layout,
|
||||||
panels: [...targetTab.layout.panels, terminalNode],
|
panels: [...targetTab.layout.panels, terminalNode],
|
||||||
@@ -3648,7 +3728,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
|
|
||||||
if (!tab.layout) {
|
if (!tab.layout) {
|
||||||
newLayout = { type: 'terminal', sessionId, size: 100, branchName };
|
newLayout = { type: 'terminal', sessionId, size: 100, branchName };
|
||||||
} else if (tab.layout.type === 'terminal') {
|
} else if (tab.layout.type === 'terminal' || tab.layout.type === 'testRunner') {
|
||||||
newLayout = {
|
newLayout = {
|
||||||
type: 'split',
|
type: 'split',
|
||||||
id: generateSplitId(),
|
id: generateSplitId(),
|
||||||
@@ -3656,6 +3736,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
panels: [{ ...tab.layout, size: 50 }, terminalNode],
|
panels: [{ ...tab.layout, size: 50 }, terminalNode],
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
// It's a split
|
||||||
if (tab.layout.direction === direction) {
|
if (tab.layout.direction === direction) {
|
||||||
const newSize = 100 / (tab.layout.panels.length + 1);
|
const newSize = 100 / (tab.layout.panels.length + 1);
|
||||||
newLayout = {
|
newLayout = {
|
||||||
@@ -3696,7 +3777,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
|
|
||||||
// Find first terminal in layout if no activeSessionId provided
|
// Find first terminal in layout if no activeSessionId provided
|
||||||
const findFirst = (node: TerminalPanelContent): string | null => {
|
const findFirst = (node: TerminalPanelContent): string | null => {
|
||||||
if (node.type === 'terminal') return node.sessionId;
|
if (node.type === 'terminal' || node.type === 'testRunner') return node.sessionId;
|
||||||
for (const p of node.panels) {
|
for (const p of node.panels) {
|
||||||
const found = findFirst(p);
|
const found = findFirst(p);
|
||||||
if (found) return found;
|
if (found) return found;
|
||||||
@@ -3729,7 +3810,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
|
|
||||||
// Helper to generate panel key (matches getPanelKey in terminal-view.tsx)
|
// Helper to generate panel key (matches getPanelKey in terminal-view.tsx)
|
||||||
const getPanelKey = (panel: TerminalPanelContent): string => {
|
const getPanelKey = (panel: TerminalPanelContent): string => {
|
||||||
if (panel.type === 'terminal') return panel.sessionId;
|
if (panel.type === 'terminal' || panel.type === 'testRunner') return panel.sessionId;
|
||||||
const childKeys = panel.panels.map(getPanelKey).join('-');
|
const childKeys = panel.panels.map(getPanelKey).join('-');
|
||||||
return `split-${panel.direction}-${childKeys}`;
|
return `split-${panel.direction}-${childKeys}`;
|
||||||
};
|
};
|
||||||
@@ -3739,7 +3820,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
const key = getPanelKey(panel);
|
const key = getPanelKey(panel);
|
||||||
const newSize = sizeMap.get(key);
|
const newSize = sizeMap.get(key);
|
||||||
|
|
||||||
if (panel.type === 'terminal') {
|
if (panel.type === 'terminal' || panel.type === 'testRunner') {
|
||||||
return newSize !== undefined ? { ...panel, size: newSize } : panel;
|
return newSize !== undefined ? { ...panel, size: newSize } : panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3782,6 +3863,14 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
branchName: panel.branchName, // Preserve branch name for display
|
branchName: panel.branchName, // Preserve branch name for display
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (panel.type === 'testRunner') {
|
||||||
|
return {
|
||||||
|
type: 'testRunner',
|
||||||
|
size: panel.size,
|
||||||
|
sessionId: panel.sessionId, // Preserve for reconnection
|
||||||
|
worktreePath: panel.worktreePath, // Preserve worktree context
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: 'split',
|
type: 'split',
|
||||||
id: panel.id, // Preserve stable ID
|
id: panel.id, // Preserve stable ID
|
||||||
|
|||||||
248
apps/ui/src/store/test-runners-store.ts
Normal file
248
apps/ui/src/store/test-runners-store.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* Test Runners Store - State management for test runner sessions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { TestRunStatus } from '@/types/electron';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A test run session
|
||||||
|
*/
|
||||||
|
export interface TestSession {
|
||||||
|
/** Unique session ID */
|
||||||
|
sessionId: string;
|
||||||
|
/** Path to the worktree where tests are running */
|
||||||
|
worktreePath: string;
|
||||||
|
/** The test command being run (from project settings) */
|
||||||
|
command: string;
|
||||||
|
/** Current status of the test run */
|
||||||
|
status: TestRunStatus;
|
||||||
|
/** Optional: specific test file being run */
|
||||||
|
testFile?: string;
|
||||||
|
/** When the test run started */
|
||||||
|
startedAt: string;
|
||||||
|
/** When the test run finished (if completed) */
|
||||||
|
finishedAt?: string;
|
||||||
|
/** Exit code (if completed) */
|
||||||
|
exitCode?: number | null;
|
||||||
|
/** Duration in milliseconds (if completed) */
|
||||||
|
duration?: number;
|
||||||
|
/** Accumulated output logs */
|
||||||
|
output: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// State Interface
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface TestRunnersState {
|
||||||
|
/** Map of sessionId -> TestSession for all tracked sessions */
|
||||||
|
sessions: Record<string, TestSession>;
|
||||||
|
/** Map of worktreePath -> sessionId for quick lookup of active session per worktree */
|
||||||
|
activeSessionByWorktree: Record<string, string>;
|
||||||
|
/** Loading state for initial data fetch */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Error state */
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Actions Interface
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface TestRunnersActions {
|
||||||
|
/** Add or update a session when a test run starts */
|
||||||
|
startSession: (session: Omit<TestSession, 'output'>) => void;
|
||||||
|
|
||||||
|
/** Append output to a session */
|
||||||
|
appendOutput: (sessionId: string, content: string) => void;
|
||||||
|
|
||||||
|
/** Complete a session with final status */
|
||||||
|
completeSession: (
|
||||||
|
sessionId: string,
|
||||||
|
status: TestRunStatus,
|
||||||
|
exitCode: number | null,
|
||||||
|
duration: number
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/** Get the active session for a worktree */
|
||||||
|
getActiveSession: (worktreePath: string) => TestSession | null;
|
||||||
|
|
||||||
|
/** Get a session by ID */
|
||||||
|
getSession: (sessionId: string) => TestSession | null;
|
||||||
|
|
||||||
|
/** Check if a worktree has an active (running) test session */
|
||||||
|
isWorktreeRunning: (worktreePath: string) => boolean;
|
||||||
|
|
||||||
|
/** Remove a session (cleanup) */
|
||||||
|
removeSession: (sessionId: string) => void;
|
||||||
|
|
||||||
|
/** Clear all sessions for a worktree */
|
||||||
|
clearWorktreeSessions: (worktreePath: string) => void;
|
||||||
|
|
||||||
|
/** Set loading state */
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
|
||||||
|
/** Set error state */
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
|
||||||
|
/** Reset the store */
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Initial State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const initialState: TestRunnersState = {
|
||||||
|
sessions: {},
|
||||||
|
activeSessionByWorktree: {},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Store
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const useTestRunnersStore = create<TestRunnersState & TestRunnersActions>((set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
startSession: (session) => {
|
||||||
|
const newSession: TestSession = {
|
||||||
|
...session,
|
||||||
|
output: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
sessions: {
|
||||||
|
...state.sessions,
|
||||||
|
[session.sessionId]: newSession,
|
||||||
|
},
|
||||||
|
activeSessionByWorktree: {
|
||||||
|
...state.activeSessionByWorktree,
|
||||||
|
[session.worktreePath]: session.sessionId,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
appendOutput: (sessionId, content) => {
|
||||||
|
set((state) => {
|
||||||
|
const session = state.sessions[sessionId];
|
||||||
|
if (!session) return state;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions: {
|
||||||
|
...state.sessions,
|
||||||
|
[sessionId]: {
|
||||||
|
...session,
|
||||||
|
output: session.output + content,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
completeSession: (sessionId, status, exitCode, duration) => {
|
||||||
|
set((state) => {
|
||||||
|
const session = state.sessions[sessionId];
|
||||||
|
if (!session) return state;
|
||||||
|
|
||||||
|
const finishedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// Remove from active sessions since it's no longer running
|
||||||
|
const { [session.worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions: {
|
||||||
|
...state.sessions,
|
||||||
|
[sessionId]: {
|
||||||
|
...session,
|
||||||
|
status,
|
||||||
|
exitCode,
|
||||||
|
duration,
|
||||||
|
finishedAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Only remove from active if this is the current active session
|
||||||
|
activeSessionByWorktree:
|
||||||
|
state.activeSessionByWorktree[session.worktreePath] === sessionId
|
||||||
|
? remainingActive
|
||||||
|
: state.activeSessionByWorktree,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getActiveSession: (worktreePath) => {
|
||||||
|
const state = get();
|
||||||
|
const sessionId = state.activeSessionByWorktree[worktreePath];
|
||||||
|
if (!sessionId) return null;
|
||||||
|
return state.sessions[sessionId] || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getSession: (sessionId) => {
|
||||||
|
return get().sessions[sessionId] || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
isWorktreeRunning: (worktreePath) => {
|
||||||
|
const state = get();
|
||||||
|
const sessionId = state.activeSessionByWorktree[worktreePath];
|
||||||
|
if (!sessionId) return false;
|
||||||
|
const session = state.sessions[sessionId];
|
||||||
|
return session?.status === 'running' || session?.status === 'pending';
|
||||||
|
},
|
||||||
|
|
||||||
|
removeSession: (sessionId) => {
|
||||||
|
set((state) => {
|
||||||
|
const session = state.sessions[sessionId];
|
||||||
|
if (!session) return state;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { [sessionId]: _, ...remainingSessions } = state.sessions;
|
||||||
|
|
||||||
|
// Remove from active if this was the active session
|
||||||
|
const { [session.worktreePath]: activeId, ...remainingActive } =
|
||||||
|
state.activeSessionByWorktree;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions: remainingSessions,
|
||||||
|
activeSessionByWorktree:
|
||||||
|
activeId === sessionId ? remainingActive : state.activeSessionByWorktree,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearWorktreeSessions: (worktreePath) => {
|
||||||
|
set((state) => {
|
||||||
|
// Find all sessions for this worktree
|
||||||
|
const sessionsToRemove = Object.values(state.sessions)
|
||||||
|
.filter((s) => s.worktreePath === worktreePath)
|
||||||
|
.map((s) => s.sessionId);
|
||||||
|
|
||||||
|
// Remove them from sessions
|
||||||
|
const remainingSessions = { ...state.sessions };
|
||||||
|
sessionsToRemove.forEach((id) => {
|
||||||
|
delete remainingSessions[id];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove from active
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { [worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions: remainingSessions,
|
||||||
|
activeSessionByWorktree: remainingActive,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
|
||||||
|
setError: (error) => set({ error }),
|
||||||
|
|
||||||
|
reset: () => set(initialState),
|
||||||
|
}));
|
||||||
101
apps/ui/src/types/electron.d.ts
vendored
101
apps/ui/src/types/electron.d.ts
vendored
@@ -1267,6 +1267,107 @@ export interface WorktreeAPI {
|
|||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
// Test runner methods
|
||||||
|
|
||||||
|
// Start tests for a worktree
|
||||||
|
startTests: (
|
||||||
|
worktreePath: string,
|
||||||
|
options?: { projectPath?: string; testFile?: string }
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
result?: {
|
||||||
|
sessionId: string;
|
||||||
|
worktreePath: string;
|
||||||
|
/** The test command being run (from project settings) */
|
||||||
|
command: string;
|
||||||
|
status: TestRunStatus;
|
||||||
|
testFile?: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Stop a running test session
|
||||||
|
stopTests: (sessionId: string) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
result?: {
|
||||||
|
sessionId: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Get test logs for a session
|
||||||
|
getTestLogs: (
|
||||||
|
worktreePath?: string,
|
||||||
|
sessionId?: string
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
result?: {
|
||||||
|
sessionId: string;
|
||||||
|
worktreePath: string;
|
||||||
|
command: string;
|
||||||
|
status: TestRunStatus;
|
||||||
|
testFile?: string;
|
||||||
|
logs: string;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
exitCode: number | null;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Subscribe to test runner events (started, output, completed)
|
||||||
|
onTestRunnerEvent: (
|
||||||
|
callback: (
|
||||||
|
event:
|
||||||
|
| {
|
||||||
|
type: 'test-runner:started';
|
||||||
|
payload: TestRunnerStartedEvent;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'test-runner:output';
|
||||||
|
payload: TestRunnerOutputEvent;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'test-runner:completed';
|
||||||
|
payload: TestRunnerCompletedEvent;
|
||||||
|
}
|
||||||
|
) => void
|
||||||
|
) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test runner status type
|
||||||
|
export type TestRunStatus = 'pending' | 'running' | 'passed' | 'failed' | 'cancelled' | 'error';
|
||||||
|
|
||||||
|
// Test runner event payloads
|
||||||
|
export interface TestRunnerStartedEvent {
|
||||||
|
sessionId: string;
|
||||||
|
worktreePath: string;
|
||||||
|
/** The test command being run (from project settings) */
|
||||||
|
command: string;
|
||||||
|
testFile?: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestRunnerOutputEvent {
|
||||||
|
sessionId: string;
|
||||||
|
worktreePath: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestRunnerCompletedEvent {
|
||||||
|
sessionId: string;
|
||||||
|
worktreePath: string;
|
||||||
|
/** The test command that was run */
|
||||||
|
command: string;
|
||||||
|
status: TestRunStatus;
|
||||||
|
testFile?: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
duration: number;
|
||||||
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitAPI {
|
export interface GitAPI {
|
||||||
|
|||||||
@@ -28,6 +28,29 @@ import type {
|
|||||||
InstallationStatus,
|
InstallationStatus,
|
||||||
ValidationResult,
|
ValidationResult,
|
||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
|
AgentDefinition,
|
||||||
|
ReasoningEffort,
|
||||||
|
SystemPromptPreset,
|
||||||
|
McpServerConfig,
|
||||||
|
McpStdioServerConfig,
|
||||||
|
McpSSEServerConfig,
|
||||||
|
McpHttpServerConfig,
|
||||||
|
} from '@automaker/types';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Codex CLI Types
|
||||||
|
|
||||||
|
Types for Codex CLI integration.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type {
|
||||||
|
CodexSandboxMode,
|
||||||
|
CodexApprovalPolicy,
|
||||||
|
CodexCliConfig,
|
||||||
|
CodexAuthStatus,
|
||||||
|
CodexEventType,
|
||||||
|
CodexItemType,
|
||||||
|
CodexEvent,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ export type EventType =
|
|||||||
| 'dev-server:started'
|
| 'dev-server:started'
|
||||||
| 'dev-server:output'
|
| 'dev-server:output'
|
||||||
| 'dev-server:stopped'
|
| 'dev-server:stopped'
|
||||||
|
| 'test-runner:started'
|
||||||
|
| 'test-runner:progress'
|
||||||
|
| 'test-runner:output'
|
||||||
|
| 'test-runner:completed'
|
||||||
|
| 'test-runner:error'
|
||||||
|
| 'test-runner:result'
|
||||||
| 'notification:created';
|
| 'notification:created';
|
||||||
|
|
||||||
export type EventCallback = (type: EventType, payload: unknown) => void;
|
export type EventCallback = (type: EventType, payload: unknown) => void;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export interface Feature {
|
|||||||
// Branch info - worktree path is derived at runtime from branchName
|
// Branch info - worktree path is derived at runtime from branchName
|
||||||
branchName?: string; // Name of the feature branch (undefined = use current worktree)
|
branchName?: string; // Name of the feature branch (undefined = use current worktree)
|
||||||
skipTests?: boolean;
|
skipTests?: boolean;
|
||||||
|
excludedPipelineSteps?: string[]; // Array of pipeline step IDs to skip for this feature
|
||||||
thinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
reasoningEffort?: ReasoningEffort;
|
reasoningEffort?: ReasoningEffort;
|
||||||
planningMode?: PlanningMode;
|
planningMode?: PlanningMode;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export type {
|
|||||||
McpHttpServerConfig,
|
McpHttpServerConfig,
|
||||||
AgentDefinition,
|
AgentDefinition,
|
||||||
ReasoningEffort,
|
ReasoningEffort,
|
||||||
|
// System prompt configuration for CLAUDE.md auto-loading
|
||||||
|
SystemPromptPreset,
|
||||||
} from './provider.js';
|
} from './provider.js';
|
||||||
|
|
||||||
// Provider constants and utilities
|
// Provider constants and utilities
|
||||||
@@ -34,6 +36,10 @@ export type {
|
|||||||
CodexApprovalPolicy,
|
CodexApprovalPolicy,
|
||||||
CodexCliConfig,
|
CodexCliConfig,
|
||||||
CodexAuthStatus,
|
CodexAuthStatus,
|
||||||
|
// Event types for CLI event parsing
|
||||||
|
CodexEventType,
|
||||||
|
CodexItemType,
|
||||||
|
CodexEvent,
|
||||||
} from './codex.js';
|
} from './codex.js';
|
||||||
export * from './codex-models.js';
|
export * from './codex-models.js';
|
||||||
|
|
||||||
@@ -332,3 +338,6 @@ export { PR_STATES, validatePRState } from './worktree.js';
|
|||||||
|
|
||||||
// Terminal types
|
// Terminal types
|
||||||
export type { TerminalInfo } from './terminal.js';
|
export type { TerminalInfo } from './terminal.js';
|
||||||
|
|
||||||
|
// Test runner types
|
||||||
|
export type { TestRunnerInfo } from './test-runner.js';
|
||||||
|
|||||||
@@ -863,6 +863,10 @@ export interface GlobalSettings {
|
|||||||
/** Enable HTTP request logging (Morgan). Default: true */
|
/** Enable HTTP request logging (Morgan). Default: true */
|
||||||
enableRequestLogging?: boolean;
|
enableRequestLogging?: boolean;
|
||||||
|
|
||||||
|
// Developer Tools
|
||||||
|
/** Show React Query DevTools panel (only in development mode). Default: true */
|
||||||
|
showQueryDevtools?: boolean;
|
||||||
|
|
||||||
// AI Commit Message Generation
|
// AI Commit Message Generation
|
||||||
/** Enable AI-generated commit messages when opening commit dialog (default: true) */
|
/** Enable AI-generated commit messages when opening commit dialog (default: true) */
|
||||||
enableAiCommitMessages: boolean;
|
enableAiCommitMessages: boolean;
|
||||||
@@ -1178,6 +1182,14 @@ export interface ProjectSettings {
|
|||||||
/** Maximum concurrent agents for this project (overrides global maxConcurrency) */
|
/** Maximum concurrent agents for this project (overrides global maxConcurrency) */
|
||||||
maxConcurrentAgents?: number;
|
maxConcurrentAgents?: number;
|
||||||
|
|
||||||
|
// Test Runner Configuration
|
||||||
|
/**
|
||||||
|
* Custom command to run tests for this project.
|
||||||
|
* If not specified, auto-detection will be used based on project structure.
|
||||||
|
* Examples: "npm test", "yarn test", "pnpm test", "pytest", "go test ./..."
|
||||||
|
*/
|
||||||
|
testCommand?: string;
|
||||||
|
|
||||||
// Phase Model Overrides (per-project)
|
// Phase Model Overrides (per-project)
|
||||||
/**
|
/**
|
||||||
* Override phase model settings for this project.
|
* Override phase model settings for this project.
|
||||||
@@ -1186,6 +1198,13 @@ export interface ProjectSettings {
|
|||||||
*/
|
*/
|
||||||
phaseModelOverrides?: Partial<PhaseModelConfig>;
|
phaseModelOverrides?: Partial<PhaseModelConfig>;
|
||||||
|
|
||||||
|
// Feature Defaults Override (per-project)
|
||||||
|
/**
|
||||||
|
* Override the default model for new feature cards in this project.
|
||||||
|
* If not specified, falls back to the global defaultFeatureModel setting.
|
||||||
|
*/
|
||||||
|
defaultFeatureModel?: PhaseModelEntry;
|
||||||
|
|
||||||
// Deprecated Claude API Profile Override
|
// Deprecated Claude API Profile Override
|
||||||
/**
|
/**
|
||||||
* @deprecated Use phaseModelOverrides instead.
|
* @deprecated Use phaseModelOverrides instead.
|
||||||
@@ -1279,6 +1298,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
|||||||
muteDoneSound: false,
|
muteDoneSound: false,
|
||||||
serverLogLevel: 'info',
|
serverLogLevel: 'info',
|
||||||
enableRequestLogging: true,
|
enableRequestLogging: true,
|
||||||
|
showQueryDevtools: true,
|
||||||
enableAiCommitMessages: true,
|
enableAiCommitMessages: true,
|
||||||
phaseModels: DEFAULT_PHASE_MODELS,
|
phaseModels: DEFAULT_PHASE_MODELS,
|
||||||
enhancementModel: 'sonnet', // Legacy alias still supported
|
enhancementModel: 'sonnet', // Legacy alias still supported
|
||||||
|
|||||||
17
libs/types/src/test-runner.ts
Normal file
17
libs/types/src/test-runner.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Test runner types for the test runner functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Information about an available test runner
|
||||||
|
*/
|
||||||
|
export interface TestRunnerInfo {
|
||||||
|
/** Unique identifier for the test runner (e.g., 'vitest', 'jest', 'pytest') */
|
||||||
|
id: string;
|
||||||
|
/** Display name of the test runner (e.g., "Vitest", "Jest", "Pytest") */
|
||||||
|
name: string;
|
||||||
|
/** CLI command to run all tests */
|
||||||
|
command: string;
|
||||||
|
/** Optional: CLI command pattern to run a specific test file */
|
||||||
|
fileCommand?: string;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "automaker",
|
"name": "automaker",
|
||||||
"version": "0.12.0rc",
|
"version": "0.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0 <23.0.0"
|
"node": ">=22.0.0 <23.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user