Merge branch 'v0.11.0rc' into fix/pipeline-resume-edge-cases

This commit is contained in:
webdevcody
2026-01-12 23:49:33 -05:00
562 changed files with 65881 additions and 13321 deletions

View File

@@ -7,4 +7,4 @@ export { useBoardEffects } from './use-board-effects';
export { useBoardBackground } from './use-board-background';
export { useBoardPersistence } from './use-board-persistence';
export { useFollowUpState } from './use-follow-up-state';
export { useSuggestionsState } from './use-suggestions-state';
export { useSelectionMode } from './use-selection-mode';

View File

@@ -1,18 +1,24 @@
// @ts-nocheck
import { useCallback } from 'react';
import {
Feature,
FeatureImage,
AgentModel,
ModelAlias,
ThinkingLevel,
PlanningMode,
useAppStore,
} from '@/store/app-store';
import type { ReasoningEffort } from '@automaker/types';
import { FeatureImagePath as DescriptionImagePath } from '@/components/ui/description-image-dropzone';
import { getElectronAPI } from '@/lib/electron';
import { isConnectionError, handleServerOffline } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { truncateDescription } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('BoardActions');
interface UseBoardActionsProps {
currentProject: { path: string; id: string } | null;
@@ -20,7 +26,13 @@ interface UseBoardActionsProps {
runningAutoTasks: string[];
loadFeatures: () => Promise<void>;
persistFeatureCreate: (feature: Feature) => Promise<void>;
persistFeatureUpdate: (featureId: string, updates: Partial<Feature>) => Promise<void>;
persistFeatureUpdate: (
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) => Promise<void>;
persistFeatureDelete: (featureId: string) => Promise<void>;
saveCategory: (category: string) => Promise<void>;
setEditingFeature: (feature: Feature | null) => void;
@@ -76,6 +88,7 @@ export function useBoardActions({
moveFeature,
useWorktrees,
enableDependencyBlocking,
skipVerificationInAutoMode,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
} = useAppStore();
@@ -92,28 +105,46 @@ export function useBoardActions({
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
skipTests: boolean;
model: AgentModel;
model: ModelAlias;
thinkingLevel: ThinkingLevel;
branchName: string;
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
dependencies?: string[];
workMode?: 'current' | 'auto' | 'custom';
}) => {
// Empty string means "unassigned" (show only on primary worktree) - convert to undefined
// Non-empty string is the actual branch name (for non-primary worktrees)
const finalBranchName = featureData.branchName || undefined;
const workMode = featureData.workMode || 'current';
// If worktrees enabled and a branch is specified, create the worktree now
// This ensures the worktree exists before the feature starts
if (useWorktrees && finalBranchName && currentProject) {
// Determine final branch name based on work mode:
// - 'current': No branch name, work on current branch (no worktree)
// - 'auto': Auto-generate branch name based on current branch
// - 'custom': Use the provided branch name
let finalBranchName: string | undefined;
if (workMode === 'current') {
// No worktree isolation - work directly on current branch
finalBranchName = undefined;
} else if (workMode === 'auto') {
// Auto-generate a branch name based on current branch and timestamp
const baseBranch = currentWorktreeBranch || 'main';
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 6);
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
} else {
// Custom mode - use provided branch name
finalBranchName = featureData.branchName || undefined;
}
// Create worktree for 'auto' or 'custom' modes when we have a branch name
if ((workMode === 'auto' || workMode === 'custom') && finalBranchName && currentProject) {
try {
const api = getElectronAPI();
if (api?.worktree?.create) {
const result = await api.worktree.create(currentProject.path, finalBranchName);
if (result.success && result.worktree) {
console.log(
`[Board] Worktree for branch "${finalBranchName}" ${
logger.info(
`Worktree for branch "${finalBranchName}" ${
result.worktree?.isNew ? 'created' : 'already exists'
}`
);
@@ -125,8 +156,8 @@ export function useBoardActions({
// Refresh worktree list in UI
onWorktreeCreated?.();
} else if (!result.success) {
console.error(
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
logger.error(
`Failed to create worktree for branch "${finalBranchName}":`,
result.error
);
toast.error('Failed to create worktree', {
@@ -135,7 +166,7 @@ export function useBoardActions({
}
}
} catch (error) {
console.error('[Board] Error creating worktree:', error);
logger.error('Error creating worktree:', error);
toast.error('Failed to create worktree', {
description: error instanceof Error ? error.message : 'An error occurred',
});
@@ -180,7 +211,7 @@ export function useBoardActions({
}
})
.catch((error) => {
console.error('[Board] Error generating title:', error);
logger.error('Error generating title:', error);
// Clear generating flag on error
const titleUpdates = { titleGenerating: false };
updateFeature(createdFeature.id, titleUpdates);
@@ -195,10 +226,10 @@ export function useBoardActions({
persistFeatureUpdate,
updateFeature,
saveCategory,
useWorktrees,
currentProject,
onWorktreeCreated,
onWorktreeAutoSelect,
currentWorktreeBranch,
]
);
@@ -210,35 +241,53 @@ export function useBoardActions({
category: string;
description: string;
skipTests: boolean;
model: AgentModel;
model: ModelAlias;
thinkingLevel: ThinkingLevel;
reasoningEffort: ReasoningEffort;
imagePaths: DescriptionImagePath[];
branchName: string;
priority: number;
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
}
workMode?: 'current' | 'auto' | 'custom';
},
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) => {
const finalBranchName = updates.branchName || undefined;
const workMode = updates.workMode || 'current';
// If worktrees enabled and a branch is specified, create the worktree now
// This ensures the worktree exists before the feature starts
if (useWorktrees && finalBranchName && currentProject) {
// Determine final branch name based on work mode
let finalBranchName: string | undefined;
if (workMode === 'current') {
finalBranchName = undefined;
} else if (workMode === 'auto') {
const baseBranch = currentWorktreeBranch || 'main';
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 6);
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
} else {
finalBranchName = updates.branchName || undefined;
}
// Create worktree for 'auto' or 'custom' modes when we have a branch name
if ((workMode === 'auto' || workMode === 'custom') && finalBranchName && currentProject) {
try {
const api = getElectronAPI();
if (api?.worktree?.create) {
const result = await api.worktree.create(currentProject.path, finalBranchName);
if (result.success) {
console.log(
`[Board] Worktree for branch "${finalBranchName}" ${
logger.info(
`Worktree for branch "${finalBranchName}" ${
result.worktree?.isNew ? 'created' : 'already exists'
}`
);
// Refresh worktree list in UI
onWorktreeCreated?.();
} else {
console.error(
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
logger.error(
`Failed to create worktree for branch "${finalBranchName}":`,
result.error
);
toast.error('Failed to create worktree', {
@@ -247,7 +296,7 @@ export function useBoardActions({
}
}
} catch (error) {
console.error('[Board] Error creating worktree:', error);
logger.error('Error creating worktree:', error);
toast.error('Failed to create worktree', {
description: error instanceof Error ? error.message : 'An error occurred',
});
@@ -261,7 +310,13 @@ export function useBoardActions({
};
updateFeature(featureId, finalUpdates);
persistFeatureUpdate(featureId, finalUpdates);
persistFeatureUpdate(
featureId,
finalUpdates,
descriptionHistorySource,
enhancementMode,
preEnhancementDescription
);
if (updates.category) {
saveCategory(updates.category);
}
@@ -272,9 +327,9 @@ export function useBoardActions({
persistFeatureUpdate,
saveCategory,
setEditingFeature,
useWorktrees,
currentProject,
onWorktreeCreated,
currentWorktreeBranch,
]
);
@@ -292,7 +347,7 @@ export function useBoardActions({
description: `Stopped and deleted: ${truncateDescription(feature.description)}`,
});
} catch (error) {
console.error('[Board] Error stopping feature before delete:', error);
logger.error('Error stopping feature before delete:', error);
toast.error('Failed to stop agent', {
description: 'The feature will still be deleted.',
});
@@ -305,13 +360,13 @@ export function useBoardActions({
for (const imagePathObj of feature.imagePaths) {
try {
await api.deleteFile(imagePathObj.path);
console.log(`[Board] Deleted image: ${imagePathObj.path}`);
logger.info(`Deleted image: ${imagePathObj.path}`);
} catch (error) {
console.error(`[Board] Failed to delete image ${imagePathObj.path}:`, error);
logger.error(`Failed to delete image ${imagePathObj.path}:`, error);
}
}
} catch (error) {
console.error(`[Board] Error deleting images for feature ${featureId}:`, error);
logger.error(`Error deleting images for feature ${featureId}:`, error);
}
}
@@ -323,38 +378,31 @@ export function useBoardActions({
const handleRunFeature = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
if (!currentProject) {
throw new Error('No project selected');
}
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error('Auto mode API not available');
return;
}
const api = getElectronAPI();
if (!api?.autoMode) {
throw new Error('Auto mode API not available');
}
// Server derives workDir from feature.branchName at execution time
const result = await api.autoMode.runFeature(
currentProject.path,
feature.id,
useWorktrees
// No worktreePath - server derives from feature.branchName
);
// Server derives workDir from feature.branchName at execution time
const result = await api.autoMode.runFeature(
currentProject.path,
feature.id,
useWorktrees
// No worktreePath - server derives from feature.branchName
);
if (result.success) {
console.log(
'[Board] Feature run started successfully, branch:',
feature.branchName || 'default'
);
} else {
console.error('[Board] Failed to run feature:', result.error);
await loadFeatures();
}
} catch (error) {
console.error('[Board] Error running feature:', error);
await loadFeatures();
if (result.success) {
logger.info('Feature run started successfully, branch:', feature.branchName || 'default');
} else {
// Throw error so caller can handle rollback
throw new Error(result.error || 'Failed to start feature');
}
},
[currentProject, useWorktrees, loadFeatures]
[currentProject, useWorktrees]
);
const handleStartImplementation = useCallback(
@@ -390,11 +438,34 @@ export function useBoardActions({
startedAt: new Date().toISOString(),
};
updateFeature(feature.id, updates);
// Must await to ensure feature status is persisted before starting agent
await persistFeatureUpdate(feature.id, updates);
console.log('[Board] Feature moved to in_progress, starting agent...');
await handleRunFeature(feature);
return true;
try {
// Must await to ensure feature status is persisted before starting agent
await persistFeatureUpdate(feature.id, updates);
logger.info('Feature moved to in_progress, starting agent...');
await handleRunFeature(feature);
return true;
} catch (error) {
// Rollback to backlog if persist or run fails (e.g., server offline)
logger.error('Failed to start feature, rolling back to backlog:', error);
const rollbackUpdates = {
status: 'backlog' as const,
startedAt: undefined,
};
updateFeature(feature.id, rollbackUpdates);
// If server is offline (connection refused), redirect to login page
if (isConnectionError(error)) {
handleServerOffline();
return false;
}
toast.error('Failed to start feature', {
description:
error instanceof Error ? error.message : 'Server may be offline. Please try again.',
});
return false;
}
},
[
autoMode,
@@ -413,20 +484,20 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error('Auto mode API not available');
logger.error('Auto mode API not available');
return;
}
const result = await api.autoMode.verifyFeature(currentProject.path, feature.id);
if (result.success) {
console.log('[Board] Feature verification started successfully');
logger.info('Feature verification started successfully');
} else {
console.error('[Board] Failed to verify feature:', result.error);
logger.error('Failed to verify feature:', result.error);
await loadFeatures();
}
} catch (error) {
console.error('[Board] Error verifying feature:', error);
logger.error('Error verifying feature:', error);
await loadFeatures();
}
},
@@ -435,20 +506,20 @@ export function useBoardActions({
const handleResumeFeature = useCallback(
async (feature: Feature) => {
console.log('[Board] handleResumeFeature called for feature:', feature.id);
logger.info('handleResumeFeature called for feature:', feature.id);
if (!currentProject) {
console.error('[Board] No current project');
logger.error('No current project');
return;
}
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error('[Board] Auto mode API not available');
logger.error('Auto mode API not available');
return;
}
console.log('[Board] Calling resumeFeature API...', {
logger.info('Calling resumeFeature API...', {
projectPath: currentProject.path,
featureId: feature.id,
useWorktrees,
@@ -460,16 +531,16 @@ export function useBoardActions({
useWorktrees
);
console.log('[Board] resumeFeature result:', result);
logger.info('resumeFeature result:', result);
if (result.success) {
console.log('[Board] Feature resume started successfully');
logger.info('Feature resume started successfully');
} else {
console.error('[Board] Failed to resume feature:', result.error);
logger.error('Failed to resume feature:', result.error);
await loadFeatures();
}
} catch (error) {
console.error('[Board] Error resuming feature:', error);
logger.error('Error resuming feature:', error);
await loadFeatures();
}
},
@@ -520,10 +591,11 @@ export function useBoardActions({
const featureId = followUpFeature.id;
const featureDescription = followUpFeature.description;
const previousStatus = followUpFeature.status;
const api = getElectronAPI();
if (!api?.autoMode?.followUpFeature) {
console.error('Follow-up feature API not available');
logger.error('Follow-up feature API not available');
toast.error('Follow-up not available', {
description: 'This feature is not available in the current version.',
});
@@ -536,35 +608,53 @@ export function useBoardActions({
justFinishedAt: undefined,
};
updateFeature(featureId, updates);
persistFeatureUpdate(featureId, updates);
setShowFollowUpDialog(false);
setFollowUpFeature(null);
setFollowUpPrompt('');
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
try {
await persistFeatureUpdate(featureId, updates);
toast.success('Follow-up started', {
description: `Continuing work on: ${truncateDescription(featureDescription)}`,
});
setShowFollowUpDialog(false);
setFollowUpFeature(null);
setFollowUpPrompt('');
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
const imagePaths = followUpImagePaths.map((img) => img.path);
// Server derives workDir from feature.branchName at execution time
api.autoMode
.followUpFeature(
toast.success('Follow-up started', {
description: `Continuing work on: ${truncateDescription(featureDescription)}`,
});
const imagePaths = followUpImagePaths.map((img) => img.path);
// Server derives workDir from feature.branchName at execution time
const result = await api.autoMode.followUpFeature(
currentProject.path,
followUpFeature.id,
followUpPrompt,
imagePaths
// No worktreePath - server derives from feature.branchName
)
.catch((error) => {
console.error('[Board] Error sending follow-up:', error);
toast.error('Failed to send follow-up', {
description: error instanceof Error ? error.message : 'An error occurred',
});
loadFeatures();
);
if (!result.success) {
throw new Error(result.error || 'Failed to send follow-up');
}
} catch (error) {
// Rollback to previous status if follow-up fails
logger.error('Error sending follow-up, rolling back:', error);
const rollbackUpdates = {
status: previousStatus as 'backlog' | 'in_progress' | 'waiting_approval' | 'verified',
startedAt: undefined,
};
updateFeature(featureId, rollbackUpdates);
// If server is offline (connection refused), redirect to login page
if (isConnectionError(error)) {
handleServerOffline();
return;
}
toast.error('Failed to send follow-up', {
description:
error instanceof Error ? error.message : 'Server may be offline. Please try again.',
});
}
}, [
currentProject,
followUpFeature,
@@ -577,7 +667,6 @@ export function useBoardActions({
setFollowUpPrompt,
setFollowUpImagePaths,
setFollowUpPreviewMap,
loadFeatures,
]);
const handleCommitFeature = useCallback(
@@ -587,7 +676,7 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.autoMode?.commitFeature) {
console.error('Commit feature API not available');
logger.error('Commit feature API not available');
toast.error('Commit not available', {
description: 'This feature is not available in the current version.',
});
@@ -610,14 +699,14 @@ export function useBoardActions({
// Refresh worktree selector to update commit counts
onWorktreeCreated?.();
} else {
console.error('[Board] Failed to commit feature:', result.error);
logger.error('Failed to commit feature:', result.error);
toast.error('Failed to commit feature', {
description: result.error || 'An error occurred',
});
await loadFeatures();
}
} catch (error) {
console.error('[Board] Error committing feature:', error);
logger.error('Error committing feature:', error);
toast.error('Failed to commit feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
@@ -634,7 +723,7 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.worktree?.mergeFeature) {
console.error('Worktree API not available');
logger.error('Worktree API not available');
toast.error('Merge not available', {
description: 'This feature is not available in the current version.',
});
@@ -651,13 +740,13 @@ export function useBoardActions({
)}`,
});
} else {
console.error('[Board] Failed to merge feature:', result.error);
logger.error('Failed to merge feature:', result.error);
toast.error('Failed to merge feature', {
description: result.error || 'An error occurred',
});
}
} catch (error) {
console.error('[Board] Error merging feature:', error);
logger.error('Error merging feature:', error);
toast.error('Failed to merge feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
@@ -747,7 +836,7 @@ export function useBoardActions({
: `Stopped working on: ${truncateDescription(feature.description)}`,
});
} catch (error) {
console.error('[Board] Error stopping feature:', error);
logger.error('Error stopping feature:', error);
toast.error('Failed to stop agent', {
description: error instanceof Error ? error.message : 'An error occurred',
});
@@ -805,12 +894,14 @@ export function useBoardActions({
// Sort by priority (lower number = higher priority, priority 1 is highest)
// Features with blocking dependencies are sorted to the end
const sortedBacklog = [...backlogFeatures].sort((a, b) => {
const aBlocked = enableDependencyBlocking
? getBlockingDependencies(a, features).length > 0
: false;
const bBlocked = enableDependencyBlocking
? getBlockingDependencies(b, features).length > 0
: false;
const aBlocked =
enableDependencyBlocking && !skipVerificationInAutoMode
? getBlockingDependencies(a, features).length > 0
: false;
const bBlocked =
enableDependencyBlocking && !skipVerificationInAutoMode
? getBlockingDependencies(b, features).length > 0
: false;
// Blocked features go to the end
if (aBlocked && !bBlocked) return 1;
@@ -822,14 +913,14 @@ export function useBoardActions({
// Find the first feature without blocking dependencies
const featureToStart = sortedBacklog.find((f) => {
if (!enableDependencyBlocking) return true;
if (!enableDependencyBlocking || skipVerificationInAutoMode) return true;
return getBlockingDependencies(f, features).length === 0;
});
if (!featureToStart) {
toast.info('No eligible features', {
description:
'All backlog features have unmet dependencies. Complete their dependencies first.',
'All backlog features have unmet dependencies. Complete their dependencies first (or enable "Skip verification requirement" in Auto Mode settings).',
});
return;
}
@@ -846,6 +937,7 @@ export function useBoardActions({
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
enableDependencyBlocking,
skipVerificationInAutoMode,
]);
const handleArchiveAllVerified = useCallback(async () => {
@@ -857,7 +949,7 @@ export function useBoardActions({
try {
await autoMode.stopFeature(feature.id);
} catch (error) {
console.error('[Board] Error stopping feature before archive:', error);
logger.error('Error stopping feature before archive:', error);
}
}
// Archive the feature by setting status to completed

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
import { getServerUrlSync } from '@/lib/http-api-client';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
interface UseBoardBackgroundProps {
currentProject: { path: string; id: string } | null;
@@ -22,14 +22,14 @@ export function useBoardBackground({ currentProject }: UseBoardBackgroundProps)
return {};
}
const imageUrl = getAuthenticatedImageUrl(
backgroundSettings.imagePath,
currentProject.path,
backgroundSettings.imageVersion
);
return {
backgroundImage: `url(${
import.meta.env.VITE_SERVER_URL || getServerUrlSync()
}/api/fs/image?path=${encodeURIComponent(
backgroundSettings.imagePath
)}&projectPath=${encodeURIComponent(currentProject.path)}${
backgroundSettings.imageVersion ? `&v=${backgroundSettings.imageVersion}` : ''
})`,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { useMemo, useCallback } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { resolveDependencies, getBlockingDependencies } from '@automaker/dependency-resolver';
@@ -69,9 +70,21 @@ export function useBoardColumnFeatures({
// We're viewing main but branch hasn't been initialized yet
// (worktrees disabled or haven't loaded yet).
// Show features assigned to primary worktree's branch.
matchesWorktree = projectPath
? useAppStore.getState().isPrimaryWorktreeBranch(projectPath, featureBranch)
: false;
if (projectPath) {
const worktrees = useAppStore.getState().worktreesByProject[projectPath] ?? [];
if (worktrees.length === 0) {
// Worktrees not loaded yet - fallback to showing features on common default branches
// This prevents features from disappearing during initial load
matchesWorktree =
featureBranch === 'main' || featureBranch === 'master' || featureBranch === 'develop';
} else {
matchesWorktree = useAppStore
.getState()
.isPrimaryWorktreeBranch(projectPath, featureBranch);
}
} else {
matchesWorktree = false;
}
} else {
// Match by branch name
matchesWorktree = featureBranch === effectiveBranch;

View File

@@ -1,10 +1,13 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
import { Feature } from '@/store/app-store';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { COLUMNS, ColumnId } from '../constants';
const logger = createLogger('BoardDragDrop');
interface UseBoardDragDropProps {
features: Feature[];
currentProject: { path: string; id: string } | null;
@@ -63,7 +66,7 @@ export function useBoardDragDrop({
if (draggedFeature.status === 'in_progress') {
// Only allow dragging in_progress if it's not currently running
if (isRunningTask) {
console.log('[Board] Cannot drag feature - currently running');
logger.debug('Cannot drag feature - currently running');
return;
}
}

View File

@@ -1,14 +1,14 @@
import { useEffect, useRef } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('BoardEffects');
interface UseBoardEffectsProps {
currentProject: { path: string; id: string } | null;
specCreatingForProject: string | null;
setSpecCreatingForProject: (path: string | null) => void;
setSuggestionsCount: (count: number) => void;
setFeatureSuggestions: (suggestions: any[]) => void;
setIsGeneratingSuggestions: (generating: boolean) => void;
checkContextExists: (featureId: string) => Promise<boolean>;
features: any[];
isLoading: boolean;
@@ -20,9 +20,6 @@ export function useBoardEffects({
currentProject,
specCreatingForProject,
setSpecCreatingForProject,
setSuggestionsCount,
setFeatureSuggestions,
setIsGeneratingSuggestions,
checkContextExists,
features,
isLoading,
@@ -44,38 +41,13 @@ export function useBoardEffects({
};
}, [currentProject]);
// Listen for suggestions events to update count (persists even when dialog is closed)
useEffect(() => {
const api = getElectronAPI();
if (!api?.suggestions) return;
const unsubscribe = api.suggestions.onEvent((event) => {
if (event.type === 'suggestions_complete' && event.suggestions) {
setSuggestionsCount(event.suggestions.length);
setFeatureSuggestions(event.suggestions);
setIsGeneratingSuggestions(false);
} else if (event.type === 'suggestions_error') {
setIsGeneratingSuggestions(false);
}
});
return () => {
unsubscribe();
};
}, [setSuggestionsCount, setFeatureSuggestions, setIsGeneratingSuggestions]);
// Subscribe to spec regeneration events to clear creating state on completion
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event) => {
console.log(
'[BoardView] Spec regeneration event:',
event.type,
'for project:',
event.projectPath
);
logger.info('Spec regeneration event:', event.type, 'for project:', event.projectPath);
if (event.projectPath !== specCreatingForProject) {
return;
@@ -108,7 +80,7 @@ export function useBoardEffects({
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
if (status.runningFeatures) {
console.log('[Board] Syncing running tasks from backend:', status.runningFeatures);
logger.info('Syncing running tasks from backend:', status.runningFeatures);
clearRunningTasks(projectId);
@@ -118,7 +90,7 @@ export function useBoardEffects({
}
}
} catch (error) {
console.error('[Board] Failed to sync running tasks:', error);
logger.error('Failed to sync running tasks:', error);
}
};

View File

@@ -2,6 +2,9 @@ import { useState, useCallback, useEffect, useRef } from 'react';
import { useAppStore, Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('BoardFeatures');
interface UseBoardFeaturesProps {
currentProject: { path: string; id: string } | null;
@@ -32,7 +35,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// If project switched, mark it but don't clear features yet
// We'll clear after successful API load to prevent data loss
if (isProjectSwitch) {
console.log(`[BoardView] Project switch detected: ${previousPath} -> ${currentPath}`);
logger.info(`Project switch detected: ${previousPath} -> ${currentPath}`);
isSwitchingProjectRef.current = true;
isInitialLoadRef.current = true;
}
@@ -48,7 +51,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
try {
const api = getElectronAPI();
if (!api.features) {
console.error('[BoardView] Features API not available');
logger.error('Features API not available');
// Keep cached features if API is unavailable
return;
}
@@ -72,8 +75,19 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
if (isProjectSwitch) {
setPersistedCategories([]);
}
// Check for interrupted features and resume them
// This handles server restarts where features were in pipeline steps
if (api.autoMode?.resumeInterrupted) {
try {
await api.autoMode.resumeInterrupted(currentProject.path);
logger.info('Checked for interrupted features');
} catch (resumeError) {
logger.warn('Failed to check for interrupted features:', resumeError);
}
}
} else if (!result.success && result.error) {
console.error('[BoardView] API returned error:', result.error);
logger.error('API returned error:', result.error);
// If it's a new project or the error indicates no features found,
// that's expected - start with empty array
if (isProjectSwitch) {
@@ -83,7 +97,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// Otherwise keep cached features
}
} catch (error) {
console.error('Failed to load features:', error);
logger.error('Failed to load features:', error);
// On error, keep existing cached features for the current project
// Only clear on project switch if we have no features from server
if (isProjectSwitch && cachedFeatures.length === 0) {
@@ -115,7 +129,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories([]);
}
} catch (error) {
console.error('Failed to load categories:', error);
logger.error('Failed to load categories:', error);
// If file doesn't exist, ensure categories are cleared
setPersistedCategories([]);
}
@@ -147,7 +161,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories(categories);
}
} catch (error) {
console.error('Failed to save category:', error);
logger.error('Failed to save category:', error);
}
},
[currentProject, persistedCategories]
@@ -165,7 +179,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
currentProject &&
event.projectPath === currentProject.path
) {
console.log('[BoardView] Spec regeneration complete, refreshing features');
logger.info('Spec regeneration complete, refreshing features');
loadFeatures();
}
});
@@ -190,27 +204,27 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
if (event.type === 'auto_mode_feature_complete') {
// Reload features when a feature is completed
console.log('[Board] Feature completed, reloading features...');
logger.info('Feature completed, reloading features...');
loadFeatures();
// Play ding sound when feature is done (unless muted)
const { muteDoneSound } = useAppStore.getState();
if (!muteDoneSound) {
const audio = new Audio('/sounds/ding.mp3');
audio.play().catch((err) => console.warn('Could not play ding sound:', err));
audio.play().catch((err) => logger.warn('Could not play ding sound:', err));
}
} else if (event.type === 'plan_approval_required') {
// Reload features when plan is generated and requires approval
// This ensures the feature card shows the "Approve Plan" button
console.log('[Board] Plan approval required, reloading features...');
logger.info('Plan approval required, reloading features...');
loadFeatures();
} else if (event.type === 'pipeline_step_started') {
// Pipeline steps update the feature status to `pipeline_*` before the step runs.
// Reload so the card moves into the correct pipeline column immediately.
console.log('[Board] Pipeline step started, reloading features...');
logger.info('Pipeline step started, reloading features...');
loadFeatures();
} else if (event.type === 'auto_mode_error') {
// Reload features when an error occurs (feature moved to waiting_approval)
console.log('[Board] Feature error, reloading features...', event.error);
logger.info('Feature error, reloading features...', event.error);
// Remove from running tasks so it moves to the correct column
if (event.featureId) {

View File

@@ -2,6 +2,9 @@ import { useCallback } from 'react';
import { Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('BoardPersistence');
interface UseBoardPersistenceProps {
currentProject: { path: string; id: string } | null;
@@ -12,22 +15,35 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
// Persist feature update to API (replaces saveFeatures)
const persistFeatureUpdate = useCallback(
async (featureId: string, updates: Partial<Feature>) => {
async (
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api.features) {
console.error('[BoardView] Features API not available');
logger.error('Features API not available');
return;
}
const result = await api.features.update(currentProject.path, featureId, updates);
const result = await api.features.update(
currentProject.path,
featureId,
updates,
descriptionHistorySource,
enhancementMode,
preEnhancementDescription
);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);
}
} catch (error) {
console.error('Failed to persist feature update:', error);
logger.error('Failed to persist feature update:', error);
}
},
[currentProject, updateFeature]
@@ -41,7 +57,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
try {
const api = getElectronAPI();
if (!api.features) {
console.error('[BoardView] Features API not available');
logger.error('Features API not available');
return;
}
@@ -50,7 +66,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
updateFeature(result.feature.id, result.feature);
}
} catch (error) {
console.error('Failed to persist feature creation:', error);
logger.error('Failed to persist feature creation:', error);
}
},
[currentProject, updateFeature]
@@ -64,13 +80,13 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
try {
const api = getElectronAPI();
if (!api.features) {
console.error('[BoardView] Features API not available');
logger.error('Features API not available');
return;
}
await api.features.delete(currentProject.path, featureId);
} catch (error) {
console.error('Failed to persist feature deletion:', error);
logger.error('Failed to persist feature deletion:', error);
}
},
[currentProject]

View File

@@ -4,13 +4,18 @@ import {
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import type { FollowUpHistoryEntry } from '../dialogs/follow-up-dialog';
/**
* Custom hook for managing follow-up dialog state including prompt history
*/
export function useFollowUpState() {
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
const [followUpPrompt, setFollowUpPrompt] = useState('');
const [followUpImagePaths, setFollowUpImagePaths] = useState<DescriptionImagePath[]>([]);
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(() => new Map());
const [followUpPromptHistory, setFollowUpPromptHistory] = useState<FollowUpHistoryEntry[]>([]);
const resetFollowUpState = useCallback(() => {
setShowFollowUpDialog(false);
@@ -18,6 +23,7 @@ export function useFollowUpState() {
setFollowUpPrompt('');
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
setFollowUpPromptHistory([]);
}, []);
const handleFollowUpDialogChange = useCallback(
@@ -31,6 +37,13 @@ export function useFollowUpState() {
[resetFollowUpState]
);
/**
* Adds a new entry to the prompt history
*/
const addToPromptHistory = useCallback((entry: FollowUpHistoryEntry) => {
setFollowUpPromptHistory((prev) => [...prev, entry]);
}, []);
return {
// State
showFollowUpDialog,
@@ -38,14 +51,17 @@ export function useFollowUpState() {
followUpPrompt,
followUpImagePaths,
followUpPreviewMap,
followUpPromptHistory,
// Setters
setShowFollowUpDialog,
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setFollowUpPreviewMap,
setFollowUpPromptHistory,
// Helpers
resetFollowUpState,
handleFollowUpDialogChange,
addToPromptHistory,
};
}

View File

@@ -0,0 +1,82 @@
import { useState, useCallback, useEffect } from 'react';
interface UseSelectionModeReturn {
isSelectionMode: boolean;
selectedFeatureIds: Set<string>;
selectedCount: number;
toggleSelectionMode: () => void;
toggleFeatureSelection: (featureId: string) => void;
selectAll: (featureIds: string[]) => void;
clearSelection: () => void;
isFeatureSelected: (featureId: string) => boolean;
exitSelectionMode: () => void;
}
export function useSelectionMode(): UseSelectionModeReturn {
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedFeatureIds, setSelectedFeatureIds] = useState<Set<string>>(new Set());
const toggleSelectionMode = useCallback(() => {
setIsSelectionMode((prev) => {
if (prev) {
// Exiting selection mode - clear selection
setSelectedFeatureIds(new Set());
}
return !prev;
});
}, []);
const exitSelectionMode = useCallback(() => {
setIsSelectionMode(false);
setSelectedFeatureIds(new Set());
}, []);
const toggleFeatureSelection = useCallback((featureId: string) => {
setSelectedFeatureIds((prev) => {
const next = new Set(prev);
if (next.has(featureId)) {
next.delete(featureId);
} else {
next.add(featureId);
}
return next;
});
}, []);
const selectAll = useCallback((featureIds: string[]) => {
setSelectedFeatureIds(new Set(featureIds));
}, []);
const clearSelection = useCallback(() => {
setSelectedFeatureIds(new Set());
}, []);
const isFeatureSelected = useCallback(
(featureId: string) => selectedFeatureIds.has(featureId),
[selectedFeatureIds]
);
// Handle Escape key to exit selection mode
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isSelectionMode) {
exitSelectionMode();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isSelectionMode, exitSelectionMode]);
return {
isSelectionMode,
selectedFeatureIds,
selectedCount: selectedFeatureIds.size,
toggleSelectionMode,
toggleFeatureSelection,
selectAll,
clearSelection,
isFeatureSelected,
exitSelectionMode,
};
}

View File

@@ -1,34 +0,0 @@
import { useState, useCallback } from 'react';
import type { FeatureSuggestion } from '@/lib/electron';
export function useSuggestionsState() {
const [showSuggestionsDialog, setShowSuggestionsDialog] = useState(false);
const [suggestionsCount, setSuggestionsCount] = useState(0);
const [featureSuggestions, setFeatureSuggestions] = useState<FeatureSuggestion[]>([]);
const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false);
const updateSuggestions = useCallback((suggestions: FeatureSuggestion[]) => {
setFeatureSuggestions(suggestions);
setSuggestionsCount(suggestions.length);
}, []);
const closeSuggestionsDialog = useCallback(() => {
setShowSuggestionsDialog(false);
}, []);
return {
// State
showSuggestionsDialog,
suggestionsCount,
featureSuggestions,
isGeneratingSuggestions,
// Setters
setShowSuggestionsDialog,
setSuggestionsCount,
setFeatureSuggestions,
setIsGeneratingSuggestions,
// Helpers
updateSuggestions,
closeSuggestionsDialog,
};
}