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:
gsxdsm
2026-02-27 22:14:41 -08:00
committed by GitHub
parent 0196911d59
commit 1c0e460dd1
36 changed files with 2048 additions and 406 deletions

1
.gitignore vendored
View File

@@ -76,6 +76,7 @@ test/board-bg-test-*/
test/edit-feature-test-*/
test/open-project-test-*/
# Environment files (keep .example)
.env
.env.local

View File

@@ -19,6 +19,11 @@ import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent
import { createGenerateTitleHandler } from './routes/generate-title.js';
import { createExportHandler } from './routes/export.js';
import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
import {
createOrphanedListHandler,
createOrphanedResolveHandler,
createOrphanedBulkResolveHandler,
} from './routes/orphaned.js';
export function createFeaturesRoutes(
featureLoader: FeatureLoader,
@@ -70,6 +75,21 @@ export function createFeaturesRoutes(
validatePathParams('projectPath'),
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;
}

View File

@@ -46,7 +46,7 @@ export function createListHandler(
// Note: detectOrphanedFeatures handles errors internally and always resolves
if (autoModeService) {
autoModeService
.detectOrphanedFeatures(projectPath)
.detectOrphanedFeatures(projectPath, features)
.then((orphanedFeatures) => {
if (orphanedFeatures.length > 0) {
logger.info(

View 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) });
}
};
}

View File

@@ -232,9 +232,10 @@ export class AutoModeServiceCompat {
}
async detectOrphanedFeatures(
projectPath: string
projectPath: string,
preloadedFeatures?: Feature[]
): Promise<Array<{ feature: Feature; missingBranch: string }>> {
const facade = this.createFacade(projectPath);
return facade.detectOrphanedFeatures();
return facade.detectOrphanedFeatures(preloadedFeatures);
}
}

View File

