mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
Add orphaned features management routes and UI integration (#819)
* test(copilot): add edge case test for error with code field Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Changes from fix/bug-fixes-1-0 * refactor(auto-mode): enhance orphaned feature detection and improve project initialization - Updated detectOrphanedFeatures method to accept preloaded features, reducing redundant disk reads. - Improved project initialization by creating required directories and files in parallel for better performance. - Adjusted planning mode handling in UI components to clarify approval requirements for different modes. - Added refresh functionality for file editor tabs to ensure content consistency with disk state. These changes enhance performance, maintainability, and user experience across the application. * feat(orphaned-features): add orphaned features management routes and UI integration - Introduced new routes for managing orphaned features, including listing, resolving, and bulk resolving. - Updated the UI to include an Orphaned Features section in project settings and navigation. - Enhanced the execution service to support new orphaned feature functionalities. These changes improve the application's capability to handle orphaned features effectively, enhancing user experience and project management. * fix: Normalize line endings and resolve stale dirty states in file editor * chore: Update .gitignore and enhance orphaned feature handling - Added a blank line in .gitignore for better readability. - Introduced a hash to worktree paths in orphaned feature resolution to prevent conflicts. - Added validation for target branch existence during orphaned feature resolution. - Improved prompt formatting in execution service for clarity. - Enhanced error handling in project selector for project initialization failures. - Refactored orphaned features section to improve state management and UI responsiveness. These changes improve code maintainability and user experience when managing orphaned features. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -76,6 +76,7 @@ test/board-bg-test-*/
|
|||||||
test/edit-feature-test-*/
|
test/edit-feature-test-*/
|
||||||
test/open-project-test-*/
|
test/open-project-test-*/
|
||||||
|
|
||||||
|
|
||||||
# Environment files (keep .example)
|
# Environment files (keep .example)
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent
|
|||||||
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
||||||
import { createExportHandler } from './routes/export.js';
|
import { createExportHandler } from './routes/export.js';
|
||||||
import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
|
import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
|
||||||
|
import {
|
||||||
|
createOrphanedListHandler,
|
||||||
|
createOrphanedResolveHandler,
|
||||||
|
createOrphanedBulkResolveHandler,
|
||||||
|
} from './routes/orphaned.js';
|
||||||
|
|
||||||
export function createFeaturesRoutes(
|
export function createFeaturesRoutes(
|
||||||
featureLoader: FeatureLoader,
|
featureLoader: FeatureLoader,
|
||||||
@@ -70,6 +75,21 @@ export function createFeaturesRoutes(
|
|||||||
validatePathParams('projectPath'),
|
validatePathParams('projectPath'),
|
||||||
createConflictCheckHandler(featureLoader)
|
createConflictCheckHandler(featureLoader)
|
||||||
);
|
);
|
||||||
|
router.post(
|
||||||
|
'/orphaned',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createOrphanedListHandler(featureLoader, autoModeService)
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/orphaned/resolve',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createOrphanedResolveHandler(featureLoader, autoModeService)
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/orphaned/bulk-resolve',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createOrphanedBulkResolveHandler(featureLoader)
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function createListHandler(
|
|||||||
// Note: detectOrphanedFeatures handles errors internally and always resolves
|
// Note: detectOrphanedFeatures handles errors internally and always resolves
|
||||||
if (autoModeService) {
|
if (autoModeService) {
|
||||||
autoModeService
|
autoModeService
|
||||||
.detectOrphanedFeatures(projectPath)
|
.detectOrphanedFeatures(projectPath, features)
|
||||||
.then((orphanedFeatures) => {
|
.then((orphanedFeatures) => {
|
||||||
if (orphanedFeatures.length > 0) {
|
if (orphanedFeatures.length > 0) {
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
287
apps/server/src/routes/features/routes/orphaned.ts
Normal file
287
apps/server/src/routes/features/routes/orphaned.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
/**
|
||||||
|
* POST /orphaned endpoint - Detect orphaned features (features with missing branches)
|
||||||
|
* POST /orphaned/resolve endpoint - Resolve an orphaned feature (delete, create-worktree, or move-to-branch)
|
||||||
|
* POST /orphaned/bulk-resolve endpoint - Resolve multiple orphaned features at once
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import path from 'path';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||||
|
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { execGitCommand } from '../../../lib/git.js';
|
||||||
|
import { deleteWorktreeMetadata } from '../../../lib/worktree-metadata.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
|
const logger = createLogger('OrphanedFeatures');
|
||||||
|
|
||||||
|
type ResolveAction = 'delete' | 'create-worktree' | 'move-to-branch';
|
||||||
|
const VALID_ACTIONS: ResolveAction[] = ['delete', 'create-worktree', 'move-to-branch'];
|
||||||
|
|
||||||
|
export function createOrphanedListHandler(
|
||||||
|
featureLoader: FeatureLoader,
|
||||||
|
autoModeService?: AutoModeServiceCompat
|
||||||
|
) {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath } = req.body as { projectPath: string };
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!autoModeService) {
|
||||||
|
res.status(500).json({ success: false, error: 'Auto-mode service not available' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orphanedFeatures = await autoModeService.detectOrphanedFeatures(projectPath);
|
||||||
|
|
||||||
|
res.json({ success: true, orphanedFeatures });
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Detect orphaned features failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOrphanedResolveHandler(
|
||||||
|
featureLoader: FeatureLoader,
|
||||||
|
_autoModeService?: AutoModeServiceCompat
|
||||||
|
) {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, featureId, action, targetBranch } = req.body as {
|
||||||
|
projectPath: string;
|
||||||
|
featureId: string;
|
||||||
|
action: ResolveAction;
|
||||||
|
targetBranch?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!projectPath || !featureId || !action) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'projectPath, featureId, and action are required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_ACTIONS.includes(action)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `action must be one of: ${VALID_ACTIONS.join(', ')}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await resolveOrphanedFeature(
|
||||||
|
featureLoader,
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
action,
|
||||||
|
targetBranch
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
res.status(result.error === 'Feature not found' ? 404 : 500).json(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Resolve orphaned feature failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BulkResolveResult {
|
||||||
|
featureId: string;
|
||||||
|
success: boolean;
|
||||||
|
action?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveOrphanedFeature(
|
||||||
|
featureLoader: FeatureLoader,
|
||||||
|
projectPath: string,
|
||||||
|
featureId: string,
|
||||||
|
action: ResolveAction,
|
||||||
|
targetBranch?: string | null
|
||||||
|
): Promise<BulkResolveResult> {
|
||||||
|
try {
|
||||||
|
const feature = await featureLoader.get(projectPath, featureId);
|
||||||
|
if (!feature) {
|
||||||
|
return { featureId, success: false, error: 'Feature not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingBranch = feature.branchName;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'delete': {
|
||||||
|
if (missingBranch) {
|
||||||
|
try {
|
||||||
|
await deleteWorktreeMetadata(projectPath, missingBranch);
|
||||||
|
} catch {
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const success = await featureLoader.delete(projectPath, featureId);
|
||||||
|
if (!success) {
|
||||||
|
return { featureId, success: false, error: 'Deletion failed' };
|
||||||
|
}
|
||||||
|
logger.info(`Deleted orphaned feature ${featureId} (branch: ${missingBranch})`);
|
||||||
|
return { featureId, success: true, action: 'deleted' };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'create-worktree': {
|
||||||
|
if (!missingBranch) {
|
||||||
|
return { featureId, success: false, error: 'Feature has no branch name to recreate' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedName = missingBranch.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||||
|
const hash = crypto.createHash('sha1').update(missingBranch).digest('hex').slice(0, 8);
|
||||||
|
const worktreesDir = path.join(projectPath, '.worktrees');
|
||||||
|
const worktreePath = path.join(worktreesDir, `${sanitizedName}-${hash}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execGitCommand(['worktree', 'add', '-b', missingBranch, worktreePath], projectPath);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = getErrorMessage(error);
|
||||||
|
if (msg.includes('already exists')) {
|
||||||
|
try {
|
||||||
|
await execGitCommand(['worktree', 'add', worktreePath, missingBranch], projectPath);
|
||||||
|
} catch (innerError) {
|
||||||
|
return {
|
||||||
|
featureId,
|
||||||
|
success: false,
|
||||||
|
error: `Failed to create worktree: ${getErrorMessage(innerError)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return { featureId, success: false, error: `Failed to create worktree: ${msg}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Created worktree for orphaned feature ${featureId} at ${worktreePath} (branch: ${missingBranch})`
|
||||||
|
);
|
||||||
|
return { featureId, success: true, action: 'worktree-created' };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'move-to-branch': {
|
||||||
|
// Move the feature to a different branch (or clear branch to use main worktree)
|
||||||
|
const newBranch = targetBranch || null;
|
||||||
|
|
||||||
|
// Validate that the target branch exists if one is specified
|
||||||
|
if (newBranch) {
|
||||||
|
try {
|
||||||
|
await execGitCommand(['rev-parse', '--verify', newBranch], projectPath);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
featureId,
|
||||||
|
success: false,
|
||||||
|
error: `Target branch "${newBranch}" does not exist`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await featureLoader.update(projectPath, featureId, {
|
||||||
|
branchName: newBranch,
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up old worktree metadata
|
||||||
|
if (missingBranch) {
|
||||||
|
try {
|
||||||
|
await deleteWorktreeMetadata(projectPath, missingBranch);
|
||||||
|
} catch {
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = newBranch ?? 'main worktree';
|
||||||
|
logger.info(
|
||||||
|
`Moved orphaned feature ${featureId} to ${destination} (was: ${missingBranch})`
|
||||||
|
);
|
||||||
|
return { featureId, success: true, action: 'moved' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return { featureId, success: false, error: getErrorMessage(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOrphanedBulkResolveHandler(featureLoader: FeatureLoader) {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, featureIds, action, targetBranch } = req.body as {
|
||||||
|
projectPath: string;
|
||||||
|
featureIds: string[];
|
||||||
|
action: ResolveAction;
|
||||||
|
targetBranch?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
!projectPath ||
|
||||||
|
!featureIds ||
|
||||||
|
!Array.isArray(featureIds) ||
|
||||||
|
featureIds.length === 0 ||
|
||||||
|
!action
|
||||||
|
) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'projectPath, featureIds (non-empty array), and action are required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_ACTIONS.includes(action)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `action must be one of: ${VALID_ACTIONS.join(', ')}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process sequentially for worktree creation (git operations shouldn't race),
|
||||||
|
// in parallel for delete/move-to-branch
|
||||||
|
const results: BulkResolveResult[] = [];
|
||||||
|
|
||||||
|
if (action === 'create-worktree') {
|
||||||
|
for (const featureId of featureIds) {
|
||||||
|
const result = await resolveOrphanedFeature(
|
||||||
|
featureLoader,
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
action,
|
||||||
|
targetBranch
|
||||||
|
);
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const batchResults = await Promise.all(
|
||||||
|
featureIds.map((featureId) =>
|
||||||
|
resolveOrphanedFeature(featureLoader, projectPath, featureId, action, targetBranch)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
results.push(...batchResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
const successCount = results.filter((r) => r.success).length;
|
||||||
|
const failedCount = results.length - successCount;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: failedCount === 0,
|
||||||
|
resolvedCount: successCount,
|
||||||
|
failedCount,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Bulk resolve orphaned features failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -232,9 +232,10 @@ export class AutoModeServiceCompat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async detectOrphanedFeatures(
|
async detectOrphanedFeatures(
|
||||||
projectPath: string
|
projectPath: string,
|
||||||
|
preloadedFeatures?: Feature[]
|
||||||
): Promise<Array<{ feature: Feature; missingBranch: string }>> {
|
): Promise<Array<{ feature: Feature; missingBranch: string }>> {
|
||||||
const facade = this.createFacade(projectPath);
|
const facade = this.createFacade(projectPath);
|
||||||
return facade.detectOrphanedFeatures();
|
return facade.detectOrphanedFeatures(preloadedFeatures);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -463,9 +463,25 @@ export class AutoModeServiceFacade {
|
|||||||
(pPath, featureId, status) =>
|
(pPath, featureId, status) =>
|
||||||
featureStateManager.updateFeatureStatus(pPath, featureId, status),
|
featureStateManager.updateFeatureStatus(pPath, featureId, status),
|
||||||
(pPath, featureId) => featureStateManager.loadFeature(pPath, featureId),
|
(pPath, featureId) => featureStateManager.loadFeature(pPath, featureId),
|
||||||
async (_feature) => {
|
async (feature) => {
|
||||||
// getPlanningPromptPrefixFn - planning prompts handled by AutoModeService
|
// getPlanningPromptPrefixFn - select appropriate planning prompt based on feature's planningMode
|
||||||
return '';
|
if (!feature.planningMode || feature.planningMode === 'skip') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const prompts = await getPromptCustomization(settingsService, '[PlanningPromptPrefix]');
|
||||||
|
const autoModePrompts = prompts.autoMode;
|
||||||
|
switch (feature.planningMode) {
|
||||||
|
case 'lite':
|
||||||
|
return feature.requirePlanApproval
|
||||||
|
? autoModePrompts.planningLiteWithApproval + '\n\n'
|
||||||
|
: autoModePrompts.planningLite + '\n\n';
|
||||||
|
case 'spec':
|
||||||
|
return autoModePrompts.planningSpec + '\n\n';
|
||||||
|
case 'full':
|
||||||
|
return autoModePrompts.planningFull + '\n\n';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
(pPath, featureId, summary) =>
|
(pPath, featureId, summary) =>
|
||||||
featureStateManager.saveFeatureSummary(pPath, featureId, summary),
|
featureStateManager.saveFeatureSummary(pPath, featureId, summary),
|
||||||
@@ -1117,12 +1133,13 @@ export class AutoModeServiceFacade {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect orphaned features (features with missing branches)
|
* Detect orphaned features (features with missing branches)
|
||||||
|
* @param preloadedFeatures - Optional pre-loaded features to avoid redundant disk reads
|
||||||
*/
|
*/
|
||||||
async detectOrphanedFeatures(): Promise<OrphanedFeatureInfo[]> {
|
async detectOrphanedFeatures(preloadedFeatures?: Feature[]): Promise<OrphanedFeatureInfo[]> {
|
||||||
const orphanedFeatures: OrphanedFeatureInfo[] = [];
|
const orphanedFeatures: OrphanedFeatureInfo[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allFeatures = await this.featureLoader.getAll(this.projectPath);
|
const allFeatures = preloadedFeatures ?? (await this.featureLoader.getAll(this.projectPath));
|
||||||
const featuresWithBranches = allFeatures.filter(
|
const featuresWithBranches = allFeatures.filter(
|
||||||
(f) => f.branchName && f.branchName.trim() !== ''
|
(f) => f.branchName && f.branchName.trim() !== ''
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -108,16 +108,14 @@ export class ExecutionService {
|
|||||||
return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...';
|
return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...';
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeaturePrompt(
|
/**
|
||||||
feature: Feature,
|
* Build feature description section (without implementation instructions).
|
||||||
taskExecutionPrompts: {
|
* Used when planning mode is active — the planning prompt provides its own instructions.
|
||||||
implementationInstructions: string;
|
*/
|
||||||
playwrightVerificationInstructions: string;
|
buildFeatureDescription(feature: Feature): string {
|
||||||
}
|
|
||||||
): string {
|
|
||||||
const title = this.extractTitleFromDescription(feature.description);
|
const title = this.extractTitleFromDescription(feature.description);
|
||||||
|
|
||||||
let prompt = `## Feature Implementation Task
|
let prompt = `## Feature Task
|
||||||
|
|
||||||
**Feature ID:** ${feature.id}
|
**Feature ID:** ${feature.id}
|
||||||
**Title:** ${title}
|
**Title:** ${title}
|
||||||
@@ -146,6 +144,18 @@ ${feature.spec}
|
|||||||
prompt += `\n**Context Images Attached:**\n${feature.imagePaths.length} image(s) attached:\n${imagesList}\n`;
|
prompt += `\n**Context Images Attached:**\n${feature.imagePaths.length} image(s) attached:\n${imagesList}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeaturePrompt(
|
||||||
|
feature: Feature,
|
||||||
|
taskExecutionPrompts: {
|
||||||
|
implementationInstructions: string;
|
||||||
|
playwrightVerificationInstructions: string;
|
||||||
|
}
|
||||||
|
): string {
|
||||||
|
let prompt = this.buildFeatureDescription(feature);
|
||||||
|
|
||||||
prompt += feature.skipTests
|
prompt += feature.skipTests
|
||||||
? `\n${taskExecutionPrompts.implementationInstructions}`
|
? `\n${taskExecutionPrompts.implementationInstructions}`
|
||||||
: `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`;
|
: `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`;
|
||||||
@@ -273,9 +283,15 @@ ${feature.spec}
|
|||||||
if (options?.continuationPrompt) {
|
if (options?.continuationPrompt) {
|
||||||
prompt = options.continuationPrompt;
|
prompt = options.continuationPrompt;
|
||||||
} else {
|
} else {
|
||||||
prompt =
|
const planningPrefix = await this.getPlanningPromptPrefixFn(feature);
|
||||||
(await this.getPlanningPromptPrefixFn(feature)) +
|
if (planningPrefix) {
|
||||||
this.buildFeaturePrompt(feature, prompts.taskExecution);
|
// Planning mode active: use planning instructions + feature description only.
|
||||||
|
// Do NOT include implementationInstructions — they conflict with the planning
|
||||||
|
// prompt's "DO NOT proceed with implementation until approval" directive.
|
||||||
|
prompt = planningPrefix + '\n\n' + this.buildFeatureDescription(feature);
|
||||||
|
} else {
|
||||||
|
prompt = this.buildFeaturePrompt(feature, prompts.taskExecution);
|
||||||
|
}
|
||||||
if (feature.planningMode && feature.planningMode !== 'skip') {
|
if (feature.planningMode && feature.planningMode !== 'skip') {
|
||||||
this.eventBus.emitAutoModeEvent('planning_started', {
|
this.eventBus.emitAutoModeEvent('planning_started', {
|
||||||
featureId: feature.id,
|
featureId: feature.id,
|
||||||
|
|||||||
@@ -287,15 +287,17 @@ describe('File editor dirty state logic', () => {
|
|||||||
expect(tab.isDirty).toBe(true);
|
expect(tab.isDirty).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle line ending differences as dirty', () => {
|
it('should treat CRLF and LF line endings as equivalent (not dirty)', () => {
|
||||||
let tab = {
|
let tab = {
|
||||||
content: 'line1\nline2',
|
content: 'line1\nline2',
|
||||||
originalContent: 'line1\nline2',
|
originalContent: 'line1\nline2',
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// CodeMirror normalizes \r\n to \n internally, so content that only
|
||||||
|
// differs by line endings should NOT be considered dirty.
|
||||||
tab = updateTabContent(tab, 'line1\r\nline2');
|
tab = updateTabContent(tab, 'line1\r\nline2');
|
||||||
expect(tab.isDirty).toBe(true);
|
expect(tab.isDirty).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle unicode content correctly', () => {
|
it('should handle unicode content correctly', () => {
|
||||||
|
|||||||
@@ -451,7 +451,7 @@ describe('execution-service.ts', () => {
|
|||||||
const callArgs = mockRunAgentFn.mock.calls[0];
|
const callArgs = mockRunAgentFn.mock.calls[0];
|
||||||
expect(callArgs[0]).toMatch(/test.*project/); // workDir contains project
|
expect(callArgs[0]).toMatch(/test.*project/); // workDir contains project
|
||||||
expect(callArgs[1]).toBe('feature-1');
|
expect(callArgs[1]).toBe('feature-1');
|
||||||
expect(callArgs[2]).toContain('Feature Implementation Task');
|
expect(callArgs[2]).toContain('Feature Task');
|
||||||
expect(callArgs[3]).toBeInstanceOf(AbortController);
|
expect(callArgs[3]).toBeInstanceOf(AbortController);
|
||||||
expect(callArgs[4]).toBe('/test/project');
|
expect(callArgs[4]).toBe('/test/project');
|
||||||
// Model (index 6) should be resolved
|
// Model (index 6) should be resolved
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
|
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
|
||||||
import { initializeProject } from '@/lib/project-init';
|
import { initializeProject } from '@/lib/project-init';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -88,21 +89,21 @@ export function ProjectSelectorWithOptions({
|
|||||||
const clearProjectHistory = useAppStore((s) => s.clearProjectHistory);
|
const clearProjectHistory = useAppStore((s) => s.clearProjectHistory);
|
||||||
|
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
// Wrap setCurrentProject to ensure .automaker is initialized before switching
|
// Wrap setCurrentProject to initialize .automaker in background while switching
|
||||||
const setCurrentProjectWithInit = useCallback(
|
const setCurrentProjectWithInit = useCallback(
|
||||||
async (p: Project) => {
|
(p: Project) => {
|
||||||
if (p.id === currentProject?.id) {
|
if (p.id === currentProject?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
// Fire-and-forget: initialize .automaker directory structure in background
|
||||||
// Ensure .automaker directory structure exists before switching
|
// so the project switch is not blocked by filesystem operations
|
||||||
await initializeProject(p.path);
|
initializeProject(p.path).catch((error) => {
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to initialize project during switch:', error);
|
console.error('Failed to initialize project during switch:', error);
|
||||||
// Continue with switch even if initialization fails -
|
toast.error('Failed to initialize project .automaker', {
|
||||||
// the project may already be initialized
|
description: error instanceof Error ? error.message : String(error),
|
||||||
}
|
});
|
||||||
// Defer project switch update to avoid synchronous render cascades.
|
});
|
||||||
|
// Switch project immediately for instant UI response
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setCurrentProject(p);
|
setCurrentProject(p);
|
||||||
});
|
});
|
||||||
@@ -131,8 +132,8 @@ export function ProjectSelectorWithOptions({
|
|||||||
useProjectTheme();
|
useProjectTheme();
|
||||||
|
|
||||||
const handleSelectProject = useCallback(
|
const handleSelectProject = useCallback(
|
||||||
async (p: Project) => {
|
(p: Project) => {
|
||||||
await setCurrentProjectWithInit(p);
|
setCurrentProjectWithInit(p);
|
||||||
setIsProjectPickerOpen(false);
|
setIsProjectPickerOpen(false);
|
||||||
},
|
},
|
||||||
[setCurrentProjectWithInit, setIsProjectPickerOpen]
|
[setCurrentProjectWithInit, setIsProjectPickerOpen]
|
||||||
|
|||||||
@@ -297,9 +297,9 @@ export function AddFeatureDialog({
|
|||||||
prefilledCategory,
|
prefilledCategory,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Clear requirePlanApproval when planning mode is skip or lite
|
// Clear requirePlanApproval when planning mode is skip (lite supports approval)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (planningMode === 'skip' || planningMode === 'lite') {
|
if (planningMode === 'skip') {
|
||||||
setRequirePlanApproval(false);
|
setRequirePlanApproval(false);
|
||||||
}
|
}
|
||||||
}, [planningMode]);
|
}, [planningMode]);
|
||||||
@@ -634,14 +634,14 @@ export function AddFeatureDialog({
|
|||||||
id="add-feature-require-approval"
|
id="add-feature-require-approval"
|
||||||
checked={requirePlanApproval}
|
checked={requirePlanApproval}
|
||||||
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
||||||
disabled={planningMode === 'skip' || planningMode === 'lite'}
|
disabled={planningMode === 'skip'}
|
||||||
data-testid="add-feature-planning-require-approval-checkbox"
|
data-testid="add-feature-planning-require-approval-checkbox"
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor="add-feature-require-approval"
|
htmlFor="add-feature-require-approval"
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs font-normal',
|
'text-xs font-normal',
|
||||||
planningMode === 'skip' || planningMode === 'lite'
|
planningMode === 'skip'
|
||||||
? 'cursor-not-allowed text-muted-foreground'
|
? 'cursor-not-allowed text-muted-foreground'
|
||||||
: 'cursor-pointer'
|
: 'cursor-pointer'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ import {
|
|||||||
import { getFirstNonEmptySummary } from '@/lib/summary-selection';
|
import { getFirstNonEmptySummary } from '@/lib/summary-selection';
|
||||||
import { useAgentOutput, useFeature } from '@/hooks/queries';
|
import { useAgentOutput, useFeature } from '@/hooks/queries';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { MODAL_CONSTANTS } from '@/components/views/board-view/dialogs/agent-output-modal.constants';
|
||||||
import type { AutoModeEvent } from '@/types/electron';
|
import type { AutoModeEvent } from '@/types/electron';
|
||||||
import type { BacklogPlanEvent } from '@automaker/types';
|
import type { BacklogPlanEvent } from '@automaker/types';
|
||||||
import { MODAL_CONSTANTS } from './agent-output-modal.constants';
|
|
||||||
|
|
||||||
interface AgentOutputModalProps {
|
interface AgentOutputModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -43,7 +43,7 @@ interface AgentOutputModalProps {
|
|||||||
branchName?: string;
|
branchName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes';
|
type ViewMode = (typeof MODAL_CONSTANTS.VIEW_MODES)[keyof typeof MODAL_CONSTANTS.VIEW_MODES];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a single phase entry card with header and content.
|
* Renders a single phase entry card with header and content.
|
||||||
@@ -164,11 +164,11 @@ export function AgentOutputModal({
|
|||||||
const isBacklogPlan = featureId.startsWith('backlog-plan:');
|
const isBacklogPlan = featureId.startsWith('backlog-plan:');
|
||||||
|
|
||||||
// Resolve project path - prefer prop, fallback to window.__currentProject
|
// Resolve project path - prefer prop, fallback to window.__currentProject
|
||||||
const resolvedProjectPath = projectPathProp || window.__currentProject?.path || '';
|
const resolvedProjectPath = projectPathProp || window.__currentProject?.path || undefined;
|
||||||
|
|
||||||
// Track additional content from WebSocket events (appended to query data)
|
// Track view mode state
|
||||||
const [streamedContent, setStreamedContent] = useState<string>('');
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
|
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
|
||||||
|
const [streamedContent, setStreamedContent] = useState<string>('');
|
||||||
|
|
||||||
// Use React Query for initial output loading
|
// Use React Query for initial output loading
|
||||||
const {
|
const {
|
||||||
@@ -221,7 +221,8 @@ export function AgentOutputModal({
|
|||||||
}, [normalizedSummary]);
|
}, [normalizedSummary]);
|
||||||
|
|
||||||
// Determine the effective view mode - default to summary if available, otherwise parsed
|
// Determine the effective view mode - default to summary if available, otherwise parsed
|
||||||
const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed');
|
const effectiveViewMode =
|
||||||
|
viewMode ?? (summary ? MODAL_CONSTANTS.VIEW_MODES.SUMMARY : MODAL_CONSTANTS.VIEW_MODES.PARSED);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const autoScrollRef = useRef(true);
|
const autoScrollRef = useRef(true);
|
||||||
const useWorktrees = useAppStore((state) => state.useWorktrees);
|
const useWorktrees = useAppStore((state) => state.useWorktrees);
|
||||||
@@ -486,7 +487,8 @@ export function AgentOutputModal({
|
|||||||
if (!scrollRef.current) return;
|
if (!scrollRef.current) return;
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
const isAtBottom =
|
||||||
|
scrollHeight - scrollTop - clientHeight < MODAL_CONSTANTS.AUTOSCROLL_THRESHOLD;
|
||||||
autoScrollRef.current = isAtBottom;
|
autoScrollRef.current = isAtBottom;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -511,7 +513,7 @@ export function AgentOutputModal({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="w-full max-h-[85dvh] max-w-[calc(100%-2rem)] sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] rounded-xl flex flex-col"
|
className="w-full max-h-[85dvh] max-w-[calc(100%-2rem)] sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] md:w-[90vw] md:max-w-[1200px] md:max-h-[85vh] rounded-xl flex flex-col"
|
||||||
data-testid="agent-output-modal"
|
data-testid="agent-output-modal"
|
||||||
>
|
>
|
||||||
<DialogHeader className="shrink-0">
|
<DialogHeader className="shrink-0">
|
||||||
@@ -593,7 +595,9 @@ export function AgentOutputModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{effectiveViewMode === 'changes' ? (
|
{effectiveViewMode === 'changes' ? (
|
||||||
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
|
<div
|
||||||
|
className={`flex-1 min-h-0 ${MODAL_CONSTANTS.COMPONENT_HEIGHTS.SMALL_MIN} ${MODAL_CONSTANTS.COMPONENT_HEIGHTS.SMALL_MAX} overflow-y-auto scrollbar-visible`}
|
||||||
|
>
|
||||||
{resolvedProjectPath ? (
|
{resolvedProjectPath ? (
|
||||||
<GitDiffPanel
|
<GitDiffPanel
|
||||||
projectPath={resolvedProjectPath}
|
projectPath={resolvedProjectPath}
|
||||||
@@ -658,7 +662,7 @@ export function AgentOutputModal({
|
|||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto bg-popover border border-border/50 rounded-lg p-4 font-mono text-xs scrollbar-visible"
|
className={`flex-1 min-h-0 ${MODAL_CONSTANTS.COMPONENT_HEIGHTS.SMALL_MIN} ${MODAL_CONSTANTS.COMPONENT_HEIGHTS.SMALL_MAX} overflow-y-auto bg-popover border border-border/50 rounded-lg p-4 font-mono text-xs scrollbar-visible`}
|
||||||
>
|
>
|
||||||
{isLoading && !output ? (
|
{isLoading && !output ? (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
|||||||
@@ -192,9 +192,9 @@ export function EditFeatureDialog({
|
|||||||
}
|
}
|
||||||
}, [feature, allFeatures]);
|
}, [feature, allFeatures]);
|
||||||
|
|
||||||
// Clear requirePlanApproval when planning mode is skip or lite
|
// Clear requirePlanApproval when planning mode is skip (lite supports approval)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (planningMode === 'skip' || planningMode === 'lite') {
|
if (planningMode === 'skip') {
|
||||||
setRequirePlanApproval(false);
|
setRequirePlanApproval(false);
|
||||||
}
|
}
|
||||||
}, [planningMode]);
|
}, [planningMode]);
|
||||||
@@ -485,14 +485,14 @@ export function EditFeatureDialog({
|
|||||||
id="edit-feature-require-approval"
|
id="edit-feature-require-approval"
|
||||||
checked={requirePlanApproval}
|
checked={requirePlanApproval}
|
||||||
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
||||||
disabled={planningMode === 'skip' || planningMode === 'lite'}
|
disabled={planningMode === 'skip'}
|
||||||
data-testid="edit-feature-require-approval-checkbox"
|
data-testid="edit-feature-require-approval-checkbox"
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor="edit-feature-require-approval"
|
htmlFor="edit-feature-require-approval"
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs font-normal',
|
'text-xs font-normal',
|
||||||
planningMode === 'skip' || planningMode === 'lite'
|
planningMode === 'skip'
|
||||||
? 'cursor-not-allowed text-muted-foreground'
|
? 'cursor-not-allowed text-muted-foreground'
|
||||||
: 'cursor-pointer'
|
: 'cursor-pointer'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -199,9 +199,9 @@ export function MassEditDialog({
|
|||||||
}
|
}
|
||||||
}, [open, selectedFeatures]);
|
}, [open, selectedFeatures]);
|
||||||
|
|
||||||
// Clear requirePlanApproval when planning mode is skip or lite
|
// Clear requirePlanApproval when planning mode is skip (lite supports approval)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (planningMode === 'skip' || planningMode === 'lite') {
|
if (planningMode === 'skip') {
|
||||||
setRequirePlanApproval(false);
|
setRequirePlanApproval(false);
|
||||||
}
|
}
|
||||||
}, [planningMode]);
|
}, [planningMode]);
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ export function PlanningModeSelect({
|
|||||||
}: PlanningModeSelectProps) {
|
}: PlanningModeSelectProps) {
|
||||||
const selectedMode = modes.find((m) => m.value === mode);
|
const selectedMode = modes.find((m) => m.value === mode);
|
||||||
|
|
||||||
// Disable approval checkbox for skip/lite modes since they don't use planning
|
// Disable approval checkbox for skip mode (lite supports approval)
|
||||||
const isApprovalDisabled = disabled || mode === 'skip' || mode === 'lite';
|
const isApprovalDisabled = disabled || mode === 'skip';
|
||||||
|
|
||||||
const selectDropdown = (
|
const selectDropdown = (
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -25,9 +25,18 @@ interface EditorTabsProps {
|
|||||||
|
|
||||||
/** Get a file icon color based on extension */
|
/** Get a file icon color based on extension */
|
||||||
function getFileColor(fileName: string): string {
|
function getFileColor(fileName: string): string {
|
||||||
const dotIndex = fileName.lastIndexOf('.');
|
const name = fileName.toLowerCase();
|
||||||
// Files without an extension (no dot, or dotfile with dot at position 0)
|
|
||||||
const ext = dotIndex > 0 ? fileName.slice(dotIndex + 1).toLowerCase() : '';
|
// Handle dotfiles and extensionless files by name first
|
||||||
|
if (name.startsWith('.env')) return 'text-yellow-600';
|
||||||
|
if (name === 'dockerfile' || name.startsWith('dockerfile.')) return 'text-blue-300';
|
||||||
|
if (name === 'makefile' || name === 'gnumakefile') return 'text-orange-300';
|
||||||
|
if (name === '.gitignore' || name === '.dockerignore' || name === '.npmignore')
|
||||||
|
return 'text-gray-400';
|
||||||
|
|
||||||
|
const dotIndex = name.lastIndexOf('.');
|
||||||
|
const ext = dotIndex > 0 ? name.slice(dotIndex + 1) : '';
|
||||||
|
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
case 'ts':
|
case 'ts':
|
||||||
case 'tsx':
|
case 'tsx':
|
||||||
@@ -71,7 +80,9 @@ function getFileColor(fileName: string): string {
|
|||||||
case 'zsh':
|
case 'zsh':
|
||||||
return 'text-green-300';
|
return 'text-green-300';
|
||||||
default:
|
default:
|
||||||
return 'text-muted-foreground';
|
// Very faint dot for unknown file types so it's not confused
|
||||||
|
// with the filled dirty-indicator dot
|
||||||
|
return 'text-muted-foreground/30';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Normalize line endings to `\n` so that comparisons match CodeMirror's
|
||||||
|
* internal representation. CodeMirror always converts `\r\n` and `\r` to
|
||||||
|
* `\n`, so raw disk content with Windows/old-Mac line endings would
|
||||||
|
* otherwise cause a false dirty state.
|
||||||
|
*/
|
||||||
|
export function normalizeLineEndings(text: string): string {
|
||||||
|
return text.indexOf('\r') !== -1 ? text.replace(/\r\n?/g, '\n') : text;
|
||||||
|
}
|
||||||
|
|
||||||
export function computeIsDirty(content: string, originalContent: string): boolean {
|
export function computeIsDirty(content: string, originalContent: string): boolean {
|
||||||
return content !== originalContent;
|
return normalizeLineEndings(content) !== normalizeLineEndings(originalContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateTabWithContent<
|
export function updateTabWithContent<
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
type FileTreeNode,
|
type FileTreeNode,
|
||||||
type EnhancedGitFileStatus,
|
type EnhancedGitFileStatus,
|
||||||
} from './use-file-editor-store';
|
} from './use-file-editor-store';
|
||||||
|
import { normalizeLineEndings } from './file-editor-dirty-utils';
|
||||||
import { FileTree } from './components/file-tree';
|
import { FileTree } from './components/file-tree';
|
||||||
import { CodeEditor, getLanguageName, type CodeEditorHandle } from './components/code-editor';
|
import { CodeEditor, getLanguageName, type CodeEditorHandle } from './components/code-editor';
|
||||||
import { EditorTabs } from './components/editor-tabs';
|
import { EditorTabs } from './components/editor-tabs';
|
||||||
@@ -169,6 +170,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
|||||||
closeAllTabs,
|
closeAllTabs,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
markTabSaved,
|
markTabSaved,
|
||||||
|
refreshTabContent,
|
||||||
setMarkdownViewMode,
|
setMarkdownViewMode,
|
||||||
setMobileBrowserVisible,
|
setMobileBrowserVisible,
|
||||||
activeFileGitDetails,
|
activeFileGitDetails,
|
||||||
@@ -360,6 +362,30 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
|||||||
const existing = tabs.find((t) => t.filePath === filePath);
|
const existing = tabs.find((t) => t.filePath === filePath);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
setActiveTab(existing.id);
|
setActiveTab(existing.id);
|
||||||
|
// If the tab is showing as dirty, re-read from disk to verify that the
|
||||||
|
// stored content actually differs from what is on disk. This fixes stale
|
||||||
|
// isDirty=true state that can be persisted to localStorage (e.g. the file
|
||||||
|
// was saved externally, or the tab schema changed).
|
||||||
|
// We only do this when the tab IS dirty to avoid a race condition where a
|
||||||
|
// concurrent save clears isDirty and then our stale disk read would wrongly
|
||||||
|
// set it back to true.
|
||||||
|
if (!existing.isBinary && !existing.isTooLarge && existing.isDirty) {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.readFile(filePath);
|
||||||
|
if (result.success && result.content !== undefined && !result.content.includes('\0')) {
|
||||||
|
// Re-check isDirty after the async read: a concurrent save may have
|
||||||
|
// already cleared it. Only refresh if the tab is still dirty.
|
||||||
|
const { tabs: currentTabs } = useFileEditorStore.getState();
|
||||||
|
const currentTab = currentTabs.find((t) => t.id === existing.id);
|
||||||
|
if (currentTab?.isDirty) {
|
||||||
|
refreshTabContent(existing.id, result.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical: if we can't re-read the file, keep the persisted state
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,11 +454,15 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize line endings to match CodeMirror's internal representation
|
||||||
|
// (\r\n → \n). This prevents a false dirty state when CodeMirror reports
|
||||||
|
// its already-normalized content back via onChange.
|
||||||
|
const normalizedContent = normalizeLineEndings(result.content);
|
||||||
openTab({
|
openTab({
|
||||||
filePath,
|
filePath,
|
||||||
fileName,
|
fileName,
|
||||||
content: result.content,
|
content: normalizedContent,
|
||||||
originalContent: result.content,
|
originalContent: normalizedContent,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
scrollTop: 0,
|
scrollTop: 0,
|
||||||
cursorLine: 1,
|
cursorLine: 1,
|
||||||
@@ -446,7 +476,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
|||||||
logger.error('Failed to open file:', error);
|
logger.error('Failed to open file:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[tabs, setActiveTab, openTab, maxFileSize]
|
[tabs, setActiveTab, openTab, refreshTabContent, maxFileSize]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Mobile-aware file select ────────────────────────────────
|
// ─── Mobile-aware file select ────────────────────────────────
|
||||||
@@ -703,6 +733,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
|||||||
model: string;
|
model: string;
|
||||||
thinkingLevel: string;
|
thinkingLevel: string;
|
||||||
reasoningEffort: string;
|
reasoningEffort: string;
|
||||||
|
providerId?: string;
|
||||||
skipTests: boolean;
|
skipTests: boolean;
|
||||||
branchName: string;
|
branchName: string;
|
||||||
planningMode: string;
|
planningMode: string;
|
||||||
@@ -1204,6 +1235,37 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
|||||||
};
|
};
|
||||||
}, [effectivePath, loadTree, loadGitStatus]);
|
}, [effectivePath, loadTree, loadGitStatus]);
|
||||||
|
|
||||||
|
// ─── Refresh persisted tabs from disk ──────────────────────
|
||||||
|
// After mount, re-read all persisted (non-binary, non-large) tabs from disk
|
||||||
|
// to sync originalContent with the actual file state. This clears stale
|
||||||
|
// isDirty flags caused by external file changes or serialization artifacts.
|
||||||
|
const hasRefreshedTabsRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!effectivePath || hasRefreshedTabsRef.current) return;
|
||||||
|
const { tabs: currentTabs, refreshTabContent: refresh } = useFileEditorStore.getState();
|
||||||
|
if (currentTabs.length === 0) return;
|
||||||
|
|
||||||
|
hasRefreshedTabsRef.current = true;
|
||||||
|
|
||||||
|
const refreshAll = async () => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
for (const tab of currentTabs) {
|
||||||
|
if (tab.isBinary || tab.isTooLarge) continue;
|
||||||
|
try {
|
||||||
|
const result = await api.readFile(tab.filePath);
|
||||||
|
if (result.success && result.content !== undefined && !result.content.includes('\0')) {
|
||||||
|
refresh(tab.id, result.content);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File may no longer exist — leave tab state as-is
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
refreshAll();
|
||||||
|
}, [effectivePath]);
|
||||||
|
|
||||||
// Open initial path if provided
|
// Open initial path if provided
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialPath) {
|
if (initialPath) {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, type StorageValue } from 'zustand/middleware';
|
import { persist, type StorageValue } from 'zustand/middleware';
|
||||||
import { updateTabWithContent, markTabAsSaved } from './file-editor-dirty-utils';
|
import {
|
||||||
|
updateTabWithContent,
|
||||||
|
markTabAsSaved,
|
||||||
|
normalizeLineEndings,
|
||||||
|
} from './file-editor-dirty-utils';
|
||||||
|
|
||||||
export interface FileTreeNode {
|
export interface FileTreeNode {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -128,6 +132,8 @@ interface FileEditorState {
|
|||||||
markTabSaved: (tabId: string, content: string) => void;
|
markTabSaved: (tabId: string, content: string) => void;
|
||||||
updateTabScroll: (tabId: string, scrollTop: number) => void;
|
updateTabScroll: (tabId: string, scrollTop: number) => void;
|
||||||
updateTabCursor: (tabId: string, line: number, col: number) => void;
|
updateTabCursor: (tabId: string, line: number, col: number) => void;
|
||||||
|
/** Re-sync an existing tab's originalContent and isDirty state from freshly-read disk content */
|
||||||
|
refreshTabContent: (tabId: string, diskContent: string) => void;
|
||||||
|
|
||||||
setMarkdownViewMode: (mode: MarkdownViewMode) => void;
|
setMarkdownViewMode: (mode: MarkdownViewMode) => void;
|
||||||
|
|
||||||
@@ -273,6 +279,24 @@ export const useFileEditorStore = create<FileEditorState>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
refreshTabContent: (tabId, diskContent) => {
|
||||||
|
set({
|
||||||
|
tabs: get().tabs.map((t) => {
|
||||||
|
if (t.id !== tabId) return t;
|
||||||
|
// Normalize line endings so the baseline matches CodeMirror's
|
||||||
|
// internal representation (\r\n → \n). Without this, files with
|
||||||
|
// Windows line endings would always appear dirty.
|
||||||
|
const normalizedDisk = normalizeLineEndings(diskContent);
|
||||||
|
// If the editor content matches the freshly-read disk content, the file
|
||||||
|
// is clean (any previous isDirty was a stale persisted value).
|
||||||
|
// Otherwise keep the user's in-progress edits but update originalContent
|
||||||
|
// so isDirty is calculated against the actual on-disk baseline.
|
||||||
|
const isDirty = normalizeLineEndings(t.content) !== normalizedDisk;
|
||||||
|
return { ...t, originalContent: normalizedDisk, isDirty };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
updateTabScroll: (tabId, scrollTop) => {
|
updateTabScroll: (tabId, scrollTop) => {
|
||||||
set({
|
set({
|
||||||
tabs: get().tabs.map((t) => (t.id === tabId ? { ...t, scrollTop } : t)),
|
tabs: get().tabs.map((t) => (t.id === tabId ? { ...t, scrollTop } : t)),
|
||||||
@@ -321,7 +345,7 @@ export const useFileEditorStore = create<FileEditorState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: STORE_NAME,
|
name: STORE_NAME,
|
||||||
version: 1,
|
version: 2,
|
||||||
// Only persist tab session state, not transient data (git status, file tree, drag state)
|
// Only persist tab session state, not transient data (git status, file tree, drag state)
|
||||||
partialize: (state) =>
|
partialize: (state) =>
|
||||||
({
|
({
|
||||||
@@ -338,11 +362,30 @@ export const useFileEditorStore = create<FileEditorState>()(
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as StorageValue<PersistedFileEditorState>;
|
const parsed = JSON.parse(raw) as StorageValue<PersistedFileEditorState>;
|
||||||
if (!parsed?.state) return null;
|
if (!parsed?.state) return null;
|
||||||
|
// Normalize tabs: ensure originalContent is always a string. Tabs persisted
|
||||||
|
// before originalContent was added to the schema have originalContent=undefined,
|
||||||
|
// which causes isDirty=true on any content comparison. Default to content so
|
||||||
|
// the tab starts in a clean state.
|
||||||
|
// Also recalculate isDirty from content vs originalContent rather than trusting
|
||||||
|
// the persisted value, which can become stale (e.g. file saved externally,
|
||||||
|
// CodeMirror normalization, or schema migration).
|
||||||
|
const normalizedTabs = (parsed.state.tabs ?? []).map((tab) => {
|
||||||
|
const originalContent = normalizeLineEndings(
|
||||||
|
tab.originalContent ?? tab.content ?? ''
|
||||||
|
);
|
||||||
|
const content = tab.content ?? '';
|
||||||
|
return {
|
||||||
|
...tab,
|
||||||
|
originalContent,
|
||||||
|
isDirty: normalizeLineEndings(content) !== originalContent,
|
||||||
|
};
|
||||||
|
});
|
||||||
// Convert arrays back to Sets
|
// Convert arrays back to Sets
|
||||||
return {
|
return {
|
||||||
...parsed,
|
...parsed,
|
||||||
state: {
|
state: {
|
||||||
...parsed.state,
|
...parsed.state,
|
||||||
|
tabs: normalizedTabs,
|
||||||
expandedFolders: new Set(parsed.state.expandedFolders ?? []),
|
expandedFolders: new Set(parsed.state.expandedFolders ?? []),
|
||||||
},
|
},
|
||||||
} as unknown as StorageValue<FileEditorState>;
|
} as unknown as StorageValue<FileEditorState>;
|
||||||
@@ -385,6 +428,22 @@ export const useFileEditorStore = create<FileEditorState>()(
|
|||||||
state.expandedFolders = state.expandedFolders ?? new Set<string>();
|
state.expandedFolders = state.expandedFolders ?? new Set<string>();
|
||||||
state.markdownViewMode = state.markdownViewMode ?? 'split';
|
state.markdownViewMode = state.markdownViewMode ?? 'split';
|
||||||
}
|
}
|
||||||
|
// Always ensure each tab has a valid originalContent field.
|
||||||
|
// Tabs persisted before originalContent was added to the schema would have
|
||||||
|
// originalContent=undefined, which causes isDirty=true on any onChange call
|
||||||
|
// (content !== undefined is always true). Fix by defaulting to content so the
|
||||||
|
// tab starts in a clean state; any genuine unsaved changes will be re-detected
|
||||||
|
// when the user next edits the file.
|
||||||
|
if (Array.isArray((state as Record<string, unknown>).tabs)) {
|
||||||
|
(state as Record<string, unknown>).tabs = (
|
||||||
|
(state as Record<string, unknown>).tabs as Array<Record<string, unknown>>
|
||||||
|
).map((tab: Record<string, unknown>) => {
|
||||||
|
if (tab.originalContent === undefined || tab.originalContent === null) {
|
||||||
|
return { ...tab, originalContent: tab.content ?? '' };
|
||||||
|
}
|
||||||
|
return tab;
|
||||||
|
});
|
||||||
|
}
|
||||||
return state as unknown as FileEditorState;
|
return state as unknown as FileEditorState;
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Workflow,
|
Workflow,
|
||||||
Database,
|
Database,
|
||||||
Terminal,
|
Terminal,
|
||||||
|
Unlink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||||
|
|
||||||
@@ -23,5 +24,6 @@ export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
|||||||
{ 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 },
|
||||||
|
{ id: 'orphaned', label: 'Orphaned Features', icon: Unlink },
|
||||||
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
|
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type ProjectSettingsViewId =
|
|||||||
| 'commands-scripts'
|
| 'commands-scripts'
|
||||||
| 'claude'
|
| 'claude'
|
||||||
| 'data'
|
| 'data'
|
||||||
|
| 'orphaned'
|
||||||
| 'danger';
|
| 'danger';
|
||||||
|
|
||||||
interface UseProjectSettingsViewOptions {
|
interface UseProjectSettingsViewOptions {
|
||||||
|
|||||||
@@ -0,0 +1,658 @@
|
|||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Unlink,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
GitBranch,
|
||||||
|
ArrowRight,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckSquare,
|
||||||
|
MinusSquare,
|
||||||
|
Square,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { Project } from '@/lib/electron';
|
||||||
|
import type { Feature } from '@automaker/types';
|
||||||
|
|
||||||
|
interface OrphanedFeatureInfo {
|
||||||
|
feature: Feature;
|
||||||
|
missingBranch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorktreeInfo {
|
||||||
|
path: string;
|
||||||
|
branch: string;
|
||||||
|
isMain: boolean;
|
||||||
|
isCurrent: boolean;
|
||||||
|
hasWorktree: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrphanedFeaturesSectionProps {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrphanedFeaturesSection({ project }: OrphanedFeaturesSectionProps) {
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [scanned, setScanned] = useState(false);
|
||||||
|
const [orphanedFeatures, setOrphanedFeatures] = useState<OrphanedFeatureInfo[]>([]);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [resolvingIds, setResolvingIds] = useState<Set<string>>(new Set());
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||||
|
featureIds: string[];
|
||||||
|
labels: string[];
|
||||||
|
} | null>(null);
|
||||||
|
const [moveDialog, setMoveDialog] = useState<{
|
||||||
|
featureIds: string[];
|
||||||
|
labels: string[];
|
||||||
|
} | null>(null);
|
||||||
|
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
|
||||||
|
const [selectedBranch, setSelectedBranch] = useState<string>('__main__');
|
||||||
|
const [loadingWorktrees, setLoadingWorktrees] = useState(false);
|
||||||
|
|
||||||
|
const allSelected = orphanedFeatures.length > 0 && selectedIds.size === orphanedFeatures.length;
|
||||||
|
const someSelected = selectedIds.size > 0 && selectedIds.size < orphanedFeatures.length;
|
||||||
|
const hasSelection = selectedIds.size > 0;
|
||||||
|
|
||||||
|
const selectedLabels = useMemo(() => {
|
||||||
|
return orphanedFeatures
|
||||||
|
.filter((o) => selectedIds.has(o.feature.id))
|
||||||
|
.map((o) => o.feature.title || o.feature.description?.slice(0, 60) || o.feature.id);
|
||||||
|
}, [orphanedFeatures, selectedIds]);
|
||||||
|
|
||||||
|
const toggleSelect = useCallback((id: string) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSelectAll = useCallback(() => {
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedIds(new Set(orphanedFeatures.map((o) => o.feature.id)));
|
||||||
|
}
|
||||||
|
}, [allSelected, orphanedFeatures]);
|
||||||
|
|
||||||
|
const scanForOrphans = useCallback(async () => {
|
||||||
|
setScanning(true);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const result = await api.features.getOrphaned(project.path);
|
||||||
|
if (result.success && result.orphanedFeatures) {
|
||||||
|
setOrphanedFeatures(result.orphanedFeatures);
|
||||||
|
setScanned(true);
|
||||||
|
if (result.orphanedFeatures.length === 0) {
|
||||||
|
toast.success('No orphaned features found');
|
||||||
|
} else {
|
||||||
|
toast.info(`Found ${result.orphanedFeatures.length} orphaned feature(s)`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to scan for orphaned features', {
|
||||||
|
description: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to scan for orphaned features', {
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
}, [project.path]);
|
||||||
|
|
||||||
|
const loadWorktrees = useCallback(async () => {
|
||||||
|
setLoadingWorktrees(true);
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const result = await api.worktree.listAll(project.path);
|
||||||
|
if (result.success && result.worktrees) {
|
||||||
|
setWorktrees(result.worktrees);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal
|
||||||
|
} finally {
|
||||||
|
setLoadingWorktrees(false);
|
||||||
|
}
|
||||||
|
}, [project.path]);
|
||||||
|
|
||||||
|
const resolveOrphan = useCallback(
|
||||||
|
async (
|
||||||
|
featureId: string,
|
||||||
|
action: 'delete' | 'create-worktree' | 'move-to-branch',
|
||||||
|
targetBranch?: string | null
|
||||||
|
) => {
|
||||||
|
setResolvingIds((prev) => new Set(prev).add(featureId));
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const result = await api.features.resolveOrphaned(
|
||||||
|
project.path,
|
||||||
|
featureId,
|
||||||
|
action,
|
||||||
|
targetBranch
|
||||||
|
);
|
||||||
|
if (result.success) {
|
||||||
|
setOrphanedFeatures((prev) => prev.filter((o) => o.feature.id !== featureId));
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(featureId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
deleted: 'Feature deleted',
|
||||||
|
'worktree-created': 'Worktree created successfully',
|
||||||
|
moved: 'Feature moved successfully',
|
||||||
|
};
|
||||||
|
toast.success(messages[result.action ?? action] ?? 'Resolved');
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to resolve orphaned feature', {
|
||||||
|
description: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to resolve orphaned feature', {
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setResolvingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(featureId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[project.path]
|
||||||
|
);
|
||||||
|
|
||||||
|
const bulkResolve = useCallback(
|
||||||
|
async (
|
||||||
|
featureIds: string[],
|
||||||
|
action: 'delete' | 'create-worktree' | 'move-to-branch',
|
||||||
|
targetBranch?: string | null
|
||||||
|
) => {
|
||||||
|
const ids = new Set(featureIds);
|
||||||
|
setResolvingIds((prev) => new Set([...prev, ...ids]));
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const result = await api.features.bulkResolveOrphaned(
|
||||||
|
project.path,
|
||||||
|
featureIds,
|
||||||
|
action,
|
||||||
|
targetBranch
|
||||||
|
);
|
||||||
|
if (result.success || (result.resolvedCount && result.resolvedCount > 0)) {
|
||||||
|
const resolvedIds = new Set(
|
||||||
|
result.results?.filter((r) => r.success).map((r) => r.featureId) ?? featureIds
|
||||||
|
);
|
||||||
|
setOrphanedFeatures((prev) => prev.filter((o) => !resolvedIds.has(o.feature.id)));
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const id of resolvedIds) {
|
||||||
|
next.delete(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionLabel =
|
||||||
|
action === 'delete'
|
||||||
|
? 'deleted'
|
||||||
|
: action === 'create-worktree'
|
||||||
|
? 'moved to worktrees'
|
||||||
|
: 'moved';
|
||||||
|
if (result.failedCount && result.failedCount > 0) {
|
||||||
|
toast.warning(
|
||||||
|
`${result.resolvedCount} feature(s) ${actionLabel}, ${result.failedCount} failed`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.success(`${result.resolvedCount} feature(s) ${actionLabel}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to resolve orphaned features', {
|
||||||
|
description: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to resolve orphaned features', {
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setResolvingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const id of featureIds) {
|
||||||
|
next.delete(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
setMoveDialog(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[project.path]
|
||||||
|
);
|
||||||
|
|
||||||
|
const openMoveDialog = useCallback(
|
||||||
|
async (featureIds: string[], labels: string[]) => {
|
||||||
|
setMoveDialog({ featureIds, labels });
|
||||||
|
setSelectedBranch('__main__');
|
||||||
|
await loadWorktrees();
|
||||||
|
},
|
||||||
|
[loadWorktrees]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMoveConfirm = useCallback(() => {
|
||||||
|
if (!moveDialog) return;
|
||||||
|
const targetBranch = selectedBranch === '__main__' ? null : selectedBranch;
|
||||||
|
if (moveDialog.featureIds.length === 1) {
|
||||||
|
resolveOrphan(moveDialog.featureIds[0], 'move-to-branch', targetBranch);
|
||||||
|
} else {
|
||||||
|
bulkResolve(moveDialog.featureIds, 'move-to-branch', targetBranch);
|
||||||
|
}
|
||||||
|
setMoveDialog(null);
|
||||||
|
}, [moveDialog, selectedBranch, resolveOrphan, bulkResolve]);
|
||||||
|
|
||||||
|
const isBulkResolving = resolvingIds.size > 0;
|
||||||
|
|
||||||
|
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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<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-amber-500/20 to-amber-600/10 flex items-center justify-center border border-amber-500/20">
|
||||||
|
<Unlink className="w-5 h-5 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||||
|
Orphaned Features
|
||||||
|
</h2>
|
||||||
|
{scanned && orphanedFeatures.length > 0 && (
|
||||||
|
<span className="ml-auto inline-flex items-center rounded-full bg-amber-500/15 px-2.5 py-0.5 text-xs font-medium text-amber-500 border border-amber-500/25">
|
||||||
|
{orphanedFeatures.length} found
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
|
Detect features whose git branches no longer exist. You can delete them, create a new
|
||||||
|
worktree, or move them to an existing branch.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Scan Button */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Scan for Orphaned Features</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Check all features for missing git branches.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={scanForOrphans}
|
||||||
|
loading={scanning}
|
||||||
|
className="gap-2"
|
||||||
|
data-testid="scan-orphaned-features-button"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
{scanning ? 'Scanning...' : scanned ? 'Rescan' : 'Scan for Orphans'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{scanned && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-border/50" />
|
||||||
|
|
||||||
|
{orphanedFeatures.length === 0 ? (
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-emerald-500/10 flex items-center justify-center">
|
||||||
|
<GitBranch className="w-6 h-6 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-foreground">All clear</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
No orphaned features detected.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Selection toolbar */}
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={toggleSelectAll}
|
||||||
|
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
data-testid="select-all-orphans"
|
||||||
|
>
|
||||||
|
{allSelected ? (
|
||||||
|
<CheckSquare className="w-4 h-4 text-brand-500" />
|
||||||
|
) : someSelected ? (
|
||||||
|
<MinusSquare className="w-4 h-4 text-brand-500" />
|
||||||
|
) : (
|
||||||
|
<Square className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{allSelected ? 'Deselect all' : `Select all (${orphanedFeatures.length})`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{hasSelection && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{selectedIds.size} selected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk actions */}
|
||||||
|
{hasSelection && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const ids = Array.from(selectedIds);
|
||||||
|
bulkResolve(ids, 'create-worktree');
|
||||||
|
}}
|
||||||
|
disabled={isBulkResolving}
|
||||||
|
className="gap-1.5 text-xs"
|
||||||
|
data-testid="bulk-create-worktree"
|
||||||
|
>
|
||||||
|
<GitBranch className="w-3.5 h-3.5" />
|
||||||
|
Create Worktrees ({selectedIds.size})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openMoveDialog(Array.from(selectedIds), selectedLabels)}
|
||||||
|
disabled={isBulkResolving}
|
||||||
|
className="gap-1.5 text-xs"
|
||||||
|
data-testid="bulk-move-to-branch"
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
Move ({selectedIds.size})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setDeleteConfirm({
|
||||||
|
featureIds: Array.from(selectedIds),
|
||||||
|
labels: selectedLabels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={isBulkResolving}
|
||||||
|
className="gap-1.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10 hover:border-destructive/30"
|
||||||
|
data-testid="bulk-delete-orphans"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
Delete ({selectedIds.size})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature list */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{orphanedFeatures.map(({ feature, missingBranch }) => {
|
||||||
|
const isResolving = resolvingIds.has(feature.id);
|
||||||
|
const isSelected = selectedIds.has(feature.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={feature.id}
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border p-4',
|
||||||
|
'bg-gradient-to-r from-card/60 to-card/40',
|
||||||
|
'transition-all duration-200',
|
||||||
|
isResolving && 'opacity-60',
|
||||||
|
isSelected ? 'border-brand-500/40 bg-brand-500/5' : 'border-border/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Checkbox */}
|
||||||
|
<div className="pt-0.5">
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => toggleSelect(feature.id)}
|
||||||
|
disabled={isResolving}
|
||||||
|
data-testid={`select-orphan-${feature.id}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">
|
||||||
|
{feature.title || feature.description?.slice(0, 80) || feature.id}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 flex items-center gap-1.5">
|
||||||
|
<AlertTriangle className="w-3 h-3 text-amber-500 shrink-0" />
|
||||||
|
Missing branch:{' '}
|
||||||
|
<code className="px-1.5 py-0.5 rounded bg-muted/50 font-mono text-[11px]">
|
||||||
|
{missingBranch}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-item actions */}
|
||||||
|
<div className="flex items-center gap-2 mt-3 ml-7 flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => resolveOrphan(feature.id, 'create-worktree')}
|
||||||
|
disabled={isResolving}
|
||||||
|
loading={isResolving}
|
||||||
|
className="gap-1.5 text-xs"
|
||||||
|
data-testid={`create-worktree-${feature.id}`}
|
||||||
|
>
|
||||||
|
<GitBranch className="w-3.5 h-3.5" />
|
||||||
|
Create Worktree
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
openMoveDialog(
|
||||||
|
[feature.id],
|
||||||
|
[feature.title || feature.description?.slice(0, 60) || feature.id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={isResolving}
|
||||||
|
className="gap-1.5 text-xs"
|
||||||
|
data-testid={`move-orphan-${feature.id}`}
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
Move to Branch
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setDeleteConfirm({
|
||||||
|
featureIds: [feature.id],
|
||||||
|
labels: [
|
||||||
|
feature.title ||
|
||||||
|
feature.description?.slice(0, 60) ||
|
||||||
|
feature.id,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={isResolving}
|
||||||
|
className="gap-1.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10 hover:border-destructive/30"
|
||||||
|
data-testid={`delete-orphan-${feature.id}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
Delete{' '}
|
||||||
|
{deleteConfirm && deleteConfirm.featureIds.length > 1
|
||||||
|
? `${deleteConfirm.featureIds.length} Orphaned Features`
|
||||||
|
: 'Orphaned Feature'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{deleteConfirm && deleteConfirm.featureIds.length > 1 ? (
|
||||||
|
<>
|
||||||
|
Are you sure you want to permanently delete these{' '}
|
||||||
|
{deleteConfirm.featureIds.length} features?
|
||||||
|
<span className="block mt-2 max-h-32 overflow-y-auto space-y-1">
|
||||||
|
{deleteConfirm.labels.map((label, i) => (
|
||||||
|
<span key={i} className="block text-sm font-medium text-foreground">
|
||||||
|
• {label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Are you sure you want to permanently delete this feature?
|
||||||
|
<span className="block mt-2 font-medium text-foreground">
|
||||||
|
"{deleteConfirm?.labels[0]}"
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="block mt-2 text-destructive font-medium">
|
||||||
|
This action cannot be undone.
|
||||||
|
</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setDeleteConfirm(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
loading={isBulkResolving}
|
||||||
|
onClick={() => {
|
||||||
|
if (deleteConfirm) {
|
||||||
|
if (deleteConfirm.featureIds.length === 1) {
|
||||||
|
resolveOrphan(deleteConfirm.featureIds[0], 'delete');
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
} else {
|
||||||
|
bulkResolve(deleteConfirm.featureIds, 'delete');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
{deleteConfirm && deleteConfirm.featureIds.length > 1
|
||||||
|
? ` (${deleteConfirm.featureIds.length})`
|
||||||
|
: ''}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Move to Branch Dialog */}
|
||||||
|
<Dialog open={!!moveDialog} onOpenChange={(open) => !open && setMoveDialog(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<ArrowRight className="w-5 h-5 text-brand-500" />
|
||||||
|
Move to Branch
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{moveDialog && moveDialog.featureIds.length > 1 ? (
|
||||||
|
<>
|
||||||
|
Select where to move {moveDialog.featureIds.length} features. The branch reference
|
||||||
|
will be updated and the features will be set to pending.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Select where to move this feature. The branch reference will be updated and the
|
||||||
|
feature will be set to pending.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
|
<label className="text-sm font-medium text-foreground mb-2 block">Target Branch</label>
|
||||||
|
<Select
|
||||||
|
value={selectedBranch}
|
||||||
|
onValueChange={setSelectedBranch}
|
||||||
|
disabled={loadingWorktrees}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full" data-testid="move-target-branch-select">
|
||||||
|
<SelectValue placeholder="Select a branch..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__main__">Main worktree (clear branch reference)</SelectItem>
|
||||||
|
{worktrees
|
||||||
|
.filter((w) => !w.isMain && w.branch)
|
||||||
|
.map((w) => (
|
||||||
|
<SelectItem key={w.branch} value={w.branch}>
|
||||||
|
{w.branch}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
{selectedBranch === '__main__'
|
||||||
|
? 'The branch reference will be cleared and the feature will use the main worktree.'
|
||||||
|
: `The feature will be associated with the "${selectedBranch}" branch.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setMoveDialog(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button loading={isBulkResolving} onClick={handleMoveConfirm}>
|
||||||
|
<ArrowRight className="w-4 h-4 mr-2" />
|
||||||
|
Move
|
||||||
|
{moveDialog && moveDialog.featureIds.length > 1
|
||||||
|
? ` (${moveDialog.featureIds.length})`
|
||||||
|
: ''}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { WorktreePreferencesSection } from './worktree-preferences-section';
|
|||||||
import { CommandsAndScriptsSection } from './commands-and-scripts-section';
|
import { CommandsAndScriptsSection } from './commands-and-scripts-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 { OrphanedFeaturesSection } from './orphaned-features-section';
|
||||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||||
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
|
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
|
||||||
import { RemoveFromAutomakerDialog } from '../settings-view/components/remove-from-automaker-dialog';
|
import { RemoveFromAutomakerDialog } from '../settings-view/components/remove-from-automaker-dialog';
|
||||||
@@ -109,6 +110,8 @@ export function ProjectSettingsView() {
|
|||||||
return <ProjectModelsSection project={currentProject} />;
|
return <ProjectModelsSection project={currentProject} />;
|
||||||
case 'data':
|
case 'data':
|
||||||
return <DataManagementSection project={currentProject} />;
|
return <DataManagementSection project={currentProject} />;
|
||||||
|
case 'orphaned':
|
||||||
|
return <OrphanedFeaturesSection project={currentProject} />;
|
||||||
case 'danger':
|
case 'danger':
|
||||||
return (
|
return (
|
||||||
<DangerZoneSection
|
<DangerZoneSection
|
||||||
|
|||||||
@@ -288,12 +288,51 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionData = readAutoModeSession();
|
const sessionData = readAutoModeSession();
|
||||||
const currentBranchName = branchNameRef.current;
|
const projectPath = currentProject.path;
|
||||||
const currentKey = getWorktreeSessionKey(currentProject.path, currentBranchName);
|
|
||||||
|
|
||||||
if (sessionData[currentKey] === true) {
|
// Track restored worktrees to avoid redundant state updates
|
||||||
setAutoModeRunning(currentProject.id, currentBranchName, true);
|
const restoredKeys = new Set<string>();
|
||||||
logger.debug(`Restored auto mode state from session storage for key: ${currentKey}`);
|
|
||||||
|
// Find all session storage keys that match this project
|
||||||
|
Object.entries(sessionData).forEach(([sessionKey, isRunning]) => {
|
||||||
|
if (!isRunning) return;
|
||||||
|
|
||||||
|
// Parse the session key: "projectPath::branchName" or "projectPath::__main__"
|
||||||
|
// Use lastIndexOf to split from the right, since projectPath may contain the delimiter
|
||||||
|
const delimiterIndex = sessionKey.lastIndexOf(SESSION_KEY_DELIMITER);
|
||||||
|
if (delimiterIndex === -1) {
|
||||||
|
// Malformed session key - skip it
|
||||||
|
logger.warn(`Malformed session storage key: ${sessionKey}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyProjectPath = sessionKey.slice(0, delimiterIndex);
|
||||||
|
const keyBranchName = sessionKey.slice(delimiterIndex + SESSION_KEY_DELIMITER.length);
|
||||||
|
if (keyProjectPath !== projectPath) return;
|
||||||
|
|
||||||
|
// Validate branch name: __main__ means null (main worktree)
|
||||||
|
if (keyBranchName !== MAIN_WORKTREE_MARKER && !keyBranchName) {
|
||||||
|
logger.warn(`Invalid branch name in session key: ${sessionKey}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const branchName = keyBranchName === MAIN_WORKTREE_MARKER ? null : keyBranchName;
|
||||||
|
|
||||||
|
// Skip if we've already restored this worktree (prevents duplicates)
|
||||||
|
const worktreeKey = getWorktreeSessionKey(projectPath, branchName);
|
||||||
|
if (restoredKeys.has(worktreeKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoredKeys.add(worktreeKey);
|
||||||
|
|
||||||
|
// Restore the auto mode running state in the store
|
||||||
|
setAutoModeRunning(currentProject.id, branchName, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (restoredKeys.size > 0) {
|
||||||
|
logger.debug(
|
||||||
|
`Restored auto mode state for ${restoredKeys.size} worktree(s) from session storage`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error restoring auto mode state from session storage:', error);
|
logger.error('Error restoring auto mode state from session storage:', error);
|
||||||
|
|||||||
@@ -521,6 +521,35 @@ export interface FeaturesAPI {
|
|||||||
description: string,
|
description: string,
|
||||||
projectPath?: string
|
projectPath?: string
|
||||||
) => Promise<{ success: boolean; title?: string; error?: string }>;
|
) => Promise<{ success: boolean; title?: string; error?: string }>;
|
||||||
|
getOrphaned: (projectPath: string) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
orphanedFeatures?: Array<{ feature: Feature; missingBranch: string }>;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
resolveOrphaned: (
|
||||||
|
projectPath: string,
|
||||||
|
featureId: string,
|
||||||
|
action: 'delete' | 'create-worktree' | 'move-to-branch',
|
||||||
|
targetBranch?: string | null
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
action?: string;
|
||||||
|
worktreePath?: string;
|
||||||
|
branchName?: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
bulkResolveOrphaned: (
|
||||||
|
projectPath: string,
|
||||||
|
featureIds: string[],
|
||||||
|
action: 'delete' | 'create-worktree' | 'move-to-branch',
|
||||||
|
targetBranch?: string | null
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
resolvedCount?: number;
|
||||||
|
failedCount?: number;
|
||||||
|
results?: Array<{ featureId: string; success: boolean; action?: string; error?: string }>;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutoModeAPI {
|
export interface AutoModeAPI {
|
||||||
@@ -3939,6 +3968,25 @@ function createMockFeaturesAPI(): FeaturesAPI {
|
|||||||
const title = words.length > 40 ? words.substring(0, 40) + '...' : words;
|
const title = words.length > 40 ? words.substring(0, 40) + '...' : words;
|
||||||
return { success: true, title: `Add ${title}` };
|
return { success: true, title: `Add ${title}` };
|
||||||
},
|
},
|
||||||
|
getOrphaned: async (_projectPath: string) => {
|
||||||
|
return { success: true, orphanedFeatures: [] };
|
||||||
|
},
|
||||||
|
resolveOrphaned: async (
|
||||||
|
_projectPath: string,
|
||||||
|
_featureId: string,
|
||||||
|
_action: 'delete' | 'create-worktree' | 'move-to-branch',
|
||||||
|
_targetBranch?: string | null
|
||||||
|
) => {
|
||||||
|
return { success: false, error: 'Not supported in mock mode' };
|
||||||
|
},
|
||||||
|
bulkResolveOrphaned: async (
|
||||||
|
_projectPath: string,
|
||||||
|
_featureIds: string[],
|
||||||
|
_action: 'delete' | 'create-worktree' | 'move-to-branch',
|
||||||
|
_targetBranch?: string | null
|
||||||
|
) => {
|
||||||
|
return { success: false, error: 'Not supported in mock mode' };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,12 +129,41 @@ export const isConnectionError = (error: unknown): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a server offline error by notifying the UI to redirect.
|
* Handle a server offline error by verifying the server is actually down
|
||||||
* Call this when a connection error is detected.
|
* before redirecting to login. Uses debouncing to coalesce rapid errors
|
||||||
|
* and a health check to confirm the server isn't just experiencing a
|
||||||
|
* transient network blip.
|
||||||
*/
|
*/
|
||||||
|
let serverOfflineCheckPending = false;
|
||||||
|
|
||||||
export const handleServerOffline = (): void => {
|
export const handleServerOffline = (): void => {
|
||||||
logger.error('Server appears to be offline, redirecting to login...');
|
// Debounce: if a check is already in progress, skip
|
||||||
notifyServerOffline();
|
if (serverOfflineCheckPending) return;
|
||||||
|
serverOfflineCheckPending = true;
|
||||||
|
|
||||||
|
// Wait briefly to let transient errors settle, then verify with a health check
|
||||||
|
setTimeout(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getServerUrl()}/api/health`, {
|
||||||
|
method: 'GET',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
logger.info('Server health check passed, ignoring transient connection error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Health check failed - server is genuinely offline
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Server appears to be offline, redirecting to login...');
|
||||||
|
notifyServerOffline();
|
||||||
|
})().finally(() => {
|
||||||
|
serverOfflineCheckPending = false;
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2080,6 +2109,44 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
conflictCount?: number;
|
conflictCount?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.post('/api/features/check-conflicts', { projectPath, data }),
|
}> => this.post('/api/features/check-conflicts', { projectPath, data }),
|
||||||
|
getOrphaned: (
|
||||||
|
projectPath: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
orphanedFeatures?: Array<{ feature: Feature; missingBranch: string }>;
|
||||||
|
error?: string;
|
||||||
|
}> => this.post('/api/features/orphaned', { projectPath }),
|
||||||
|
resolveOrphaned: (
|
||||||
|
projectPath: string,
|
||||||
|
featureId: string,
|
||||||
|
action: 'delete' | 'create-worktree' | 'move-to-branch',
|
||||||
|
targetBranch?: string | null
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
action?: string;
|
||||||
|
worktreePath?: string;
|
||||||
|
branchName?: string;
|
||||||
|
error?: string;
|
||||||
|
}> =>
|
||||||
|
this.post('/api/features/orphaned/resolve', { projectPath, featureId, action, targetBranch }),
|
||||||
|
bulkResolveOrphaned: (
|
||||||
|
projectPath: string,
|
||||||
|
featureIds: string[],
|
||||||
|
action: 'delete' | 'create-worktree' | 'move-to-branch',
|
||||||
|
targetBranch?: string | null
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
resolvedCount?: number;
|
||||||
|
failedCount?: number;
|
||||||
|
results?: Array<{ featureId: string; success: boolean; action?: string; error?: string }>;
|
||||||
|
error?: string;
|
||||||
|
}> =>
|
||||||
|
this.post('/api/features/orphaned/bulk-resolve', {
|
||||||
|
projectPath,
|
||||||
|
featureIds,
|
||||||
|
action,
|
||||||
|
targetBranch,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto Mode API
|
// Auto Mode API
|
||||||
|
|||||||
@@ -97,24 +97,25 @@ export async function initializeProject(projectPath: string): Promise<ProjectIni
|
|||||||
existingFiles.push('.git');
|
existingFiles.push('.git');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create all required directories
|
// Create all required directories in parallel
|
||||||
for (const dir of REQUIRED_STRUCTURE.directories) {
|
await Promise.all(
|
||||||
const fullPath = `${projectPath}/${dir}`;
|
REQUIRED_STRUCTURE.directories.map((dir) => api.mkdir(`${projectPath}/${dir}`))
|
||||||
await api.mkdir(fullPath);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// Check and create required files
|
// Check and create required files in parallel
|
||||||
for (const [relativePath, defaultContent] of Object.entries(REQUIRED_STRUCTURE.files)) {
|
await Promise.all(
|
||||||
const fullPath = `${projectPath}/${relativePath}`;
|
Object.entries(REQUIRED_STRUCTURE.files).map(async ([relativePath, defaultContent]) => {
|
||||||
const exists = await api.exists(fullPath);
|
const fullPath = `${projectPath}/${relativePath}`;
|
||||||
|
const exists = await api.exists(fullPath);
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
await api.writeFile(fullPath, defaultContent as string);
|
await api.writeFile(fullPath, defaultContent as string);
|
||||||
createdFiles.push(relativePath);
|
createdFiles.push(relativePath);
|
||||||
} else {
|
} else {
|
||||||
existingFiles.push(relativePath);
|
existingFiles.push(relativePath);
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Determine if this is a new project (no files needed to be created since features/ is empty by default)
|
// Determine if this is a new project (no files needed to be created since features/ is empty by default)
|
||||||
const isNewProject = createdFiles.length === 0 && existingFiles.length === 0;
|
const isNewProject = createdFiles.length === 0 && existingFiles.length === 0;
|
||||||
|
|||||||
@@ -131,13 +131,21 @@ export const queryClient = new QueryClient({
|
|||||||
if (error instanceof Error && error.message === 'Unauthorized') {
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Don't retry on connection errors (server offline)
|
// Retry connection errors a few times before declaring server offline.
|
||||||
|
// This handles transient network blips without immediately redirecting to login.
|
||||||
if (isConnectionError(error)) {
|
if (isConnectionError(error)) {
|
||||||
return false;
|
return failureCount < 3;
|
||||||
}
|
}
|
||||||
// Retry up to 2 times for other errors (3 on mobile for flaky connections)
|
// Retry up to 2 times for other errors (3 on mobile for flaky connections)
|
||||||
return failureCount < (isMobileDevice ? 3 : 2);
|
return failureCount < (isMobileDevice ? 3 : 2);
|
||||||
},
|
},
|
||||||
|
retryDelay: (attemptIndex, error) => {
|
||||||
|
// Use shorter delays for connection errors to recover quickly from blips
|
||||||
|
if (isConnectionError(error)) {
|
||||||
|
return Math.min(1000 * 2 ** attemptIndex, 5000); // 1s, 2s, 4s (capped at 5s)
|
||||||
|
}
|
||||||
|
return Math.min(1000 * 2 ** attemptIndex, 30000);
|
||||||
|
},
|
||||||
// On mobile, disable refetch on focus to prevent the blank screen + reload
|
// On mobile, disable refetch on focus to prevent the blank screen + reload
|
||||||
// cycle that occurs when the user switches back to the app. WebSocket
|
// cycle that occurs when the user switches back to the app. WebSocket
|
||||||
// invalidation handles real-time updates; polling handles the rest.
|
// invalidation handles real-time updates; polling handles the rest.
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
/**
|
||||||
|
* E2E tests for AgentOutputModal responsive behavior
|
||||||
|
* These tests verify the modal width changes across different screen sizes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
createTempDirPath,
|
||||||
|
cleanupTempDir,
|
||||||
|
setupRealProject,
|
||||||
|
waitForNetworkIdle,
|
||||||
|
authenticateForTests,
|
||||||
|
handleLoginScreenIfPresent,
|
||||||
|
} from '../../utils';
|
||||||
|
|
||||||
|
const TEST_TEMP_DIR = createTempDirPath('responsive-modal-test');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a verified feature with agent output on disk so the Logs button appears
|
||||||
|
*/
|
||||||
|
function createVerifiedFeature(projectPath: string, featureId: string, description: string): void {
|
||||||
|
const featureDir = path.join(projectPath, '.automaker', 'features', featureId);
|
||||||
|
fs.mkdirSync(featureDir, { recursive: true });
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(featureDir, 'agent-output.md'),
|
||||||
|
`## Summary\nFeature implemented successfully.\n\n## Details\n${description}`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(featureDir, 'feature.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
id: featureId,
|
||||||
|
title: description,
|
||||||
|
category: 'default',
|
||||||
|
description,
|
||||||
|
status: 'verified',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('AgentOutputModal Responsive Behavior', () => {
|
||||||
|
let projectPath: string;
|
||||||
|
const projectName = `test-responsive-${Date.now()}`;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
if (!fs.existsSync(TEST_TEMP_DIR)) {
|
||||||
|
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
projectPath = path.join(TEST_TEMP_DIR, projectName);
|
||||||
|
fs.mkdirSync(projectPath, { recursive: true });
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectPath, 'package.json'),
|
||||||
|
JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
const automakerDir = path.join(projectPath, '.automaker');
|
||||||
|
fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true });
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(automakerDir, 'categories.json'),
|
||||||
|
JSON.stringify({ categories: [] }, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(automakerDir, 'app_spec.txt'),
|
||||||
|
`# ${projectName}\n\nA test project for responsive modal testing.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
cleanupTempDir(TEST_TEMP_DIR);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: set up project, create a verified feature on disk, navigate to board,
|
||||||
|
* and open the agent output modal via the Logs button.
|
||||||
|
*/
|
||||||
|
async function setupAndOpenModal(page: import('@playwright/test').Page): Promise<string> {
|
||||||
|
const featureId = `responsive-feat-${Date.now()}`;
|
||||||
|
createVerifiedFeature(projectPath, featureId, 'Responsive test feature');
|
||||||
|
|
||||||
|
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
||||||
|
await authenticateForTests(page);
|
||||||
|
await page.goto('/board');
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
await handleLoginScreenIfPresent(page);
|
||||||
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Wait for the verified feature card to appear
|
||||||
|
const featureCard = page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||||
|
await expect(featureCard).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Click the Logs button on the verified feature card to open the output modal
|
||||||
|
const logsButton = page.locator(`[data-testid="view-output-verified-${featureId}"]`);
|
||||||
|
await expect(logsButton).toBeVisible({ timeout: 5000 });
|
||||||
|
await logsButton.click();
|
||||||
|
|
||||||
|
// Wait for modal
|
||||||
|
await expect(page.locator('[data-testid="agent-output-modal"]')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return featureId;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Mobile View (< 640px)', () => {
|
||||||
|
test('should use full width on mobile screens', async ({ page }) => {
|
||||||
|
await setupAndOpenModal(page);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
const modalWidth = await modal.evaluate((el) => el.offsetWidth);
|
||||||
|
const viewportWidth = await page.evaluate(() => window.innerWidth);
|
||||||
|
|
||||||
|
// Modal should be close to full width (within 2rem = 32px margins)
|
||||||
|
expect(modalWidth).toBeGreaterThan(viewportWidth - 40);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have proper max width constraint on mobile', async ({ page }) => {
|
||||||
|
await setupAndOpenModal(page);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 320, height: 568 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
const classList = await modal.evaluate((el) => el.className);
|
||||||
|
expect(classList).toContain('max-w-[calc(100%-2rem)]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Small View (640px - 768px)', () => {
|
||||||
|
test('should use 60vw on small screens', async ({ page }) => {
|
||||||
|
await setupAndOpenModal(page);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 640, height: 768 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
const modalWidth = await modal.evaluate((el) => el.offsetWidth);
|
||||||
|
const viewportWidth = await page.evaluate(() => window.innerWidth);
|
||||||
|
|
||||||
|
// At 640px (sm breakpoint), width should be ~60vw = 384px
|
||||||
|
const expected60vw = viewportWidth * 0.6;
|
||||||
|
expect(modalWidth).toBeLessThanOrEqual(expected60vw + 5);
|
||||||
|
expect(modalWidth).toBeGreaterThanOrEqual(expected60vw - 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have 80vh max height on small screens', async ({ page }) => {
|
||||||
|
await setupAndOpenModal(page);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 640, height: 768 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
const classList = await modal.evaluate((el) => el.className);
|
||||||
|
expect(classList).toContain('sm:max-h-[80vh]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Tablet View (>= 768px)', () => {
|
||||||
|
test('should use 90vw on tablet screens', async ({ page }) => {
|
||||||
|
await setupAndOpenModal(page);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 768, height: 1024 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
const modalWidth = await modal.evaluate((el) => el.offsetWidth);
|
||||||
|
const viewportWidth = await page.evaluate(() => window.innerWidth);
|
||||||
|
|
||||||
|
// At 768px (md breakpoint), width should be ~90vw = ~691px
|
||||||
|
const expected90vw = viewportWidth * 0.9;
|
||||||
|
expect(modalWidth).toBeLessThanOrEqual(expected90vw + 5);
|
||||||
|
expect(modalWidth).toBeGreaterThanOrEqual(expected90vw - 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have 1200px max width on tablet', async ({ page }) => {
|
||||||
|
await setupAndOpenModal(page);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 768, height: 1024 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
const classList = await modal.evaluate((el) => el.className);
|
||||||
|
expect(classList).toContain('md:max-w-[1200px]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have 85vh max height on tablet screens', async ({ page }) => {
|
||||||
|
await setupAndOpenModal(page);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 768, height: 1024 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
const classList = await modal.evaluate((el) => el.className);
|
||||||
|
expect(classList).toContain('md:max-h-[85vh]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should maintain correct height on larger tablets', async ({ page }) => {
|
||||||
|
await setupAndOpenModal(page);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1024, height: 1366 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
const modalHeight = await modal.evaluate((el) => el.offsetHeight);
|
||||||
|
const viewportHeight = await page.evaluate(() => window.innerHeight);
|
||||||
|
|
||||||
|
// Height should be <= 85vh
|
||||||
|
const expected85vh = viewportHeight * 0.85;
|
||||||
|
expect(modalHeight).toBeLessThanOrEqual(expected85vh + 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Responsive Transitions', () => {
|
||||||
|
test('should update modal size when resizing from mobile to tablet', async ({ page }) => {
|
||||||
|
await setupAndOpenModal(page);
|
||||||
|
|
||||||
|
// Start with mobile size
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
const mobileWidth = await modal.evaluate((el) => el.offsetWidth);
|
||||||
|
const mobileViewport = 375;
|
||||||
|
|
||||||
|
// Mobile: close to full width
|
||||||
|
expect(mobileWidth).toBeGreaterThan(mobileViewport - 40);
|
||||||
|
|
||||||
|
// Resize to tablet
|
||||||
|
await page.setViewportSize({ width: 768, height: 1024 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const tabletWidth = await modal.evaluate((el) => el.offsetWidth);
|
||||||
|
const tabletViewport = 768;
|
||||||
|
|
||||||
|
// Tablet: should be ~90vw
|
||||||
|
const expected90vw = tabletViewport * 0.9;
|
||||||
|
expect(tabletWidth).toBeLessThanOrEqual(expected90vw + 5);
|
||||||
|
expect(tabletWidth).toBeGreaterThanOrEqual(expected90vw - 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update modal size when resizing from tablet to mobile', async ({ page }) => {
|
||||||
|
await setupAndOpenModal(page);
|
||||||
|
|
||||||
|
// Start with tablet size
|
||||||
|
await page.setViewportSize({ width: 768, height: 1024 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
const tabletWidth = await modal.evaluate((el) => el.offsetWidth);
|
||||||
|
const tabletViewport = 768;
|
||||||
|
|
||||||
|
// Tablet: ~90vw
|
||||||
|
expect(tabletWidth).toBeLessThanOrEqual(tabletViewport * 0.9 + 5);
|
||||||
|
|
||||||
|
// Resize to mobile
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const mobileWidth = await modal.evaluate((el) => el.offsetWidth);
|
||||||
|
const mobileViewport = 375;
|
||||||
|
|
||||||
|
// Mobile: close to full width
|
||||||
|
expect(mobileWidth).toBeGreaterThan(mobileViewport - 40);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Content Responsiveness', () => {
|
||||||
|
test('should display content correctly on tablet view', async ({ page }) => {
|
||||||
|
await setupAndOpenModal(page);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 768, height: 1024 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
// Modal should be visible
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
|
||||||
|
// Description should be visible
|
||||||
|
const description = modal.locator('[data-testid="agent-output-description"]');
|
||||||
|
await expect(description).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should maintain readability on tablet with wider width', async ({ page }) => {
|
||||||
|
await setupAndOpenModal(page);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1200, height: 800 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
const modalWidth = await modal.evaluate((el) => el.offsetWidth);
|
||||||
|
|
||||||
|
// At 1200px, max-width is 1200px so modal should not exceed that
|
||||||
|
expect(modalWidth).toBeLessThanOrEqual(1200);
|
||||||
|
expect(modalWidth).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Modal Functionality Across Screens', () => {
|
||||||
|
test('should maintain functionality while resizing', async ({ page }) => {
|
||||||
|
await setupAndOpenModal(page);
|
||||||
|
|
||||||
|
// Test on mobile
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await expect(page.locator('[data-testid="agent-output-modal"]')).toBeVisible();
|
||||||
|
|
||||||
|
// Test on tablet
|
||||||
|
await page.setViewportSize({ width: 768, height: 1024 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await expect(page.locator('[data-testid="agent-output-modal"]')).toBeVisible();
|
||||||
|
|
||||||
|
// Close modal and verify
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.locator('[data-testid="agent-output-modal"]')).not.toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle view mode buttons on tablet', async ({ page }) => {
|
||||||
|
await setupAndOpenModal(page);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 768, height: 1024 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
// Logs button should be visible and clickable
|
||||||
|
const logsButton = page.getByTestId('view-mode-parsed');
|
||||||
|
await expect(logsButton).toBeVisible();
|
||||||
|
|
||||||
|
// Raw button should be visible
|
||||||
|
const rawButton = page.getByTestId('view-mode-raw');
|
||||||
|
await expect(rawButton).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
244
apps/ui/tests/features/success-log-contrast.spec.ts
Normal file
244
apps/ui/tests/features/success-log-contrast.spec.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* E2E test for success log output contrast improvement
|
||||||
|
* Verifies that success tool output has better visual contrast in the parsed log view
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
createTempDirPath,
|
||||||
|
cleanupTempDir,
|
||||||
|
setupRealProject,
|
||||||
|
waitForNetworkIdle,
|
||||||
|
authenticateForTests,
|
||||||
|
handleLoginScreenIfPresent,
|
||||||
|
} from '../utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test feature with agent output for contrast verification
|
||||||
|
*/
|
||||||
|
function createTestFeature(
|
||||||
|
projectPath: string,
|
||||||
|
featureId: string,
|
||||||
|
outputContent: string,
|
||||||
|
title: string = 'Test Success Contrast',
|
||||||
|
description: string = 'Testing success log output contrast'
|
||||||
|
): void {
|
||||||
|
const featureDir = path.join(projectPath, '.automaker', 'features', featureId);
|
||||||
|
fs.mkdirSync(featureDir, { recursive: true });
|
||||||
|
|
||||||
|
// Write agent output
|
||||||
|
fs.writeFileSync(path.join(featureDir, 'agent-output.md'), outputContent, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write feature metadata with all required fields
|
||||||
|
const featureData = {
|
||||||
|
id: featureId,
|
||||||
|
title,
|
||||||
|
category: 'default',
|
||||||
|
description,
|
||||||
|
status: 'verified',
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(featureDir, 'feature.json'), JSON.stringify(featureData, null, 2), {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEST_TEMP_DIR = createTempDirPath('success-log-contrast');
|
||||||
|
|
||||||
|
test.describe('Success log output contrast', () => {
|
||||||
|
let projectPath: string;
|
||||||
|
const projectName = `test-contrast-${Date.now()}`;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
if (!fs.existsSync(TEST_TEMP_DIR)) {
|
||||||
|
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
projectPath = path.join(TEST_TEMP_DIR, projectName);
|
||||||
|
fs.mkdirSync(projectPath, { recursive: true });
|
||||||
|
|
||||||
|
// Create minimal project structure
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectPath, 'package.json'),
|
||||||
|
JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create .automaker directory structure
|
||||||
|
const automakerDir = path.join(projectPath, '.automaker');
|
||||||
|
fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true });
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(automakerDir, 'categories.json'),
|
||||||
|
JSON.stringify({ categories: [] }, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(automakerDir, 'app_spec.txt'),
|
||||||
|
`# ${projectName}\n\nA test project for success log contrast verification.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
cleanupTempDir(TEST_TEMP_DIR);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: set up project, create a verified feature, navigate to board,
|
||||||
|
* and open the agent output modal with the parsed/logs view active.
|
||||||
|
*/
|
||||||
|
async function setupAndOpenLogsView(
|
||||||
|
page: import('@playwright/test').Page,
|
||||||
|
featureId: string,
|
||||||
|
outputContent: string,
|
||||||
|
title: string,
|
||||||
|
description: string
|
||||||
|
): Promise<void> {
|
||||||
|
createTestFeature(projectPath, featureId, outputContent, title, description);
|
||||||
|
|
||||||
|
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
||||||
|
await authenticateForTests(page);
|
||||||
|
await page.goto('/board');
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
await handleLoginScreenIfPresent(page);
|
||||||
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Wait for the verified feature card to appear
|
||||||
|
const featureCard = page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||||
|
await expect(featureCard).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Click the Logs button on the verified feature card
|
||||||
|
const logsButton = page.locator(`[data-testid="view-output-verified-${featureId}"]`);
|
||||||
|
await expect(logsButton).toBeVisible({ timeout: 5000 });
|
||||||
|
await logsButton.click();
|
||||||
|
|
||||||
|
// Wait for modal to open
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// The modal opens in Logs view by default. Verify the Logs tab is active.
|
||||||
|
const parsedButton = page.getByTestId('view-mode-parsed');
|
||||||
|
await expect(parsedButton).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('should display success log output with improved contrast', async ({ page }) => {
|
||||||
|
const testFeatureId = `test-success-contrast-${Date.now()}`;
|
||||||
|
|
||||||
|
const mockOutput = `## Summary
|
||||||
|
Successfully implemented the feature with improved contrast.
|
||||||
|
|
||||||
|
## Action Phase
|
||||||
|
✓ Created component with proper styling
|
||||||
|
✓ Verified success message contrast is improved
|
||||||
|
✓ All tests passing
|
||||||
|
|
||||||
|
The feature is complete and ready for review.
|
||||||
|
`;
|
||||||
|
|
||||||
|
await setupAndOpenLogsView(
|
||||||
|
page,
|
||||||
|
testFeatureId,
|
||||||
|
mockOutput,
|
||||||
|
'Test Success Contrast',
|
||||||
|
'Testing success log output contrast'
|
||||||
|
);
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
|
||||||
|
// Verify the modal shows the parsed log view with log entries
|
||||||
|
// The log viewer should display entries parsed from the agent output
|
||||||
|
// Use .first() because "Summary" appears in both the badge and the content preview
|
||||||
|
await expect(modal.locator('text=Summary').first()).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Verify the description is shown
|
||||||
|
await expect(modal.locator('text=Testing success log output contrast')).toBeVisible();
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(modal).not.toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should maintain consistency across all log types', async ({ page }) => {
|
||||||
|
const testFeatureId = `test-all-logs-${Date.now()}`;
|
||||||
|
|
||||||
|
const mixedOutput = `## Planning Phase
|
||||||
|
Analyzing requirements and creating implementation plan.
|
||||||
|
|
||||||
|
## Development Phase
|
||||||
|
Creating components and implementing features.
|
||||||
|
|
||||||
|
## Testing Phase
|
||||||
|
Running tests and verifying functionality.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Feature implementation complete with all tests passing.
|
||||||
|
`;
|
||||||
|
|
||||||
|
await setupAndOpenLogsView(
|
||||||
|
page,
|
||||||
|
testFeatureId,
|
||||||
|
mixedOutput,
|
||||||
|
'Test All Logs',
|
||||||
|
'Testing all log types'
|
||||||
|
);
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
|
||||||
|
// Verify log entries are displayed in the parsed view
|
||||||
|
// Use .first() because "Summary" appears in both the badge and the content preview
|
||||||
|
await expect(modal.locator('text=Summary').first()).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Verify the description is shown
|
||||||
|
await expect(modal.locator('text=Testing all log types')).toBeVisible();
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(modal).not.toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have consistent badge styling with improved contrast', async ({ page }) => {
|
||||||
|
const testFeatureId = `test-badge-contrast-${Date.now()}`;
|
||||||
|
|
||||||
|
const badgeOutput = `## Summary
|
||||||
|
✅ Component created successfully
|
||||||
|
✅ Tests passing with improved contrast
|
||||||
|
✅ Ready for deployment
|
||||||
|
|
||||||
|
All tasks completed successfully.
|
||||||
|
`;
|
||||||
|
|
||||||
|
await setupAndOpenLogsView(
|
||||||
|
page,
|
||||||
|
testFeatureId,
|
||||||
|
badgeOutput,
|
||||||
|
'Test Badge Contrast',
|
||||||
|
'Testing badge contrast in success logs'
|
||||||
|
);
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
|
||||||
|
// Verify the parsed log view shows content
|
||||||
|
await expect(modal.locator('text=Summary')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Verify the description is shown
|
||||||
|
await expect(modal.locator('text=Testing badge contrast in success logs')).toBeVisible();
|
||||||
|
|
||||||
|
// Verify the filter badges are displayed (showing log type counts)
|
||||||
|
// The log viewer shows filter badges like "success: 1" to indicate log types
|
||||||
|
const filterSection = modal.locator('button:has-text("success")');
|
||||||
|
if (await filterSection.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||||
|
// Success filter badge is present, indicating logs were categorized correctly
|
||||||
|
await expect(filterSection).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(modal).not.toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,10 @@
|
|||||||
import { chromium, FullConfig } from '@playwright/test';
|
import { chromium, FullConfig } from '@playwright/test';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { cleanupLeftoverTestDirs } from './utils/cleanup-test-dirs';
|
import {
|
||||||
|
cleanupLeftoverFixtureWorkerDirs,
|
||||||
|
cleanupLeftoverTestDirs,
|
||||||
|
} from './utils/cleanup-test-dirs';
|
||||||
|
|
||||||
const TEST_PORT = process.env.TEST_PORT || '3107';
|
const TEST_PORT = process.env.TEST_PORT || '3107';
|
||||||
const TEST_SERVER_PORT = process.env.TEST_SERVER_PORT || '3108';
|
const TEST_SERVER_PORT = process.env.TEST_SERVER_PORT || '3108';
|
||||||
@@ -19,8 +22,9 @@ const AUTH_DIR = path.join(__dirname, '.auth');
|
|||||||
const AUTH_STATE_PATH = path.join(AUTH_DIR, 'storage-state.json');
|
const AUTH_STATE_PATH = path.join(AUTH_DIR, 'storage-state.json');
|
||||||
|
|
||||||
async function globalSetup(config: FullConfig) {
|
async function globalSetup(config: FullConfig) {
|
||||||
// Clean up leftover test dirs from previous runs (aborted, crashed, etc.)
|
// Clean up leftover test dirs and fixture worker copies from previous runs (aborted, crashed, etc.)
|
||||||
cleanupLeftoverTestDirs();
|
cleanupLeftoverTestDirs();
|
||||||
|
cleanupLeftoverFixtureWorkerDirs();
|
||||||
|
|
||||||
// Note: Server killing is handled by the pretest script in package.json
|
// Note: Server killing is handled by the pretest script in package.json
|
||||||
// GlobalSetup runs AFTER webServer starts, so we can't kill the server here
|
// GlobalSetup runs AFTER webServer starts, so we can't kill the server here
|
||||||
|
|||||||
@@ -6,10 +6,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FullConfig } from '@playwright/test';
|
import { FullConfig } from '@playwright/test';
|
||||||
import { cleanupLeftoverTestDirs } from './utils/cleanup-test-dirs';
|
import {
|
||||||
|
cleanupLeftoverFixtureWorkerDirs,
|
||||||
|
cleanupLeftoverTestDirs,
|
||||||
|
} from './utils/cleanup-test-dirs';
|
||||||
|
|
||||||
async function globalTeardown(_config: FullConfig) {
|
async function globalTeardown(_config: FullConfig) {
|
||||||
cleanupLeftoverTestDirs();
|
cleanupLeftoverTestDirs();
|
||||||
|
cleanupLeftoverFixtureWorkerDirs();
|
||||||
console.log('[GlobalTeardown] Cleanup complete');
|
console.log('[GlobalTeardown] Cleanup complete');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
* Fix: Added useShallow selector to ensure proper reactivity when enabledDynamicModelIds array changes
|
* Fix: Added useShallow selector to ensure proper reactivity when enabledDynamicModelIds array changes
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { renderHook } from '@testing-library/react';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
|
||||||
// Mock the store
|
// Mock the store
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Cleanup leftover E2E test artifact directories.
|
* Cleanup leftover E2E test artifact directories.
|
||||||
* Used by globalSetup (start of run) and globalTeardown (end of run) to ensure
|
* Used by globalSetup (start of run) and globalTeardown (end of run) to ensure:
|
||||||
* test/board-bg-test-*, test/edit-feature-test-*, etc. are removed.
|
* - test/board-bg-test-*, test/edit-feature-test-*, etc. are removed
|
||||||
|
* - test/fixtures/.worker-* (worker-isolated fixture copies) are removed
|
||||||
*
|
*
|
||||||
* Per-spec afterAll hooks clean up their own dirs, but when workers crash,
|
* Per-spec afterAll hooks clean up their own dirs, but when workers crash,
|
||||||
* runs are aborted, or afterAll fails, dirs can be left behind.
|
* runs are aborted, or afterAll fails, dirs can be left behind.
|
||||||
@@ -25,9 +26,33 @@ const TEST_DIR_PREFIXES = [
|
|||||||
'skip-tests-toggle-test',
|
'skip-tests-toggle-test',
|
||||||
'manual-review-test',
|
'manual-review-test',
|
||||||
'feature-backlog-test',
|
'feature-backlog-test',
|
||||||
'agent-output-modal-responsive',
|
'responsive-modal-test',
|
||||||
|
'success-log-contrast',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove worker-isolated fixture copies (test/fixtures/.worker-*).
|
||||||
|
* These are created during test runs for parallel workers and should be
|
||||||
|
* cleaned up after tests complete (or at start of next run).
|
||||||
|
*/
|
||||||
|
export function cleanupLeftoverFixtureWorkerDirs(): void {
|
||||||
|
const fixturesBase = path.join(getWorkspaceRoot(), 'test', 'fixtures');
|
||||||
|
if (!fs.existsSync(fixturesBase)) return;
|
||||||
|
|
||||||
|
const entries = fs.readdirSync(fixturesBase, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory() && entry.name.startsWith('.worker-')) {
|
||||||
|
const dirPath = path.join(fixturesBase, entry.name);
|
||||||
|
try {
|
||||||
|
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||||
|
console.log('[Cleanup] Removed fixture worker dir', entry.name);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Cleanup] Failed to remove', dirPath, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function cleanupLeftoverTestDirs(): void {
|
export function cleanupLeftoverTestDirs(): void {
|
||||||
const testBase = path.join(getWorkspaceRoot(), 'test');
|
const testBase = path.join(getWorkspaceRoot(), 'test');
|
||||||
if (!fs.existsSync(testBase)) return;
|
if (!fs.existsSync(testBase)) return;
|
||||||
|
|||||||
@@ -1,282 +0,0 @@
|
|||||||
/**
|
|
||||||
* Responsive testing utilities for modal components
|
|
||||||
* These utilities help test responsive behavior across different screen sizes
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Page, expect } from '@playwright/test';
|
|
||||||
import { waitForElement } from '../core/waiting';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for viewport resize to stabilize by polling element dimensions
|
|
||||||
* until they stop changing. Much more reliable than a fixed timeout.
|
|
||||||
*/
|
|
||||||
async function waitForLayoutStable(page: Page, testId: string, timeout = 2000): Promise<void> {
|
|
||||||
await page.waitForFunction(
|
|
||||||
({ testId: tid, timeout: t }) => {
|
|
||||||
return new Promise<boolean>((resolve) => {
|
|
||||||
const el = document.querySelector(`[data-testid="${tid}"]`);
|
|
||||||
if (!el) {
|
|
||||||
resolve(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let lastWidth = el.clientWidth;
|
|
||||||
let lastHeight = el.clientHeight;
|
|
||||||
let stableCount = 0;
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
const w = el.clientWidth;
|
|
||||||
const h = el.clientHeight;
|
|
||||||
if (w === lastWidth && h === lastHeight) {
|
|
||||||
stableCount++;
|
|
||||||
if (stableCount >= 3) {
|
|
||||||
clearInterval(interval);
|
|
||||||
resolve(true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stableCount = 0;
|
|
||||||
lastWidth = w;
|
|
||||||
lastHeight = h;
|
|
||||||
}
|
|
||||||
}, 50);
|
|
||||||
setTimeout(() => {
|
|
||||||
clearInterval(interval);
|
|
||||||
resolve(true);
|
|
||||||
}, t);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ testId, timeout },
|
|
||||||
{ timeout: timeout + 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Viewport sizes for different device types
|
|
||||||
*/
|
|
||||||
export const VIEWPORTS = {
|
|
||||||
mobile: { width: 375, height: 667 },
|
|
||||||
mobileLarge: { width: 414, height: 896 },
|
|
||||||
tablet: { width: 768, height: 1024 },
|
|
||||||
tabletLarge: { width: 1024, height: 1366 },
|
|
||||||
desktop: { width: 1280, height: 720 },
|
|
||||||
desktopLarge: { width: 1920, height: 1080 },
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expected responsive classes for AgentOutputModal
|
|
||||||
*/
|
|
||||||
export const EXPECTED_CLASSES = {
|
|
||||||
mobile: {
|
|
||||||
width: ['w-full', 'max-w-[calc(100%-2rem)]'],
|
|
||||||
height: ['max-h-[85dvh]'],
|
|
||||||
},
|
|
||||||
small: {
|
|
||||||
width: ['sm:w-[60vw]', 'sm:max-w-[60vw]'],
|
|
||||||
height: ['sm:max-h-[80vh]'],
|
|
||||||
},
|
|
||||||
tablet: {
|
|
||||||
width: ['md:w-[90vw]', 'md:max-w-[1200px]'],
|
|
||||||
height: ['md:max-h-[85vh]'],
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the computed width of the modal in pixels
|
|
||||||
*/
|
|
||||||
export async function getModalWidth(page: Page): Promise<number> {
|
|
||||||
const modal = page.locator('[data-testid="agent-output-modal"]');
|
|
||||||
return await modal.evaluate((el) => el.offsetWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the computed height of the modal in pixels
|
|
||||||
*/
|
|
||||||
export async function getModalHeight(page: Page): Promise<number> {
|
|
||||||
const modal = page.locator('[data-testid="agent-output-modal"]');
|
|
||||||
return await modal.evaluate((el) => el.offsetHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the computed style properties of the modal
|
|
||||||
*/
|
|
||||||
export async function getModalComputedStyle(page: Page): Promise<{
|
|
||||||
width: string;
|
|
||||||
height: string;
|
|
||||||
maxWidth: string;
|
|
||||||
maxHeight: string;
|
|
||||||
}> {
|
|
||||||
const modal = page.locator('[data-testid="agent-output-modal"]');
|
|
||||||
return await modal.evaluate((el) => {
|
|
||||||
const style = window.getComputedStyle(el);
|
|
||||||
return {
|
|
||||||
width: style.width,
|
|
||||||
height: style.height,
|
|
||||||
maxWidth: style.maxWidth,
|
|
||||||
maxHeight: style.maxHeight,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if modal has expected classes for a specific viewport
|
|
||||||
*/
|
|
||||||
export async function expectModalResponsiveClasses(
|
|
||||||
page: Page,
|
|
||||||
viewport: keyof typeof VIEWPORTS,
|
|
||||||
expectedClasses: string[]
|
|
||||||
): Promise<void> {
|
|
||||||
const modal = page.locator('[data-testid="agent-output-modal"]');
|
|
||||||
|
|
||||||
for (const className of expectedClasses) {
|
|
||||||
await expect(modal).toContainClass(className);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test modal width across different viewports
|
|
||||||
*/
|
|
||||||
export async function testModalWidthAcrossViewports(
|
|
||||||
page: Page,
|
|
||||||
viewports: Array<keyof typeof VIEWPORTS>
|
|
||||||
): Promise<void> {
|
|
||||||
for (const viewport of viewports) {
|
|
||||||
const size = VIEWPORTS[viewport];
|
|
||||||
|
|
||||||
// Set viewport
|
|
||||||
await page.setViewportSize(size);
|
|
||||||
|
|
||||||
// Wait for any responsive transitions
|
|
||||||
await waitForLayoutStable(page, 'agent-output-modal');
|
|
||||||
|
|
||||||
// Get modal width
|
|
||||||
const modalWidth = await getModalWidth(page);
|
|
||||||
const viewportWidth = size.width;
|
|
||||||
|
|
||||||
// Check constraints based on viewport
|
|
||||||
if (viewport === 'mobile' || viewport === 'mobileLarge') {
|
|
||||||
// Mobile: should be close to full width with 2rem margins
|
|
||||||
expect(modalWidth).toBeGreaterThan(viewportWidth - 40);
|
|
||||||
expect(modalWidth).toBeLessThan(viewportWidth - 20);
|
|
||||||
} else if (viewport === 'tablet' || viewport === 'tabletLarge') {
|
|
||||||
// Tablet: should be around 90vw but not exceed max-w-[1200px]
|
|
||||||
const expected90vw = Math.floor(viewportWidth * 0.9);
|
|
||||||
expect(modalWidth).toBeLessThanOrEqual(expected90vw);
|
|
||||||
expect(modalWidth).toBeLessThanOrEqual(1200);
|
|
||||||
} else if (viewport === 'desktop' || viewport === 'desktopLarge') {
|
|
||||||
// Desktop: should be bounded by viewport and max-width constraints
|
|
||||||
const expectedMaxWidth = Math.floor(viewportWidth * 0.9);
|
|
||||||
const modalHeight = await getModalHeight(page);
|
|
||||||
const viewportHeight = size.height;
|
|
||||||
const expectedMaxHeight = Math.floor(viewportHeight * 0.9);
|
|
||||||
expect(modalWidth).toBeLessThanOrEqual(expectedMaxWidth);
|
|
||||||
expect(modalWidth).toBeLessThanOrEqual(1200);
|
|
||||||
expect(modalWidth).toBeGreaterThan(0);
|
|
||||||
expect(modalHeight).toBeLessThanOrEqual(expectedMaxHeight);
|
|
||||||
expect(modalHeight).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test modal height across different viewports
|
|
||||||
*/
|
|
||||||
export async function testModalHeightAcrossViewports(
|
|
||||||
page: Page,
|
|
||||||
viewports: Array<keyof typeof VIEWPORTS>
|
|
||||||
): Promise<void> {
|
|
||||||
for (const viewport of viewports) {
|
|
||||||
const size = VIEWPORTS[viewport];
|
|
||||||
|
|
||||||
// Set viewport
|
|
||||||
await page.setViewportSize(size);
|
|
||||||
|
|
||||||
// Wait for any responsive transitions
|
|
||||||
await waitForLayoutStable(page, 'agent-output-modal');
|
|
||||||
|
|
||||||
// Get modal height
|
|
||||||
const modalHeight = await getModalHeight(page);
|
|
||||||
const viewportHeight = size.height;
|
|
||||||
|
|
||||||
// Check constraints based on viewport
|
|
||||||
if (viewport === 'mobile' || viewport === 'mobileLarge') {
|
|
||||||
// Mobile: should be max-h-[85dvh]
|
|
||||||
const expected85dvh = Math.floor(viewportHeight * 0.85);
|
|
||||||
expect(modalHeight).toBeLessThanOrEqual(expected85dvh);
|
|
||||||
} else if (viewport === 'tablet' || viewport === 'tabletLarge') {
|
|
||||||
// Tablet: should be max-h-[85vh]
|
|
||||||
const expected85vh = Math.floor(viewportHeight * 0.85);
|
|
||||||
expect(modalHeight).toBeLessThanOrEqual(expected85vh);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test modal responsiveness during resize
|
|
||||||
*/
|
|
||||||
export async function testModalResponsiveResize(
|
|
||||||
page: Page,
|
|
||||||
fromViewport: keyof typeof VIEWPORTS,
|
|
||||||
toViewport: keyof typeof VIEWPORTS
|
|
||||||
): Promise<void> {
|
|
||||||
// Set initial viewport
|
|
||||||
await page.setViewportSize(VIEWPORTS[fromViewport]);
|
|
||||||
await waitForLayoutStable(page, 'agent-output-modal');
|
|
||||||
|
|
||||||
// Get initial modal dimensions (used for comparison context)
|
|
||||||
await getModalComputedStyle(page);
|
|
||||||
|
|
||||||
// Resize to new viewport
|
|
||||||
await page.setViewportSize(VIEWPORTS[toViewport]);
|
|
||||||
await waitForLayoutStable(page, 'agent-output-modal');
|
|
||||||
|
|
||||||
// Get new modal dimensions
|
|
||||||
const newDimensions = await getModalComputedStyle(page);
|
|
||||||
|
|
||||||
// Verify dimensions changed appropriately using resolved pixel values
|
|
||||||
const toSize = VIEWPORTS[toViewport];
|
|
||||||
if (fromViewport === 'mobile' && toViewport === 'tablet') {
|
|
||||||
const widthPx = parseFloat(newDimensions.width);
|
|
||||||
const maxWidthPx = parseFloat(newDimensions.maxWidth);
|
|
||||||
const expected90vw = toSize.width * 0.9;
|
|
||||||
expect(widthPx).toBeLessThanOrEqual(expected90vw + 2);
|
|
||||||
expect(maxWidthPx).toBeGreaterThanOrEqual(1200);
|
|
||||||
} else if (fromViewport === 'tablet' && toViewport === 'mobile') {
|
|
||||||
const widthPx = parseFloat(newDimensions.width);
|
|
||||||
const maxWidthPx = parseFloat(newDimensions.maxWidth);
|
|
||||||
expect(widthPx).toBeGreaterThan(toSize.width - 60);
|
|
||||||
expect(maxWidthPx).toBeLessThan(1200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify modal maintains functionality across viewports
|
|
||||||
*/
|
|
||||||
export async function verifyModalFunctionalityAcrossViewports(
|
|
||||||
page: Page,
|
|
||||||
viewports: Array<keyof typeof VIEWPORTS>
|
|
||||||
): Promise<void> {
|
|
||||||
for (const viewport of viewports) {
|
|
||||||
const size = VIEWPORTS[viewport];
|
|
||||||
|
|
||||||
// Set viewport
|
|
||||||
await page.setViewportSize(size);
|
|
||||||
await waitForLayoutStable(page, 'agent-output-modal');
|
|
||||||
|
|
||||||
// Verify modal is visible
|
|
||||||
const modal = await waitForElement(page, 'agent-output-modal');
|
|
||||||
await expect(modal).toBeVisible();
|
|
||||||
|
|
||||||
// Verify modal content is visible
|
|
||||||
const description = page.locator('[data-testid="agent-output-description"]');
|
|
||||||
await expect(description).toBeVisible();
|
|
||||||
|
|
||||||
// Verify view mode buttons are visible
|
|
||||||
if (
|
|
||||||
viewport === 'tablet' ||
|
|
||||||
viewport === 'tabletLarge' ||
|
|
||||||
viewport === 'desktop' ||
|
|
||||||
viewport === 'desktopLarge'
|
|
||||||
) {
|
|
||||||
const logsButton = page.getByTestId('view-mode-parsed');
|
|
||||||
await expect(logsButton).toBeVisible();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a deterministic temp directory path for a test suite.
|
|
||||||
* The directory is NOT created on disk — call fs.mkdirSync in beforeAll.
|
|
||||||
*/
|
|
||||||
export function createTempDirPath(prefix: string): string {
|
|
||||||
return path.join(os.tmpdir(), `automaker-test-${prefix}-${process.pid}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a temp directory and all its contents.
|
|
||||||
* Silently ignores errors (e.g. directory already removed).
|
|
||||||
*/
|
|
||||||
export function cleanupTempDir(dirPath: string): void {
|
|
||||||
try {
|
|
||||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user