mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-21 23:33:07 +00:00
Merge remote-tracking branch 'upstream/v0.15.0rc' into patchcraft
This commit is contained in:
@@ -14,10 +14,6 @@
|
|||||||
|
|
||||||
**Stop typing code. Start directing AI agents.**
|
**Stop typing code. Start directing AI agents.**
|
||||||
|
|
||||||
> **[!WARNING]**
|
|
||||||
>
|
|
||||||
> **This project is no longer actively maintained.** The codebase is provided as-is. No bug fixes, security updates, or new features are being developed.
|
|
||||||
|
|
||||||
<details open>
|
<details open>
|
||||||
<summary><h2>Table of Contents</h2></summary>
|
<summary><h2>Table of Contents</h2></summary>
|
||||||
|
|
||||||
|
|||||||
74
apps/server/eslint.config.mjs
Normal file
74
apps/server/eslint.config.mjs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import ts from '@typescript-eslint/eslint-plugin';
|
||||||
|
import tsParser from '@typescript-eslint/parser';
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
// Node.js globals
|
||||||
|
console: 'readonly',
|
||||||
|
process: 'readonly',
|
||||||
|
Buffer: 'readonly',
|
||||||
|
__dirname: 'readonly',
|
||||||
|
__filename: 'readonly',
|
||||||
|
URL: 'readonly',
|
||||||
|
URLSearchParams: 'readonly',
|
||||||
|
AbortController: 'readonly',
|
||||||
|
AbortSignal: 'readonly',
|
||||||
|
fetch: 'readonly',
|
||||||
|
Response: 'readonly',
|
||||||
|
Request: 'readonly',
|
||||||
|
Headers: 'readonly',
|
||||||
|
FormData: 'readonly',
|
||||||
|
RequestInit: 'readonly',
|
||||||
|
// Timers
|
||||||
|
setTimeout: 'readonly',
|
||||||
|
setInterval: 'readonly',
|
||||||
|
clearTimeout: 'readonly',
|
||||||
|
clearInterval: 'readonly',
|
||||||
|
setImmediate: 'readonly',
|
||||||
|
clearImmediate: 'readonly',
|
||||||
|
queueMicrotask: 'readonly',
|
||||||
|
// Node.js types
|
||||||
|
NodeJS: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'@typescript-eslint': ts,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...ts.configs.recommended.rules,
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
ignoreRestSiblings: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
// Server code frequently works with terminal output containing ANSI escape codes
|
||||||
|
'no-control-regex': 'off',
|
||||||
|
'@typescript-eslint/ban-ts-comment': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
'ts-nocheck': 'allow-with-description',
|
||||||
|
minimumDescriptionLength: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
globalIgnores(['dist/**', 'node_modules/**']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -368,24 +368,61 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
|
|||||||
logger.warn('Failed to check for legacy settings migration:', err);
|
logger.warn('Failed to check for legacy settings migration:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply logging settings from saved settings
|
// Fetch global settings once and reuse for logging config and feature reconciliation
|
||||||
|
let globalSettings: Awaited<ReturnType<typeof settingsService.getGlobalSettings>> | null = null;
|
||||||
try {
|
try {
|
||||||
const settings = await settingsService.getGlobalSettings();
|
globalSettings = await settingsService.getGlobalSettings();
|
||||||
if (settings.serverLogLevel && LOG_LEVEL_MAP[settings.serverLogLevel] !== undefined) {
|
|
||||||
setLogLevel(LOG_LEVEL_MAP[settings.serverLogLevel]);
|
|
||||||
logger.info(`Server log level set to: ${settings.serverLogLevel}`);
|
|
||||||
}
|
|
||||||
// Apply request logging setting (default true if not set)
|
|
||||||
const enableRequestLog = settings.enableRequestLogging ?? true;
|
|
||||||
setRequestLoggingEnabled(enableRequestLog);
|
|
||||||
logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to load logging settings, using defaults');
|
logger.warn('Failed to load global settings, using defaults');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply logging settings from saved settings
|
||||||
|
if (globalSettings) {
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
globalSettings.serverLogLevel &&
|
||||||
|
LOG_LEVEL_MAP[globalSettings.serverLogLevel] !== undefined
|
||||||
|
) {
|
||||||
|
setLogLevel(LOG_LEVEL_MAP[globalSettings.serverLogLevel]);
|
||||||
|
logger.info(`Server log level set to: ${globalSettings.serverLogLevel}`);
|
||||||
|
}
|
||||||
|
// Apply request logging setting (default true if not set)
|
||||||
|
const enableRequestLog = globalSettings.enableRequestLogging ?? true;
|
||||||
|
setRequestLoggingEnabled(enableRequestLog);
|
||||||
|
logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to apply logging settings, using defaults');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await agentService.initialize();
|
await agentService.initialize();
|
||||||
logger.info('Agent service initialized');
|
logger.info('Agent service initialized');
|
||||||
|
|
||||||
|
// Reconcile feature states on startup
|
||||||
|
// After any type of restart (clean, forced, crash), features may be stuck in
|
||||||
|
// transient states (in_progress, interrupted, pipeline_*) that don't match reality.
|
||||||
|
// Reconcile them back to resting states before the UI is served.
|
||||||
|
if (globalSettings) {
|
||||||
|
try {
|
||||||
|
if (globalSettings.projects && globalSettings.projects.length > 0) {
|
||||||
|
let totalReconciled = 0;
|
||||||
|
for (const project of globalSettings.projects) {
|
||||||
|
const count = await autoModeService.reconcileFeatureStates(project.path);
|
||||||
|
totalReconciled += count;
|
||||||
|
}
|
||||||
|
if (totalReconciled > 0) {
|
||||||
|
logger.info(
|
||||||
|
`[STARTUP] Reconciled ${totalReconciled} feature(s) across ${globalSettings.projects.length} project(s)`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info('[STARTUP] Feature state reconciliation complete - no stale states found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('[STARTUP] Failed to reconcile feature states:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Bootstrap Codex model cache in background (don't block server startup)
|
// Bootstrap Codex model cache in background (don't block server startup)
|
||||||
void codexModelCacheService.getModels().catch((err) => {
|
void codexModelCacheService.getModels().catch((err) => {
|
||||||
logger.error('Failed to bootstrap Codex model cache:', err);
|
logger.error('Failed to bootstrap Codex model cache:', err);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js';
|
|||||||
import { createCommitFeatureHandler } from './routes/commit-feature.js';
|
import { createCommitFeatureHandler } from './routes/commit-feature.js';
|
||||||
import { createApprovePlanHandler } from './routes/approve-plan.js';
|
import { createApprovePlanHandler } from './routes/approve-plan.js';
|
||||||
import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
|
import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
|
||||||
|
import { createReconcileHandler } from './routes/reconcile.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create auto-mode routes.
|
* Create auto-mode routes.
|
||||||
@@ -81,6 +82,11 @@ export function createAutoModeRoutes(autoModeService: AutoModeServiceCompat): Ro
|
|||||||
validatePathParams('projectPath'),
|
validatePathParams('projectPath'),
|
||||||
createResumeInterruptedHandler(autoModeService)
|
createResumeInterruptedHandler(autoModeService)
|
||||||
);
|
);
|
||||||
|
router.post(
|
||||||
|
'/reconcile',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createReconcileHandler(autoModeService)
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
53
apps/server/src/routes/auto-mode/routes/reconcile.ts
Normal file
53
apps/server/src/routes/auto-mode/routes/reconcile.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Reconcile Feature States Handler
|
||||||
|
*
|
||||||
|
* On-demand endpoint to reconcile all feature states for a project.
|
||||||
|
* Resets features stuck in transient states (in_progress, interrupted, pipeline_*)
|
||||||
|
* back to resting states (ready/backlog) and emits events to update the UI.
|
||||||
|
*
|
||||||
|
* This is useful when:
|
||||||
|
* - The UI reconnects after a server restart
|
||||||
|
* - A client detects stale feature states
|
||||||
|
* - An admin wants to force-reset stuck features
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||||
|
|
||||||
|
const logger = createLogger('ReconcileFeatures');
|
||||||
|
|
||||||
|
interface ReconcileRequest {
|
||||||
|
projectPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReconcileHandler(autoModeService: AutoModeServiceCompat) {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const { projectPath } = req.body as ReconcileRequest;
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({ error: 'Project path is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Reconciling feature states for ${projectPath}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reconciledCount = await autoModeService.reconcileFeatureStates(projectPath);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
reconciledCount,
|
||||||
|
message:
|
||||||
|
reconciledCount > 0
|
||||||
|
? `Reconciled ${reconciledCount} feature(s)`
|
||||||
|
: 'No features needed reconciliation',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error reconciling feature states:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -24,19 +24,6 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate title if title is provided
|
|
||||||
if (feature.title && feature.title.trim()) {
|
|
||||||
const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title);
|
|
||||||
if (duplicate) {
|
|
||||||
res.status(409).json({
|
|
||||||
success: false,
|
|
||||||
error: `A feature with title "${feature.title}" already exists`,
|
|
||||||
duplicateFeatureId: duplicate.id,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = await featureLoader.create(projectPath, feature);
|
const created = await featureLoader.create(projectPath, feature);
|
||||||
|
|
||||||
// Emit feature_created event for hooks
|
// Emit feature_created event for hooks
|
||||||
|
|||||||
@@ -40,23 +40,6 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate title if title is being updated
|
|
||||||
if (updates.title && updates.title.trim()) {
|
|
||||||
const duplicate = await featureLoader.findDuplicateTitle(
|
|
||||||
projectPath,
|
|
||||||
updates.title,
|
|
||||||
featureId // Exclude the current feature from duplicate check
|
|
||||||
);
|
|
||||||
if (duplicate) {
|
|
||||||
res.status(409).json({
|
|
||||||
success: false,
|
|
||||||
error: `A feature with title "${updates.title}" already exists`,
|
|
||||||
duplicateFeatureId: duplicate.id,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the current feature to detect status changes
|
// Get the current feature to detect status changes
|
||||||
const currentFeature = await featureLoader.get(projectPath, featureId);
|
const currentFeature = await featureLoader.get(projectPath, featureId);
|
||||||
const previousStatus = currentFeature?.status as FeatureStatus | undefined;
|
const previousStatus = currentFeature?.status as FeatureStatus | undefined;
|
||||||
|
|||||||
@@ -101,7 +101,12 @@ export function createWorktreeRoutes(
|
|||||||
requireValidWorktree,
|
requireValidWorktree,
|
||||||
createPullHandler()
|
createPullHandler()
|
||||||
);
|
);
|
||||||
router.post('/checkout-branch', requireValidWorktree, createCheckoutBranchHandler());
|
router.post(
|
||||||
|
'/checkout-branch',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireValidWorktree,
|
||||||
|
createCheckoutBranchHandler()
|
||||||
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/list-branches',
|
'/list-branches',
|
||||||
validatePathParams('worktreePath'),
|
validatePathParams('worktreePath'),
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
* POST /checkout-branch endpoint - Create and checkout a new branch
|
* POST /checkout-branch endpoint - Create and checkout a new branch
|
||||||
*
|
*
|
||||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||||
* the requireValidWorktree middleware in index.ts
|
* the requireValidWorktree middleware in index.ts.
|
||||||
|
* Path validation (ALLOWED_ROOT_DIRECTORY) is handled by validatePathParams
|
||||||
|
* middleware in index.ts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import path from 'path';
|
||||||
import { promisify } from 'util';
|
import { stat } from 'fs/promises';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
export function createCheckoutBranchHandler() {
|
export function createCheckoutBranchHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
@@ -36,27 +36,47 @@ export function createCheckoutBranchHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate branch name (basic validation)
|
// Validate branch name using shared allowlist: /^[a-zA-Z0-9._\-/]+$/
|
||||||
const invalidChars = /[\s~^:?*\[\\]/;
|
if (!isValidBranchName(branchName)) {
|
||||||
if (invalidChars.test(branchName)) {
|
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Branch name contains invalid characters',
|
error:
|
||||||
|
'Invalid branch name. Must contain only letters, numbers, dots, dashes, underscores, or slashes.',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current branch for reference
|
// Resolve and validate worktreePath to prevent traversal attacks.
|
||||||
const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
// The validatePathParams middleware checks against ALLOWED_ROOT_DIRECTORY,
|
||||||
cwd: worktreePath,
|
// but we also resolve the path and verify it exists as a directory.
|
||||||
});
|
const resolvedPath = path.resolve(worktreePath);
|
||||||
|
try {
|
||||||
|
const stats = await stat(resolvedPath);
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'worktreePath is not a directory',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'worktreePath does not exist or is not accessible',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current branch for reference (using argument array to avoid shell injection)
|
||||||
|
const currentBranchOutput = await execGitCommand(
|
||||||
|
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||||
|
resolvedPath
|
||||||
|
);
|
||||||
const currentBranch = currentBranchOutput.trim();
|
const currentBranch = currentBranchOutput.trim();
|
||||||
|
|
||||||
// Check if branch already exists
|
// Check if branch already exists
|
||||||
try {
|
try {
|
||||||
await execAsync(`git rev-parse --verify ${branchName}`, {
|
await execGitCommand(['rev-parse', '--verify', branchName], resolvedPath);
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
// Branch exists
|
// Branch exists
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -67,10 +87,8 @@ export function createCheckoutBranchHandler() {
|
|||||||
// Branch doesn't exist, good to create
|
// Branch doesn't exist, good to create
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and checkout the new branch
|
// Create and checkout the new branch (using argument array to avoid shell injection)
|
||||||
await execAsync(`git checkout -b ${branchName}`, {
|
await execGitCommand(['checkout', '-b', branchName], resolvedPath);
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -125,19 +125,14 @@ export function createOpenInEditorHandler() {
|
|||||||
`Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}`
|
`Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
const result = await openInFileManager(worktreePath);
|
||||||
const result = await openInFileManager(worktreePath);
|
res.json({
|
||||||
res.json({
|
success: true,
|
||||||
success: true,
|
result: {
|
||||||
result: {
|
message: `Opened ${worktreePath} in ${result.editorName}`,
|
||||||
message: `Opened ${worktreePath} in ${result.editorName}`,
|
editorName: result.editorName,
|
||||||
editorName: result.editorName,
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
} catch (fallbackError) {
|
|
||||||
// Both editor and file manager failed
|
|
||||||
throw fallbackError;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Open in editor failed');
|
logError(error, 'Open in editor failed');
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ export class AutoModeServiceCompat {
|
|||||||
return this.globalService.markAllRunningFeaturesInterrupted(reason);
|
return this.globalService.markAllRunningFeaturesInterrupted(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reconcileFeatureStates(projectPath: string): Promise<number> {
|
||||||
|
return this.globalService.reconcileFeatureStates(projectPath);
|
||||||
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// PER-PROJECT OPERATIONS (delegated to facades)
|
// PER-PROJECT OPERATIONS (delegated to facades)
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|||||||
@@ -205,4 +205,21 @@ export class GlobalAutoModeService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile all feature states for a project on server startup.
|
||||||
|
*
|
||||||
|
* Resets features stuck in transient states (in_progress, interrupted, pipeline_*)
|
||||||
|
* back to a resting state and emits events so the UI reflects corrected states.
|
||||||
|
*
|
||||||
|
* This should be called during server initialization to handle:
|
||||||
|
* - Clean shutdown: features already marked as interrupted
|
||||||
|
* - Forced kill / crash: features left in in_progress or pipeline_* states
|
||||||
|
*
|
||||||
|
* @param projectPath - The project path to reconcile
|
||||||
|
* @returns The number of features that were reconciled
|
||||||
|
*/
|
||||||
|
async reconcileFeatureStates(projectPath: string): Promise<number> {
|
||||||
|
return this.featureStateManager.reconcileAllFeatureStates(projectPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -662,7 +662,7 @@ export class ClaudeUsageService {
|
|||||||
|
|
||||||
resetTime = this.parseResetTime(resetText, type);
|
resetTime = this.parseResetTime(resetText, type);
|
||||||
// Strip timezone like "(Asia/Dubai)" from the display text
|
// Strip timezone like "(Asia/Dubai)" from the display text
|
||||||
resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim();
|
resetText = resetText.replace(/\s*\([A-Za-z_/]+\)\s*$/, '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { percentage: percentage ?? 0, resetTime, resetText };
|
return { percentage: percentage ?? 0, resetTime, resetText };
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ class DevServerService {
|
|||||||
/(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format
|
/(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format
|
||||||
/(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format
|
/(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format
|
||||||
/(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]):\d+)/i, // Generic localhost URL
|
/(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]):\d+)/i, // Generic localhost URL
|
||||||
/(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/i, // Any HTTP(S) URL
|
/(https?:\/\/[^\s<>"{}|\\^`[\]]+)/i, // Any HTTP(S) URL
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const pattern of urlPatterns) {
|
for (const pattern of urlPatterns) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { getFeatureDir, getFeaturesDir } from '@automaker/platform';
|
import { getFeatureDir, getFeaturesDir } from '@automaker/platform';
|
||||||
import * as secureFs from '../lib/secure-fs.js';
|
import * as secureFs from '../lib/secure-fs.js';
|
||||||
import type { EventEmitter } from '../lib/events.js';
|
import type { EventEmitter } from '../lib/events.js';
|
||||||
|
import type { AutoModeEventType } from './typed-event-bus.js';
|
||||||
import { getNotificationService } from './notification-service.js';
|
import { getNotificationService } from './notification-service.js';
|
||||||
import { FeatureLoader } from './feature-loader.js';
|
import { FeatureLoader } from './feature-loader.js';
|
||||||
|
|
||||||
@@ -268,20 +269,39 @@ export class FeatureStateManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset features that were stuck in transient states due to server crash.
|
* Shared helper that scans features in a project directory and resets any stuck
|
||||||
* Called when auto mode is enabled to clean up from previous session.
|
* in transient states (in_progress, interrupted, pipeline_*) back to resting states.
|
||||||
*
|
*
|
||||||
* Resets:
|
* Also resets:
|
||||||
* - in_progress features back to ready (if has plan) or backlog (if no plan)
|
|
||||||
* - generating planSpec status back to pending
|
* - generating planSpec status back to pending
|
||||||
* - in_progress tasks back to pending
|
* - in_progress tasks back to pending
|
||||||
*
|
*
|
||||||
* @param projectPath - The project path to reset features for
|
* @param projectPath - The project path to scan
|
||||||
|
* @param callerLabel - Label for log messages (e.g., 'resetStuckFeatures', 'reconcileAllFeatureStates')
|
||||||
|
* @returns Object with reconciledFeatures (id + status info), reconciledCount, and scanned count
|
||||||
*/
|
*/
|
||||||
async resetStuckFeatures(projectPath: string): Promise<void> {
|
private async scanAndResetFeatures(
|
||||||
|
projectPath: string,
|
||||||
|
callerLabel: string
|
||||||
|
): Promise<{
|
||||||
|
reconciledFeatures: Array<{
|
||||||
|
id: string;
|
||||||
|
previousStatus: string | undefined;
|
||||||
|
newStatus: string | undefined;
|
||||||
|
}>;
|
||||||
|
reconciledFeatureIds: string[];
|
||||||
|
reconciledCount: number;
|
||||||
|
scanned: number;
|
||||||
|
}> {
|
||||||
const featuresDir = getFeaturesDir(projectPath);
|
const featuresDir = getFeaturesDir(projectPath);
|
||||||
let featuresScanned = 0;
|
let scanned = 0;
|
||||||
let featuresReset = 0;
|
let reconciledCount = 0;
|
||||||
|
const reconciledFeatureIds: string[] = [];
|
||||||
|
const reconciledFeatures: Array<{
|
||||||
|
id: string;
|
||||||
|
previousStatus: string | undefined;
|
||||||
|
newStatus: string | undefined;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true });
|
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true });
|
||||||
@@ -289,7 +309,7 @@ export class FeatureStateManager {
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isDirectory()) continue;
|
if (!entry.isDirectory()) continue;
|
||||||
|
|
||||||
featuresScanned++;
|
scanned++;
|
||||||
const featurePath = path.join(featuresDir, entry.name, 'feature.json');
|
const featurePath = path.join(featuresDir, entry.name, 'feature.json');
|
||||||
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
||||||
maxBackups: DEFAULT_BACKUP_COUNT,
|
maxBackups: DEFAULT_BACKUP_COUNT,
|
||||||
@@ -300,14 +320,21 @@ export class FeatureStateManager {
|
|||||||
if (!feature) continue;
|
if (!feature) continue;
|
||||||
|
|
||||||
let needsUpdate = false;
|
let needsUpdate = false;
|
||||||
|
const originalStatus = feature.status;
|
||||||
|
|
||||||
// Reset in_progress features back to ready/backlog
|
// Reset features in active execution states back to a resting state
|
||||||
if (feature.status === 'in_progress') {
|
// After a server restart, no processes are actually running
|
||||||
|
const isActiveState =
|
||||||
|
originalStatus === 'in_progress' ||
|
||||||
|
originalStatus === 'interrupted' ||
|
||||||
|
(originalStatus != null && originalStatus.startsWith('pipeline_'));
|
||||||
|
|
||||||
|
if (isActiveState) {
|
||||||
const hasApprovedPlan = feature.planSpec?.status === 'approved';
|
const hasApprovedPlan = feature.planSpec?.status === 'approved';
|
||||||
feature.status = hasApprovedPlan ? 'ready' : 'backlog';
|
feature.status = hasApprovedPlan ? 'ready' : 'backlog';
|
||||||
needsUpdate = true;
|
needsUpdate = true;
|
||||||
logger.info(
|
logger.info(
|
||||||
`[resetStuckFeatures] Reset feature ${feature.id} from in_progress to ${feature.status}`
|
`[${callerLabel}] Reset feature ${feature.id} from ${originalStatus} to ${feature.status}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +343,7 @@ export class FeatureStateManager {
|
|||||||
feature.planSpec.status = 'pending';
|
feature.planSpec.status = 'pending';
|
||||||
needsUpdate = true;
|
needsUpdate = true;
|
||||||
logger.info(
|
logger.info(
|
||||||
`[resetStuckFeatures] Reset feature ${feature.id} planSpec status from generating to pending`
|
`[${callerLabel}] Reset feature ${feature.id} planSpec status from generating to pending`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,13 +354,13 @@ export class FeatureStateManager {
|
|||||||
task.status = 'pending';
|
task.status = 'pending';
|
||||||
needsUpdate = true;
|
needsUpdate = true;
|
||||||
logger.info(
|
logger.info(
|
||||||
`[resetStuckFeatures] Reset task ${task.id} for feature ${feature.id} from in_progress to pending`
|
`[${callerLabel}] Reset task ${task.id} for feature ${feature.id} from in_progress to pending`
|
||||||
);
|
);
|
||||||
// Clear currentTaskId if it points to this reverted task
|
// Clear currentTaskId if it points to this reverted task
|
||||||
if (feature.planSpec?.currentTaskId === task.id) {
|
if (feature.planSpec?.currentTaskId === task.id) {
|
||||||
feature.planSpec.currentTaskId = undefined;
|
feature.planSpec.currentTaskId = undefined;
|
||||||
logger.info(
|
logger.info(
|
||||||
`[resetStuckFeatures] Cleared planSpec.currentTaskId for feature ${feature.id} (was pointing to reverted task ${task.id})`
|
`[${callerLabel}] Cleared planSpec.currentTaskId for feature ${feature.id} (was pointing to reverted task ${task.id})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,19 +370,94 @@ export class FeatureStateManager {
|
|||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
feature.updatedAt = new Date().toISOString();
|
feature.updatedAt = new Date().toISOString();
|
||||||
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||||
featuresReset++;
|
reconciledCount++;
|
||||||
|
reconciledFeatureIds.push(feature.id);
|
||||||
|
reconciledFeatures.push({
|
||||||
|
id: feature.id,
|
||||||
|
previousStatus: originalStatus,
|
||||||
|
newStatus: feature.status,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`[resetStuckFeatures] Scanned ${featuresScanned} features, reset ${featuresReset} features for ${projectPath}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If features directory doesn't exist, that's fine
|
// If features directory doesn't exist, that's fine
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
logger.error(`[resetStuckFeatures] Error resetting features for ${projectPath}:`, error);
|
logger.error(`[${callerLabel}] Error resetting features for ${projectPath}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { reconciledFeatures, reconciledFeatureIds, reconciledCount, scanned };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset features that were stuck in transient states due to server crash.
|
||||||
|
* Called when auto mode is enabled to clean up from previous session.
|
||||||
|
*
|
||||||
|
* Resets:
|
||||||
|
* - in_progress features back to ready (if has plan) or backlog (if no plan)
|
||||||
|
* - interrupted features back to ready (if has plan) or backlog (if no plan)
|
||||||
|
* - pipeline_* features back to ready (if has plan) or backlog (if no plan)
|
||||||
|
* - generating planSpec status back to pending
|
||||||
|
* - in_progress tasks back to pending
|
||||||
|
*
|
||||||
|
* @param projectPath - The project path to reset features for
|
||||||
|
*/
|
||||||
|
async resetStuckFeatures(projectPath: string): Promise<void> {
|
||||||
|
const { reconciledCount, scanned } = await this.scanAndResetFeatures(
|
||||||
|
projectPath,
|
||||||
|
'resetStuckFeatures'
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[resetStuckFeatures] Scanned ${scanned} features, reset ${reconciledCount} features for ${projectPath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile all feature states on server startup.
|
||||||
|
*
|
||||||
|
* This method resets all features stuck in transient states (in_progress,
|
||||||
|
* interrupted, pipeline_*) and emits events so connected UI clients
|
||||||
|
* immediately reflect the corrected states.
|
||||||
|
*
|
||||||
|
* Should be called once during server initialization, before the UI is served,
|
||||||
|
* to ensure feature state consistency after any type of restart (clean, forced, crash).
|
||||||
|
*
|
||||||
|
* @param projectPath - The project path to reconcile features for
|
||||||
|
* @returns The number of features that were reconciled
|
||||||
|
*/
|
||||||
|
async reconcileAllFeatureStates(projectPath: string): Promise<number> {
|
||||||
|
logger.info(`[reconcileAllFeatureStates] Starting reconciliation for ${projectPath}`);
|
||||||
|
|
||||||
|
const { reconciledFeatures, reconciledFeatureIds, reconciledCount, scanned } =
|
||||||
|
await this.scanAndResetFeatures(projectPath, 'reconcileAllFeatureStates');
|
||||||
|
|
||||||
|
// Emit per-feature status change events so UI invalidates its cache
|
||||||
|
for (const { id, previousStatus, newStatus } of reconciledFeatures) {
|
||||||
|
this.emitAutoModeEvent('feature_status_changed', {
|
||||||
|
featureId: id,
|
||||||
|
projectPath,
|
||||||
|
status: newStatus,
|
||||||
|
previousStatus,
|
||||||
|
reason: 'server_restart_reconciliation',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit a bulk reconciliation event for the UI
|
||||||
|
if (reconciledCount > 0) {
|
||||||
|
this.emitAutoModeEvent('features_reconciled', {
|
||||||
|
projectPath,
|
||||||
|
reconciledCount,
|
||||||
|
reconciledFeatureIds,
|
||||||
|
message: `Reconciled ${reconciledCount} feature(s) after server restart`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[reconcileAllFeatureStates] Scanned ${scanned} features, reconciled ${reconciledCount} for ${projectPath}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return reconciledCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -532,7 +634,7 @@ export class FeatureStateManager {
|
|||||||
* @param eventType - The event type (e.g., 'auto_mode_summary')
|
* @param eventType - The event type (e.g., 'auto_mode_summary')
|
||||||
* @param data - The event payload
|
* @param data - The event payload
|
||||||
*/
|
*/
|
||||||
private emitAutoModeEvent(eventType: string, data: Record<string, unknown>): void {
|
private emitAutoModeEvent(eventType: AutoModeEventType, data: Record<string, unknown>): void {
|
||||||
// Wrap the event in auto-mode:event format expected by the client
|
// Wrap the event in auto-mode:event format expected by the client
|
||||||
this.events.emit('auto-mode:event', {
|
this.events.emit('auto-mode:event', {
|
||||||
type: eventType,
|
type: eventType,
|
||||||
|
|||||||
@@ -888,7 +888,7 @@ ${contextSection}${existingWorkSection}`;
|
|||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
// Check for numbered items or markdown headers
|
// Check for numbered items or markdown headers
|
||||||
const titleMatch = line.match(/^(?:\d+[\.\)]\s*\*{0,2}|#{1,3}\s+)(.+)/);
|
const titleMatch = line.match(/^(?:\d+[.)]\s*\*{0,2}|#{1,3}\s+)(.+)/);
|
||||||
|
|
||||||
if (titleMatch) {
|
if (titleMatch) {
|
||||||
// Save previous suggestion
|
// Save previous suggestion
|
||||||
|
|||||||
@@ -40,9 +40,13 @@ export type AutoModeEventType =
|
|||||||
| 'plan_rejected'
|
| 'plan_rejected'
|
||||||
| 'plan_revision_requested'
|
| 'plan_revision_requested'
|
||||||
| 'plan_revision_warning'
|
| 'plan_revision_warning'
|
||||||
|
| 'plan_spec_updated'
|
||||||
| 'pipeline_step_started'
|
| 'pipeline_step_started'
|
||||||
| 'pipeline_step_complete'
|
| 'pipeline_step_complete'
|
||||||
| string; // Allow other strings for extensibility
|
| 'pipeline_test_failed'
|
||||||
|
| 'pipeline_merge_conflict'
|
||||||
|
| 'feature_status_changed'
|
||||||
|
| 'features_reconciled';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TypedEventBus wraps an EventEmitter to provide type-safe event emission
|
* TypedEventBus wraps an EventEmitter to provide type-safe event emission
|
||||||
|
|||||||
@@ -119,7 +119,15 @@ const eslintConfig = defineConfig([
|
|||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...ts.configs.recommended.rules,
|
...ts.configs.recommended.rules,
|
||||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
ignoreRestSiblings: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
'@typescript-eslint/no-explicit-any': 'warn',
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
'@typescript-eslint/ban-ts-comment': [
|
'@typescript-eslint/ban-ts-comment': [
|
||||||
'error',
|
'error',
|
||||||
|
|||||||
@@ -590,6 +590,7 @@ export function BoardView() {
|
|||||||
handleForceStopFeature,
|
handleForceStopFeature,
|
||||||
handleStartNextFeatures,
|
handleStartNextFeatures,
|
||||||
handleArchiveAllVerified,
|
handleArchiveAllVerified,
|
||||||
|
handleDuplicateFeature,
|
||||||
} = useBoardActions({
|
} = useBoardActions({
|
||||||
currentProject,
|
currentProject,
|
||||||
features: hookFeatures,
|
features: hookFeatures,
|
||||||
@@ -1503,6 +1504,8 @@ export function BoardView() {
|
|||||||
setSpawnParentFeature(feature);
|
setSpawnParentFeature(feature);
|
||||||
setShowAddDialog(true);
|
setShowAddDialog(true);
|
||||||
},
|
},
|
||||||
|
onDuplicate: (feature) => handleDuplicateFeature(feature, false),
|
||||||
|
onDuplicateAsChild: (feature) => handleDuplicateFeature(feature, true),
|
||||||
}}
|
}}
|
||||||
runningAutoTasks={runningAutoTasksAllWorktrees}
|
runningAutoTasks={runningAutoTasksAllWorktrees}
|
||||||
pipelineConfig={pipelineConfig}
|
pipelineConfig={pipelineConfig}
|
||||||
@@ -1542,6 +1545,8 @@ export function BoardView() {
|
|||||||
setSpawnParentFeature(feature);
|
setSpawnParentFeature(feature);
|
||||||
setShowAddDialog(true);
|
setShowAddDialog(true);
|
||||||
}}
|
}}
|
||||||
|
onDuplicate={(feature) => handleDuplicateFeature(feature, false)}
|
||||||
|
onDuplicateAsChild={(feature) => handleDuplicateFeature(feature, true)}
|
||||||
featuresWithContext={featuresWithContext}
|
featuresWithContext={featuresWithContext}
|
||||||
runningAutoTasks={runningAutoTasksAllWorktrees}
|
runningAutoTasks={runningAutoTasksAllWorktrees}
|
||||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @ts-nocheck - header component props with optional handlers and status variants
|
|
||||||
import { memo, useState } from 'react';
|
import { memo, useState } from 'react';
|
||||||
import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core';
|
import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
@@ -9,6 +8,9 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +22,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
GitFork,
|
GitFork,
|
||||||
|
Copy,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { CountUpTimer } from '@/components/ui/count-up-timer';
|
import { CountUpTimer } from '@/components/ui/count-up-timer';
|
||||||
@@ -27,6 +30,65 @@ import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
|||||||
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
|
||||||
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
||||||
|
|
||||||
|
function DuplicateMenuItems({
|
||||||
|
onDuplicate,
|
||||||
|
onDuplicateAsChild,
|
||||||
|
}: {
|
||||||
|
onDuplicate?: () => void;
|
||||||
|
onDuplicateAsChild?: () => void;
|
||||||
|
}) {
|
||||||
|
if (!onDuplicate) return null;
|
||||||
|
|
||||||
|
// When there's no sub-child action, render a simple menu item (no DropdownMenuSub wrapper)
|
||||||
|
if (!onDuplicateAsChild) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDuplicate();
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3 mr-2" />
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When sub-child action is available, render a proper DropdownMenuSub with
|
||||||
|
// DropdownMenuSubTrigger and DropdownMenuSubContent per Radix conventions
|
||||||
|
return (
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger className="text-xs">
|
||||||
|
<Copy className="w-3 h-3 mr-2" />
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDuplicate();
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3 mr-2" />
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDuplicateAsChild();
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<GitFork className="w-3 h-3 mr-2" />
|
||||||
|
Duplicate as Child
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface CardHeaderProps {
|
interface CardHeaderProps {
|
||||||
feature: Feature;
|
feature: Feature;
|
||||||
isDraggable: boolean;
|
isDraggable: boolean;
|
||||||
@@ -36,6 +98,8 @@ interface CardHeaderProps {
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onViewOutput?: () => void;
|
onViewOutput?: () => void;
|
||||||
onSpawnTask?: () => void;
|
onSpawnTask?: () => void;
|
||||||
|
onDuplicate?: () => void;
|
||||||
|
onDuplicateAsChild?: () => void;
|
||||||
dragHandleListeners?: DraggableSyntheticListeners;
|
dragHandleListeners?: DraggableSyntheticListeners;
|
||||||
dragHandleAttributes?: DraggableAttributes;
|
dragHandleAttributes?: DraggableAttributes;
|
||||||
}
|
}
|
||||||
@@ -49,6 +113,8 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onViewOutput,
|
onViewOutput,
|
||||||
onSpawnTask,
|
onSpawnTask,
|
||||||
|
onDuplicate,
|
||||||
|
onDuplicateAsChild,
|
||||||
dragHandleListeners,
|
dragHandleListeners,
|
||||||
dragHandleAttributes,
|
dragHandleAttributes,
|
||||||
}: CardHeaderProps) {
|
}: CardHeaderProps) {
|
||||||
@@ -71,7 +137,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
|||||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||||
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
|
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
|
||||||
<Spinner size="xs" />
|
<Spinner size="xs" />
|
||||||
{feature.startedAt && (
|
{typeof feature.startedAt === 'string' && (
|
||||||
<CountUpTimer
|
<CountUpTimer
|
||||||
startedAt={feature.startedAt}
|
startedAt={feature.startedAt}
|
||||||
className="text-[var(--status-in-progress)] text-[10px]"
|
className="text-[var(--status-in-progress)] text-[10px]"
|
||||||
@@ -114,6 +180,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
|||||||
<GitFork className="w-3 h-3 mr-2" />
|
<GitFork className="w-3 h-3 mr-2" />
|
||||||
Spawn Sub-Task
|
Spawn Sub-Task
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DuplicateMenuItems
|
||||||
|
onDuplicate={onDuplicate}
|
||||||
|
onDuplicateAsChild={onDuplicateAsChild}
|
||||||
|
/>
|
||||||
{/* Model info in dropdown */}
|
{/* Model info in dropdown */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const ProviderIcon = getProviderIconForModel(feature.model);
|
const ProviderIcon = getProviderIconForModel(feature.model);
|
||||||
@@ -162,6 +232,29 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Only render overflow menu when there are actionable items */}
|
||||||
|
{onDuplicate && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`menu-backlog-${feature.id}`}
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
|
<DuplicateMenuItems
|
||||||
|
onDuplicate={onDuplicate}
|
||||||
|
onDuplicateAsChild={onDuplicateAsChild}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -187,22 +280,6 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
|||||||
>
|
>
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSpawnTask?.();
|
|
||||||
}}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
data-testid={`spawn-${
|
|
||||||
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
|
|
||||||
}-${feature.id}`}
|
|
||||||
title="Spawn Sub-Task"
|
|
||||||
>
|
|
||||||
<GitFork className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
{onViewOutput && (
|
{onViewOutput && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -234,6 +311,41 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`menu-${
|
||||||
|
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
|
||||||
|
}-${feature.id}`}
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSpawnTask?.();
|
||||||
|
}}
|
||||||
|
data-testid={`spawn-${
|
||||||
|
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
|
||||||
|
}-${feature.id}`}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<GitFork className="w-3 h-3 mr-2" />
|
||||||
|
Spawn Sub-Task
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DuplicateMenuItems
|
||||||
|
onDuplicate={onDuplicate}
|
||||||
|
onDuplicateAsChild={onDuplicateAsChild}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -302,6 +414,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
|||||||
<GitFork className="w-3 h-3 mr-2" />
|
<GitFork className="w-3 h-3 mr-2" />
|
||||||
Spawn Sub-Task
|
Spawn Sub-Task
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DuplicateMenuItems
|
||||||
|
onDuplicate={onDuplicate}
|
||||||
|
onDuplicateAsChild={onDuplicateAsChild}
|
||||||
|
/>
|
||||||
{/* Model info in dropdown */}
|
{/* Model info in dropdown */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const ProviderIcon = getProviderIconForModel(feature.model);
|
const ProviderIcon = getProviderIconForModel(feature.model);
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ interface KanbanCardProps {
|
|||||||
onViewPlan?: () => void;
|
onViewPlan?: () => void;
|
||||||
onApprovePlan?: () => void;
|
onApprovePlan?: () => void;
|
||||||
onSpawnTask?: () => void;
|
onSpawnTask?: () => void;
|
||||||
|
onDuplicate?: () => void;
|
||||||
|
onDuplicateAsChild?: () => void;
|
||||||
hasContext?: boolean;
|
hasContext?: boolean;
|
||||||
isCurrentAutoTask?: boolean;
|
isCurrentAutoTask?: boolean;
|
||||||
shortcutKey?: string;
|
shortcutKey?: string;
|
||||||
@@ -86,6 +88,8 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
onViewPlan,
|
onViewPlan,
|
||||||
onApprovePlan,
|
onApprovePlan,
|
||||||
onSpawnTask,
|
onSpawnTask,
|
||||||
|
onDuplicate,
|
||||||
|
onDuplicateAsChild,
|
||||||
hasContext,
|
hasContext,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
shortcutKey,
|
shortcutKey,
|
||||||
@@ -254,6 +258,8 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onViewOutput={onViewOutput}
|
onViewOutput={onViewOutput}
|
||||||
onSpawnTask={onSpawnTask}
|
onSpawnTask={onSpawnTask}
|
||||||
|
onDuplicate={onDuplicate}
|
||||||
|
onDuplicateAsChild={onDuplicateAsChild}
|
||||||
dragHandleListeners={isDraggable ? listeners : undefined}
|
dragHandleListeners={isDraggable ? listeners : undefined}
|
||||||
dragHandleAttributes={isDraggable ? attributes : undefined}
|
dragHandleAttributes={isDraggable ? attributes : undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export interface ListViewActionHandlers {
|
|||||||
onViewPlan?: (feature: Feature) => void;
|
onViewPlan?: (feature: Feature) => void;
|
||||||
onApprovePlan?: (feature: Feature) => void;
|
onApprovePlan?: (feature: Feature) => void;
|
||||||
onSpawnTask?: (feature: Feature) => void;
|
onSpawnTask?: (feature: Feature) => void;
|
||||||
|
onDuplicate?: (feature: Feature) => void;
|
||||||
|
onDuplicateAsChild?: (feature: Feature) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListViewProps {
|
export interface ListViewProps {
|
||||||
@@ -313,6 +315,18 @@ export const ListView = memo(function ListView({
|
|||||||
if (f) actionHandlers.onSpawnTask?.(f);
|
if (f) actionHandlers.onSpawnTask?.(f);
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
duplicate: actionHandlers.onDuplicate
|
||||||
|
? (id) => {
|
||||||
|
const f = allFeatures.find((f) => f.id === id);
|
||||||
|
if (f) actionHandlers.onDuplicate?.(f);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
duplicateAsChild: actionHandlers.onDuplicateAsChild
|
||||||
|
? (id) => {
|
||||||
|
const f = allFeatures.find((f) => f.id === id);
|
||||||
|
if (f) actionHandlers.onDuplicateAsChild?.(f);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[actionHandlers, allFeatures]
|
[actionHandlers, allFeatures]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
GitFork,
|
GitFork,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Copy,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -22,6 +23,9 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import type { Feature } from '@/store/app-store';
|
import type { Feature } from '@/store/app-store';
|
||||||
@@ -43,6 +47,8 @@ export interface RowActionHandlers {
|
|||||||
onViewPlan?: () => void;
|
onViewPlan?: () => void;
|
||||||
onApprovePlan?: () => void;
|
onApprovePlan?: () => void;
|
||||||
onSpawnTask?: () => void;
|
onSpawnTask?: () => void;
|
||||||
|
onDuplicate?: () => void;
|
||||||
|
onDuplicateAsChild?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RowActionsProps {
|
export interface RowActionsProps {
|
||||||
@@ -405,6 +411,31 @@ export const RowActions = memo(function RowActions({
|
|||||||
onClick={withClose(handlers.onSpawnTask)}
|
onClick={withClose(handlers.onSpawnTask)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{handlers.onDuplicate && (
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={withClose(handlers.onDuplicate)}
|
||||||
|
className="flex-1 pr-0 rounded-r-none"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{handlers.onDuplicateAsChild && (
|
||||||
|
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{handlers.onDuplicateAsChild && (
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<MenuItem
|
||||||
|
icon={GitFork}
|
||||||
|
label="Duplicate as Child"
|
||||||
|
onClick={withClose(handlers.onDuplicateAsChild)}
|
||||||
|
/>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
)}
|
||||||
|
</DropdownMenuSub>
|
||||||
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
@@ -457,6 +488,31 @@ export const RowActions = memo(function RowActions({
|
|||||||
onClick={withClose(handlers.onSpawnTask)}
|
onClick={withClose(handlers.onSpawnTask)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{handlers.onDuplicate && (
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={withClose(handlers.onDuplicate)}
|
||||||
|
className="flex-1 pr-0 rounded-r-none"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{handlers.onDuplicateAsChild && (
|
||||||
|
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{handlers.onDuplicateAsChild && (
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<MenuItem
|
||||||
|
icon={GitFork}
|
||||||
|
label="Duplicate as Child"
|
||||||
|
onClick={withClose(handlers.onDuplicateAsChild)}
|
||||||
|
/>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
)}
|
||||||
|
</DropdownMenuSub>
|
||||||
|
)}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
label="Delete"
|
label="Delete"
|
||||||
@@ -503,6 +559,31 @@ export const RowActions = memo(function RowActions({
|
|||||||
onClick={withClose(handlers.onSpawnTask)}
|
onClick={withClose(handlers.onSpawnTask)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{handlers.onDuplicate && (
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={withClose(handlers.onDuplicate)}
|
||||||
|
className="flex-1 pr-0 rounded-r-none"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{handlers.onDuplicateAsChild && (
|
||||||
|
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{handlers.onDuplicateAsChild && (
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<MenuItem
|
||||||
|
icon={GitFork}
|
||||||
|
label="Duplicate as Child"
|
||||||
|
onClick={withClose(handlers.onDuplicateAsChild)}
|
||||||
|
/>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
)}
|
||||||
|
</DropdownMenuSub>
|
||||||
|
)}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
label="Delete"
|
label="Delete"
|
||||||
@@ -554,6 +635,31 @@ export const RowActions = memo(function RowActions({
|
|||||||
onClick={withClose(handlers.onSpawnTask)}
|
onClick={withClose(handlers.onSpawnTask)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{handlers.onDuplicate && (
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={withClose(handlers.onDuplicate)}
|
||||||
|
className="flex-1 pr-0 rounded-r-none"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{handlers.onDuplicateAsChild && (
|
||||||
|
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{handlers.onDuplicateAsChild && (
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<MenuItem
|
||||||
|
icon={GitFork}
|
||||||
|
label="Duplicate as Child"
|
||||||
|
onClick={withClose(handlers.onDuplicateAsChild)}
|
||||||
|
/>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
)}
|
||||||
|
</DropdownMenuSub>
|
||||||
|
)}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
label="Delete"
|
label="Delete"
|
||||||
@@ -581,6 +687,31 @@ export const RowActions = memo(function RowActions({
|
|||||||
onClick={withClose(handlers.onSpawnTask)}
|
onClick={withClose(handlers.onSpawnTask)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{handlers.onDuplicate && (
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={withClose(handlers.onDuplicate)}
|
||||||
|
className="flex-1 pr-0 rounded-r-none"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{handlers.onDuplicateAsChild && (
|
||||||
|
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{handlers.onDuplicateAsChild && (
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<MenuItem
|
||||||
|
icon={GitFork}
|
||||||
|
label="Duplicate as Child"
|
||||||
|
onClick={withClose(handlers.onDuplicateAsChild)}
|
||||||
|
/>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
)}
|
||||||
|
</DropdownMenuSub>
|
||||||
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
@@ -615,6 +746,8 @@ export function createRowActionHandlers(
|
|||||||
viewPlan?: (id: string) => void;
|
viewPlan?: (id: string) => void;
|
||||||
approvePlan?: (id: string) => void;
|
approvePlan?: (id: string) => void;
|
||||||
spawnTask?: (id: string) => void;
|
spawnTask?: (id: string) => void;
|
||||||
|
duplicate?: (id: string) => void;
|
||||||
|
duplicateAsChild?: (id: string) => void;
|
||||||
}
|
}
|
||||||
): RowActionHandlers {
|
): RowActionHandlers {
|
||||||
return {
|
return {
|
||||||
@@ -631,5 +764,9 @@ export function createRowActionHandlers(
|
|||||||
onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined,
|
onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined,
|
||||||
onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined,
|
onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined,
|
||||||
onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined,
|
onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined,
|
||||||
|
onDuplicate: actions.duplicate ? () => actions.duplicate!(featureId) : undefined,
|
||||||
|
onDuplicateAsChild: actions.duplicateAsChild
|
||||||
|
? () => actions.duplicateAsChild!(featureId)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -517,7 +517,7 @@ export function useBoardActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeFeature(featureId);
|
removeFeature(featureId);
|
||||||
persistFeatureDelete(featureId);
|
await persistFeatureDelete(featureId);
|
||||||
},
|
},
|
||||||
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]
|
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]
|
||||||
);
|
);
|
||||||
@@ -1090,6 +1090,38 @@ export function useBoardActions({
|
|||||||
});
|
});
|
||||||
}, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]);
|
}, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]);
|
||||||
|
|
||||||
|
const handleDuplicateFeature = useCallback(
|
||||||
|
async (feature: Feature, asChild: boolean = false) => {
|
||||||
|
// Copy all feature data, stripping id, status (handled by create), and runtime/state fields
|
||||||
|
const {
|
||||||
|
id: _id,
|
||||||
|
status: _status,
|
||||||
|
startedAt: _startedAt,
|
||||||
|
error: _error,
|
||||||
|
summary: _summary,
|
||||||
|
spec: _spec,
|
||||||
|
passes: _passes,
|
||||||
|
planSpec: _planSpec,
|
||||||
|
descriptionHistory: _descriptionHistory,
|
||||||
|
titleGenerating: _titleGenerating,
|
||||||
|
...featureData
|
||||||
|
} = feature;
|
||||||
|
const duplicatedFeatureData = {
|
||||||
|
...featureData,
|
||||||
|
// If duplicating as child, set source as dependency; otherwise keep existing
|
||||||
|
...(asChild && { dependencies: [feature.id] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reuse the existing handleAddFeature logic
|
||||||
|
await handleAddFeature(duplicatedFeatureData);
|
||||||
|
|
||||||
|
toast.success(asChild ? 'Duplicated as child' : 'Feature duplicated', {
|
||||||
|
description: `Created copy of: ${truncateDescription(feature.description || feature.title || '')}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[handleAddFeature]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleAddFeature,
|
handleAddFeature,
|
||||||
handleUpdateFeature,
|
handleUpdateFeature,
|
||||||
@@ -1110,5 +1142,6 @@ export function useBoardActions({
|
|||||||
handleForceStopFeature,
|
handleForceStopFeature,
|
||||||
handleStartNextFeatures,
|
handleStartNextFeatures,
|
||||||
handleArchiveAllVerified,
|
handleArchiveAllVerified,
|
||||||
|
handleDuplicateFeature,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,15 +85,48 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
throw new Error('Features API not available');
|
throw new Error('Features API not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await api.features.create(currentProject.path, feature as ApiFeature);
|
// Capture previous cache snapshot for synchronous rollback on error
|
||||||
if (result.success && result.feature) {
|
const previousFeatures = queryClient.getQueryData<Feature[]>(
|
||||||
updateFeature(result.feature.id, result.feature as Partial<Feature>);
|
queryKeys.features.all(currentProject.path)
|
||||||
// Invalidate React Query cache to sync UI
|
);
|
||||||
|
|
||||||
|
// Optimistically add to React Query cache for immediate board refresh
|
||||||
|
queryClient.setQueryData<Feature[]>(
|
||||||
|
queryKeys.features.all(currentProject.path),
|
||||||
|
(existing) => (existing ? [...existing, feature] : [feature])
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.features.create(currentProject.path, feature as ApiFeature);
|
||||||
|
if (result.success && result.feature) {
|
||||||
|
updateFeature(result.feature.id, result.feature as Partial<Feature>);
|
||||||
|
// Update cache with server-confirmed feature before invalidating
|
||||||
|
queryClient.setQueryData<Feature[]>(
|
||||||
|
queryKeys.features.all(currentProject.path),
|
||||||
|
(features) => {
|
||||||
|
if (!features) return features;
|
||||||
|
return features.map((f) =>
|
||||||
|
f.id === result.feature!.id ? { ...f, ...(result.feature as Feature) } : f
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to create feature on server');
|
||||||
|
}
|
||||||
|
// Always invalidate to sync with server state
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.features.all(currentProject.path),
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
});
|
});
|
||||||
} else if (!result.success) {
|
} catch (error) {
|
||||||
throw new Error(result.error || 'Failed to create feature on server');
|
logger.error('Failed to persist feature creation:', error);
|
||||||
|
// Rollback optimistic update synchronously on error
|
||||||
|
if (previousFeatures) {
|
||||||
|
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentProject, updateFeature, queryClient]
|
[currentProject, updateFeature, queryClient]
|
||||||
@@ -104,20 +137,42 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
async (featureId: string) => {
|
async (featureId: string) => {
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
// Optimistically remove from React Query cache for immediate board refresh
|
||||||
|
const previousFeatures = queryClient.getQueryData<Feature[]>(
|
||||||
|
queryKeys.features.all(currentProject.path)
|
||||||
|
);
|
||||||
|
queryClient.setQueryData<Feature[]>(
|
||||||
|
queryKeys.features.all(currentProject.path),
|
||||||
|
(existing) => (existing ? existing.filter((f) => f.id !== featureId) : existing)
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api.features) {
|
if (!api.features) {
|
||||||
logger.error('Features API not available');
|
// Rollback optimistic deletion since we can't persist
|
||||||
return;
|
if (previousFeatures) {
|
||||||
|
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
});
|
||||||
|
throw new Error('Features API not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.features.delete(currentProject.path, featureId);
|
await api.features.delete(currentProject.path, featureId);
|
||||||
// Invalidate React Query cache to sync UI
|
// Invalidate to sync with server state
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.features.all(currentProject.path),
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to persist feature deletion:', error);
|
logger.error('Failed to persist feature deletion:', error);
|
||||||
|
// Rollback optimistic update on error
|
||||||
|
if (previousFeatures) {
|
||||||
|
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentProject, queryClient]
|
[currentProject, queryClient]
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ interface KanbanBoardProps {
|
|||||||
onViewPlan: (feature: Feature) => void;
|
onViewPlan: (feature: Feature) => void;
|
||||||
onApprovePlan: (feature: Feature) => void;
|
onApprovePlan: (feature: Feature) => void;
|
||||||
onSpawnTask?: (feature: Feature) => void;
|
onSpawnTask?: (feature: Feature) => void;
|
||||||
|
onDuplicate?: (feature: Feature) => void;
|
||||||
|
onDuplicateAsChild?: (feature: Feature) => void;
|
||||||
featuresWithContext: Set<string>;
|
featuresWithContext: Set<string>;
|
||||||
runningAutoTasks: string[];
|
runningAutoTasks: string[];
|
||||||
onArchiveAllVerified: () => void;
|
onArchiveAllVerified: () => void;
|
||||||
@@ -282,6 +284,8 @@ export function KanbanBoard({
|
|||||||
onViewPlan,
|
onViewPlan,
|
||||||
onApprovePlan,
|
onApprovePlan,
|
||||||
onSpawnTask,
|
onSpawnTask,
|
||||||
|
onDuplicate,
|
||||||
|
onDuplicateAsChild,
|
||||||
featuresWithContext,
|
featuresWithContext,
|
||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
onArchiveAllVerified,
|
onArchiveAllVerified,
|
||||||
@@ -569,6 +573,8 @@ export function KanbanBoard({
|
|||||||
onViewPlan={() => onViewPlan(feature)}
|
onViewPlan={() => onViewPlan(feature)}
|
||||||
onApprovePlan={() => onApprovePlan(feature)}
|
onApprovePlan={() => onApprovePlan(feature)}
|
||||||
onSpawnTask={() => onSpawnTask?.(feature)}
|
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||||
|
onDuplicate={() => onDuplicate?.(feature)}
|
||||||
|
onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
|
||||||
hasContext={featuresWithContext.has(feature.id)}
|
hasContext={featuresWithContext.has(feature.id)}
|
||||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||||
shortcutKey={shortcutKey}
|
shortcutKey={shortcutKey}
|
||||||
@@ -611,6 +617,8 @@ export function KanbanBoard({
|
|||||||
onViewPlan={() => onViewPlan(feature)}
|
onViewPlan={() => onViewPlan(feature)}
|
||||||
onApprovePlan={() => onApprovePlan(feature)}
|
onApprovePlan={() => onApprovePlan(feature)}
|
||||||
onSpawnTask={() => onSpawnTask?.(feature)}
|
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||||
|
onDuplicate={() => onDuplicate?.(feature)}
|
||||||
|
onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
|
||||||
hasContext={featuresWithContext.has(feature.id)}
|
hasContext={featuresWithContext.has(feature.id)}
|
||||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||||
shortcutKey={shortcutKey}
|
shortcutKey={shortcutKey}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ function featureToInternal(feature: Feature): FeatureWithId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function internalToFeature(internal: FeatureWithId): Feature {
|
function internalToFeature(internal: FeatureWithId): Feature {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const { _id, _locationIds, ...feature } = internal;
|
const { _id, _locationIds, ...feature } = internal;
|
||||||
return feature;
|
return feature;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ function phaseToInternal(phase: RoadmapPhase): PhaseWithId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function internalToPhase(internal: PhaseWithId): RoadmapPhase {
|
function internalToPhase(internal: PhaseWithId): RoadmapPhase {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const { _id, ...phase } = internal;
|
const { _id, ...phase } = internal;
|
||||||
return phase;
|
return phase;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ const FEATURE_LIST_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
|
|||||||
'plan_rejected',
|
'plan_rejected',
|
||||||
'pipeline_step_started',
|
'pipeline_step_started',
|
||||||
'pipeline_step_complete',
|
'pipeline_step_complete',
|
||||||
|
'feature_status_changed',
|
||||||
|
'features_reconciled',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1062,7 +1062,6 @@ if (typeof window !== 'undefined') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mock API for development/fallback when no backend is available
|
// Mock API for development/fallback when no backend is available
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const _getMockElectronAPI = (): ElectronAPI => {
|
const _getMockElectronAPI = (): ElectronAPI => {
|
||||||
return {
|
return {
|
||||||
ping: async () => 'pong (mock)',
|
ping: async () => 'pong (mock)',
|
||||||
|
|||||||
@@ -155,7 +155,6 @@ export const useTestRunnersStore = create<TestRunnersState & TestRunnersActions>
|
|||||||
const finishedAt = new Date().toISOString();
|
const finishedAt = new Date().toISOString();
|
||||||
|
|
||||||
// Remove from active sessions since it's no longer running
|
// Remove from active sessions since it's no longer running
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const { [session.worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree;
|
const { [session.worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -202,7 +201,6 @@ export const useTestRunnersStore = create<TestRunnersState & TestRunnersActions>
|
|||||||
const session = state.sessions[sessionId];
|
const session = state.sessions[sessionId];
|
||||||
if (!session) return state;
|
if (!session) return state;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const { [sessionId]: _, ...remainingSessions } = state.sessions;
|
const { [sessionId]: _, ...remainingSessions } = state.sessions;
|
||||||
|
|
||||||
// Remove from active if this was the active session
|
// Remove from active if this was the active session
|
||||||
@@ -231,7 +229,6 @@ export const useTestRunnersStore = create<TestRunnersState & TestRunnersActions>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Remove from active
|
// Remove from active
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const { [worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree;
|
const { [worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
17
apps/ui/src/types/electron.d.ts
vendored
17
apps/ui/src/types/electron.d.ts
vendored
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
|
import type { ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
|
||||||
import type { ParsedTask } from '@automaker/types';
|
import type { ParsedTask, FeatureStatusWithPipeline } from '@automaker/types';
|
||||||
|
|
||||||
export interface ImageAttachment {
|
export interface ImageAttachment {
|
||||||
id?: string; // Optional - may not be present in messages loaded from server
|
id?: string; // Optional - may not be present in messages loaded from server
|
||||||
@@ -359,6 +359,21 @@ export type AutoModeEvent =
|
|||||||
title?: string;
|
title?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
}>;
|
}>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'feature_status_changed';
|
||||||
|
featureId: string;
|
||||||
|
projectPath?: string;
|
||||||
|
status: FeatureStatusWithPipeline;
|
||||||
|
previousStatus: FeatureStatusWithPipeline;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'features_reconciled';
|
||||||
|
projectPath?: string;
|
||||||
|
reconciledCount: number;
|
||||||
|
reconciledFeatureIds: string[];
|
||||||
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SpecRegenerationEvent =
|
export type SpecRegenerationEvent =
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type PipelineStatus = `pipeline_${string}`;
|
|||||||
|
|
||||||
export type FeatureStatusWithPipeline =
|
export type FeatureStatusWithPipeline =
|
||||||
| 'backlog'
|
| 'backlog'
|
||||||
|
| 'ready'
|
||||||
| 'in_progress'
|
| 'in_progress'
|
||||||
| 'interrupted'
|
| 'interrupted'
|
||||||
| 'waiting_approval'
|
| 'waiting_approval'
|
||||||
|
|||||||
@@ -36,8 +36,24 @@ elif [[ "$OSTYPE" == "darwin"* ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Port configuration
|
# Port configuration
|
||||||
DEFAULT_WEB_PORT=3007
|
# Defaults can be overridden via AUTOMAKER_WEB_PORT and AUTOMAKER_SERVER_PORT env vars
|
||||||
DEFAULT_SERVER_PORT=3008
|
|
||||||
|
# Validate env-provided ports early (before colors are available)
|
||||||
|
if [ -n "$AUTOMAKER_WEB_PORT" ]; then
|
||||||
|
if ! [[ "$AUTOMAKER_WEB_PORT" =~ ^[0-9]+$ ]] || [ "$AUTOMAKER_WEB_PORT" -lt 1 ] || [ "$AUTOMAKER_WEB_PORT" -gt 65535 ]; then
|
||||||
|
echo "Error: AUTOMAKER_WEB_PORT must be a number between 1-65535, got '$AUTOMAKER_WEB_PORT'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -n "$AUTOMAKER_SERVER_PORT" ]; then
|
||||||
|
if ! [[ "$AUTOMAKER_SERVER_PORT" =~ ^[0-9]+$ ]] || [ "$AUTOMAKER_SERVER_PORT" -lt 1 ] || [ "$AUTOMAKER_SERVER_PORT" -gt 65535 ]; then
|
||||||
|
echo "Error: AUTOMAKER_SERVER_PORT must be a number between 1-65535, got '$AUTOMAKER_SERVER_PORT'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEFAULT_WEB_PORT=${AUTOMAKER_WEB_PORT:-3007}
|
||||||
|
DEFAULT_SERVER_PORT=${AUTOMAKER_SERVER_PORT:-3008}
|
||||||
PORT_SEARCH_MAX_ATTEMPTS=100
|
PORT_SEARCH_MAX_ATTEMPTS=100
|
||||||
WEB_PORT=$DEFAULT_WEB_PORT
|
WEB_PORT=$DEFAULT_WEB_PORT
|
||||||
SERVER_PORT=$DEFAULT_SERVER_PORT
|
SERVER_PORT=$DEFAULT_SERVER_PORT
|
||||||
@@ -136,6 +152,9 @@ EXAMPLES:
|
|||||||
start-automaker.sh docker # Launch Docker dev container
|
start-automaker.sh docker # Launch Docker dev container
|
||||||
start-automaker.sh --version # Show version
|
start-automaker.sh --version # Show version
|
||||||
|
|
||||||
|
AUTOMAKER_WEB_PORT=4000 AUTOMAKER_SERVER_PORT=4001 start-automaker.sh web
|
||||||
|
# Launch web mode on custom ports
|
||||||
|
|
||||||
KEYBOARD SHORTCUTS (in menu):
|
KEYBOARD SHORTCUTS (in menu):
|
||||||
Up/Down arrows Navigate between options
|
Up/Down arrows Navigate between options
|
||||||
Enter Select highlighted option
|
Enter Select highlighted option
|
||||||
@@ -146,6 +165,10 @@ HISTORY:
|
|||||||
Your last selected mode is remembered in: ~/.automaker_launcher_history
|
Your last selected mode is remembered in: ~/.automaker_launcher_history
|
||||||
Use --no-history to disable this feature
|
Use --no-history to disable this feature
|
||||||
|
|
||||||
|
ENVIRONMENT VARIABLES:
|
||||||
|
AUTOMAKER_WEB_PORT Override default web/UI port (default: 3007)
|
||||||
|
AUTOMAKER_SERVER_PORT Override default API server port (default: 3008)
|
||||||
|
|
||||||
PLATFORMS:
|
PLATFORMS:
|
||||||
Linux, macOS, Windows (Git Bash, WSL, MSYS2, Cygwin)
|
Linux, macOS, Windows (Git Bash, WSL, MSYS2, Cygwin)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user