@@ -463,9 +463,25 @@ export class AutoModeServiceFacade {
(pPath, featureId, status) =>
featureStateManager.updateFeatureStatus(pPath, featureId, status),
(pPath, featureId) => featureStateManager.loadFeature(pPath, featureId),
async (_feature) => {
// getPlanningPromptPrefixFn - planning prompts handled by AutoModeService
async (feature) => {
// getPlanningPromptPrefixFn - select appropriate planning prompt based on feature's planningMode
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) =>
featureStateManager.saveFeatureSummary(pPath, featureId, summary),
@@ -1117,12 +1133,13 @@ export class AutoModeServiceFacade {
/**
* 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[] = [];
try {
const allFeatures = await this.featureLoader.getAll(this.projectPath);
const allFeatures = preloadedFeatures ?? (await this.featureLoader.getAll(this.projectPath));
const featuresWithBranches = allFeatures.filter(
(f) => f.branchName && f.branchName.trim() !== ''
);

View File

@@ -108,16 +108,14 @@ export class ExecutionService {
return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...';
}
buildFeaturePrompt(
feature: Feature,
taskExecutionPrompts: {
implementationInstructions: string;
playwrightVerificationInstructions: string;
}
): string {
/**
* Build feature description section (without implementation instructions).
* Used when planning mode is active — the planning prompt provides its own instructions.
*/
buildFeatureDescription(feature: Feature): string {
const title = this.extractTitleFromDescription(feature.description);
let prompt = `## Feature Implementation Task
let prompt = `## Feature Task
**Feature ID:** ${feature.id}
**Title:** ${title}
@@ -146,6 +144,18 @@ ${feature.spec}
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
? `\n${taskExecutionPrompts.implementationInstructions}`
: `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`;
@@ -273,9 +283,15 @@ ${feature.spec}
if (options?.continuationPrompt) {
prompt = options.continuationPrompt;
} else {
prompt =
(await this.getPlanningPromptPrefixFn(feature)) +
this.buildFeaturePrompt(feature, prompts.taskExecution);
const planningPrefix = await this.getPlanningPromptPrefixFn(feature);
if (planningPrefix) {
// 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') {
this.eventBus.emitAutoModeEvent('planning_started', {
featureId: feature.id,

View File

@@ -287,15 +287,17 @@ describe('File editor dirty state logic', () => {
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 = {
content: 'line1\nline2',
originalContent: 'line1\nline2',
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');
expect(tab.isDirty).toBe(true);
expect(tab.isDirty).toBe(false);
});
it('should handle unicode content correctly', () => {

View File

@@ -451,7 +451,7 @@ describe('execution-service.ts', () => {
const callArgs = mockRunAgentFn.mock.calls[0];
expect(callArgs[0]).toMatch(/test.*project/); // workDir contains project
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[4]).toBe('/test/project');
// Model (index 6) should be resolved

View File

@@ -17,6 +17,7 @@ import {
import { cn } from '@/lib/utils';
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
import { initializeProject } from '@/lib/project-init';
import { toast } from 'sonner';
import type { Project } from '@/lib/electron';
import {
DropdownMenu,
@@ -88,21 +89,21 @@ export function ProjectSelectorWithOptions({
const clearProjectHistory = useAppStore((s) => s.clearProjectHistory);
const shortcuts = useKeyboardShortcutsConfig();
// Wrap setCurrentProject to ensure .automaker is initialized before switching
// Wrap setCurrentProject to initialize .automaker in background while switching
const setCurrentProjectWithInit = useCallback(
async (p: Project) => {
(p: Project) => {
if (p.id === currentProject?.id) {
return;
}
try {
// Ensure .automaker directory structure exists before switching
await initializeProject(p.path);
} catch (error) {
// Fire-and-forget: initialize .automaker directory structure in background
// so the project switch is not blocked by filesystem operations
initializeProject(p.path).catch((error) => {
console.error('Failed to initialize project during switch:', error);
// Continue with switch even if initialization fails -
// the project may already be initialized
}
// Defer project switch update to avoid synchronous render cascades.
toast.error('Failed to initialize project .automaker', {
description: error instanceof Error ? error.message : String(error),
});
});
// Switch project immediately for instant UI response
startTransition(() => {
setCurrentProject(p);
});
@@ -131,8 +132,8 @@ export function ProjectSelectorWithOptions({
useProjectTheme();
const handleSelectProject = useCallback(
async (p: Project) => {
await setCurrentProjectWithInit(p);
(p: Project) => {
setCurrentProjectWithInit(p);
setIsProjectPickerOpen(false);
},
[setCurrentProjectWithInit, setIsProjectPickerOpen]

View File

@@ -297,9 +297,9 @@ export function AddFeatureDialog({
prefilledCategory,
]);
// Clear requirePlanApproval when planning mode is skip or lite
// Clear requirePlanApproval when planning mode is skip (lite supports approval)
useEffect(() => {
if (planningMode === 'skip' || planningMode === 'lite') {
if (planningMode === 'skip') {
setRequirePlanApproval(false);
}
}, [planningMode]);
@@ -634,14 +634,14 @@ export function AddFeatureDialog({
id="add-feature-require-approval"
checked={requirePlanApproval}
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
disabled={planningMode === 'skip' || planningMode === 'lite'}
disabled={planningMode === 'skip'}
data-testid="add-feature-planning-require-approval-checkbox"
/>
<Label
htmlFor="add-feature-require-approval"
className={cn(
'text-xs font-normal',
planningMode === 'skip' || planningMode === 'lite'
planningMode === 'skip'
? 'cursor-not-allowed text-muted-foreground'
: 'cursor-pointer'
)}

View File

@@ -24,9 +24,9 @@ import {
import { getFirstNonEmptySummary } from '@/lib/summary-selection';
import { useAgentOutput, useFeature } from '@/hooks/queries';
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 { BacklogPlanEvent } from '@automaker/types';
import { MODAL_CONSTANTS } from './agent-output-modal.constants';
interface AgentOutputModalProps {
open: boolean;
@@ -43,7 +43,7 @@ interface AgentOutputModalProps {
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.
@@ -164,11 +164,11 @@ export function AgentOutputModal({
const isBacklogPlan = featureId.startsWith('backlog-plan:');
// 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)
const [streamedContent, setStreamedContent] = useState<string>('');
// Track view mode state
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
const [streamedContent, setStreamedContent] = useState<string>('');
// Use React Query for initial output loading
const {
@@ -221,7 +221,8 @@ export function AgentOutputModal({
}, [normalizedSummary]);
// 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 autoScrollRef = useRef(true);
const useWorktrees = useAppStore((state) => state.useWorktrees);
@@ -486,7 +487,8 @@ export function AgentOutputModal({
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
const isAtBottom =
scrollHeight - scrollTop - clientHeight < MODAL_CONSTANTS.AUTOSCROLL_THRESHOLD;
autoScrollRef.current = isAtBottom;
};
@@ -511,7 +513,7 @@ export function AgentOutputModal({
return (
<Dialog open={open} onOpenChange={onClose}>
<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"
>
<DialogHeader className="shrink-0">
@@ -593,7 +595,9 @@ export function AgentOutputModal({
)}
{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 ? (
<GitDiffPanel
projectPath={resolvedProjectPath}
@@ -658,7 +662,7 @@ export function AgentOutputModal({
<div
ref={scrollRef}
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 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">

View File

@@ -192,9 +192,9 @@ export function EditFeatureDialog({
}
}, [feature, allFeatures]);
// Clear requirePlanApproval when planning mode is skip or lite
// Clear requirePlanApproval when planning mode is skip (lite supports approval)
useEffect(() => {
if (planningMode === 'skip' || planningMode === 'lite') {
if (planningMode === 'skip') {
setRequirePlanApproval(false);
}
}, [planningMode]);
@@ -485,14 +485,14 @@ export function EditFeatureDialog({
id="edit-feature-require-approval"
checked={requirePlanApproval}
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
disabled={planningMode === 'skip' || planningMode === 'lite'}
disabled={planningMode === 'skip'}
data-testid="edit-feature-require-approval-checkbox"
/>
<Label
htmlFor="edit-feature-require-approval"
className={cn(
'text-xs font-normal',
planningMode === 'skip' || planningMode === 'lite'
planningMode === 'skip'
? 'cursor-not-allowed text-muted-foreground'
: 'cursor-pointer'
)}

View File

@@ -199,9 +199,9 @@ export function MassEditDialog({
}
}, [open, selectedFeatures]);
// Clear requirePlanApproval when planning mode is skip or lite
// Clear requirePlanApproval when planning mode is skip (lite supports approval)
useEffect(() => {
if (planningMode === 'skip' || planningMode === 'lite') {
if (planningMode === 'skip') {
setRequirePlanApproval(false);
}
}, [planningMode]);

View File

@@ -87,8 +87,8 @@ export function PlanningModeSelect({
}: PlanningModeSelectProps) {
const selectedMode = modes.find((m) => m.value === mode);
// Disable approval checkbox for skip/lite modes since they don't use planning
const isApprovalDisabled = disabled || mode === 'skip' || mode === 'lite';
// Disable approval checkbox for skip mode (lite supports approval)
const isApprovalDisabled = disabled || mode === 'skip';
const selectDropdown = (
<Select

View File

@@ -25,9 +25,18 @@ interface EditorTabsProps {
/** Get a file icon color based on extension */
function getFileColor(fileName: string): string {
const dotIndex = fileName.lastIndexOf('.');
// Files without an extension (no dot, or dotfile with dot at position 0)
const ext = dotIndex > 0 ? fileName.slice(dotIndex + 1).toLowerCase() : '';
const name = fileName.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) {
case 'ts':
case 'tsx':
@@ -71,7 +80,9 @@ function getFileColor(fileName: string): string {
case 'zsh':
return 'text-green-300';
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';
}
}

View File

@@ -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 {
return content !== originalContent;
return normalizeLineEndings(content) !== normalizeLineEndings(originalContent);
}
export function updateTabWithContent<

View File

@@ -37,6 +37,7 @@ import {
type FileTreeNode,
type EnhancedGitFileStatus,
} from './use-file-editor-store';
import { normalizeLineEndings } from './file-editor-dirty-utils';
import { FileTree } from './components/file-tree';
import { CodeEditor, getLanguageName, type CodeEditorHandle } from './components/code-editor';
import { EditorTabs } from './components/editor-tabs';
@@ -169,6 +170,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
closeAllTabs,
setActiveTab,
markTabSaved,
refreshTabContent,
setMarkdownViewMode,
setMobileBrowserVisible,
activeFileGitDetails,
@@ -360,6 +362,30 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
const existing = tabs.find((t) => t.filePath === filePath);
if (existing) {
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;
}
@@ -428,11 +454,15 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
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({
filePath,
fileName,
content: result.content,
originalContent: result.content,
content: normalizedContent,
originalContent: normalizedContent,
isDirty: false,
scrollTop: 0,
cursorLine: 1,
@@ -446,7 +476,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
logger.error('Failed to open file:', error);
}
},
[tabs, setActiveTab, openTab, maxFileSize]
[tabs, setActiveTab, openTab, refreshTabContent, maxFileSize]
);
// ─── Mobile-aware file select ────────────────────────────────
@@ -703,6 +733,7 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
model: string;
thinkingLevel: string;
reasoningEffort: string;
providerId?: string;
skipTests: boolean;
branchName: string;
planningMode: string;
@@ -1204,6 +1235,37 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
};
}, [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
useEffect(() => {
if (initialPath) {

View File

@@ -1,6 +1,10 @@
import { create } from 'zustand';
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 {
name: string;
@@ -128,6 +132,8 @@ interface FileEditorState {
markTabSaved: (tabId: string, content: string) => void;
updateTabScroll: (tabId: string, scrollTop: 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;
@@ -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) => {
set({
tabs: get().tabs.map((t) => (t.id === tabId ? { ...t, scrollTop } : t)),
@@ -321,7 +345,7 @@ export const useFileEditorStore = create<FileEditorState>()(
}),
{
name: STORE_NAME,
version: 1,
version: 2,
// Only persist tab session state, not transient data (git status, file tree, drag state)
partialize: (state) =>
({
@@ -338,11 +362,30 @@ export const useFileEditorStore = create<FileEditorState>()(
try {
const parsed = JSON.parse(raw) as StorageValue<PersistedFileEditorState>;
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
return {
...parsed,
state: {
...parsed.state,
tabs: normalizedTabs,
expandedFolders: new Set(parsed.state.expandedFolders ?? []),
},
} as unknown as StorageValue<FileEditorState>;
@@ -385,6 +428,22 @@ export const useFileEditorStore = create<FileEditorState>()(
state.expandedFolders = state.expandedFolders ?? new Set<string>();
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;
},
}

View File

@@ -7,6 +7,7 @@ import {
Workflow,
Database,
Terminal,
Unlink,
} from 'lucide-react';
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: 'claude', label: 'Models', icon: Workflow },
{ id: 'data', label: 'Data', icon: Database },
{ id: 'orphaned', label: 'Orphaned Features', icon: Unlink },
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
];

View File

@@ -9,6 +9,7 @@ export type ProjectSettingsViewId =
| 'commands-scripts'
| 'claude'
| 'data'
| 'orphaned'
| 'danger';
interface UseProjectSettingsViewOptions {

View File

@@ -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">
&bull; {label}
</span>
))}
</span>
</>
) : (
<>
Are you sure you want to permanently delete this feature?
<span className="block mt-2 font-medium text-foreground">
&quot;{deleteConfirm?.labels[0]}&quot;
</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>
</>
);
}

View File

@@ -8,6 +8,7 @@ import { WorktreePreferencesSection } from './worktree-preferences-section';
import { CommandsAndScriptsSection } from './commands-and-scripts-section';
import { ProjectModelsSection } from './project-models-section';
import { DataManagementSection } from './data-management-section';
import { OrphanedFeaturesSection } from './orphaned-features-section';
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
import { RemoveFromAutomakerDialog } from '../settings-view/components/remove-from-automaker-dialog';
@@ -109,6 +110,8 @@ export function ProjectSettingsView() {
return <ProjectModelsSection project={currentProject} />;
case 'data':
return <DataManagementSection project={currentProject} />;
case 'orphaned':
return <OrphanedFeaturesSection project={currentProject} />;
case 'danger':
return (
<DangerZoneSection

View File

@@ -288,12 +288,51 @@ export function useAutoMode(worktree?: WorktreeInfo) {
try {
const sessionData = readAutoModeSession();
const currentBranchName = branchNameRef.current;
const currentKey = getWorktreeSessionKey(currentProject.path, currentBranchName);
const projectPath = currentProject.path;
if (sessionData[currentKey] === true) {
setAutoModeRunning(currentProject.id, currentBranchName, true);
logger.debug(`Restored auto mode state from session storage for key: ${currentKey}`);
// Track restored worktrees to avoid redundant state updates
const restoredKeys = new Set<string>();
// 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) {
logger.error('Error restoring auto mode state from session storage:', error);

View File

@@ -521,6 +521,35 @@ export interface FeaturesAPI {
description: string,
projectPath?: 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 {
@@ -3939,6 +3968,25 @@ function createMockFeaturesAPI(): FeaturesAPI {
const title = words.length > 40 ? words.substring(0, 40) + '...' : words;
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' };
},
};
}

View File

@@ -129,12 +129,41 @@ export const isConnectionError = (error: unknown): boolean => {
};
/**
* Handle a server offline error by notifying the UI to redirect.
* Call this when a connection error is detected.
* Handle a server offline error by verifying the server is actually down
* 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 => {
// Debounce: if a check is already in progress, skip
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;
error?: string;
}> => 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

View File

@@ -97,14 +97,14 @@ export async function initializeProject(projectPath: string): Promise<ProjectIni
existingFiles.push('.git');
}
// Create all required directories
for (const dir of REQUIRED_STRUCTURE.directories) {
const fullPath = `${projectPath}/${dir}`;
await api.mkdir(fullPath);
}
// Create all required directories in parallel
await Promise.all(
REQUIRED_STRUCTURE.directories.map((dir) => api.mkdir(`${projectPath}/${dir}`))
);
// Check and create required files
for (const [relativePath, defaultContent] of Object.entries(REQUIRED_STRUCTURE.files)) {
// Check and create required files in parallel
await Promise.all(
Object.entries(REQUIRED_STRUCTURE.files).map(async ([relativePath, defaultContent]) => {
const fullPath = `${projectPath}/${relativePath}`;
const exists = await api.exists(fullPath);
@@ -114,7 +114,8 @@ export async function initializeProject(projectPath: string): Promise<ProjectIni
} else {
existingFiles.push(relativePath);
}
}
})
);
// 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;

View File

@@ -131,13 +131,21 @@ export const queryClient = new QueryClient({
if (error instanceof Error && error.message === 'Unauthorized') {
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)) {
return false;
return failureCount < 3;
}
// Retry up to 2 times for other errors (3 on mobile for flaky connections)
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
// cycle that occurs when the user switches back to the app. WebSocket
// invalidation handles real-time updates; polling handles the rest.

View File

@@ -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();
});
});
});

View 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 });
});
});

View File

@@ -8,7 +8,10 @@
import { chromium, FullConfig } from '@playwright/test';
import fs from 'fs';
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_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');
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();
cleanupLeftoverFixtureWorkerDirs();
// 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

View File

@@ -6,10 +6,14 @@
*/
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) {
cleanupLeftoverTestDirs();
cleanupLeftoverFixtureWorkerDirs();
console.log('[GlobalTeardown] Cleanup complete');
}

View File

@@ -6,10 +6,10 @@
* 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 path from 'path';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useAppStore } from '@/store/app-store';
// Mock the store

View File

@@ -1,7 +1,8 @@
/**
* Cleanup leftover E2E test artifact directories.
* Used by globalSetup (start of run) and globalTeardown (end of run) to ensure
* test/board-bg-test-*, test/edit-feature-test-*, etc. are removed.
* 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/fixtures/.worker-* (worker-isolated fixture copies) are removed
*
* Per-spec afterAll hooks clean up their own dirs, but when workers crash,
* runs are aborted, or afterAll fails, dirs can be left behind.
@@ -25,9 +26,33 @@ const TEST_DIR_PREFIXES = [
'skip-tests-toggle-test',
'manual-review-test',
'feature-backlog-test',
'agent-output-modal-responsive',
'responsive-modal-test',
'success-log-contrast',
] 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 {
const testBase = path.join(getWorkspaceRoot(), 'test');
if (!fs.existsSync(testBase)) return;

View File

@@ -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();
}
}
}

View File

@@ -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
}
}