mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
901 lines
28 KiB
TypeScript
901 lines
28 KiB
TypeScript
import { useCallback } from "react";
|
|
import {
|
|
Feature,
|
|
FeatureImage,
|
|
AgentModel,
|
|
ThinkingLevel,
|
|
PlanningMode,
|
|
useAppStore,
|
|
} from "@/store/app-store";
|
|
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
|
|
import { getElectronAPI } from "@/lib/electron";
|
|
import { toast } from "sonner";
|
|
import { useAutoMode } from "@/hooks/use-auto-mode";
|
|
import { truncateDescription } from "@/lib/utils";
|
|
import { getBlockingDependencies } from "@/lib/dependency-resolver";
|
|
|
|
interface UseBoardActionsProps {
|
|
currentProject: { path: string; id: string } | null;
|
|
features: Feature[];
|
|
runningAutoTasks: string[];
|
|
loadFeatures: () => Promise<void>;
|
|
persistFeatureCreate: (feature: Feature) => Promise<void>;
|
|
persistFeatureUpdate: (
|
|
featureId: string,
|
|
updates: Partial<Feature>
|
|
) => Promise<void>;
|
|
persistFeatureDelete: (featureId: string) => Promise<void>;
|
|
saveCategory: (category: string) => Promise<void>;
|
|
setEditingFeature: (feature: Feature | null) => void;
|
|
setShowOutputModal: (show: boolean) => void;
|
|
setOutputFeature: (feature: Feature | null) => void;
|
|
followUpFeature: Feature | null;
|
|
followUpPrompt: string;
|
|
followUpImagePaths: DescriptionImagePath[];
|
|
setFollowUpFeature: (feature: Feature | null) => void;
|
|
setFollowUpPrompt: (prompt: string) => void;
|
|
setFollowUpImagePaths: (paths: DescriptionImagePath[]) => void;
|
|
setFollowUpPreviewMap: (map: Map<string, string>) => void;
|
|
setShowFollowUpDialog: (show: boolean) => void;
|
|
inProgressFeaturesForShortcuts: Feature[];
|
|
outputFeature: Feature | null;
|
|
projectPath: string | null;
|
|
onWorktreeCreated?: () => void;
|
|
currentWorktreeBranch: string | null; // Branch name of the selected worktree for filtering
|
|
}
|
|
|
|
export function useBoardActions({
|
|
currentProject,
|
|
features,
|
|
runningAutoTasks,
|
|
loadFeatures,
|
|
persistFeatureCreate,
|
|
persistFeatureUpdate,
|
|
persistFeatureDelete,
|
|
saveCategory,
|
|
setEditingFeature,
|
|
setShowOutputModal,
|
|
setOutputFeature,
|
|
followUpFeature,
|
|
followUpPrompt,
|
|
followUpImagePaths,
|
|
setFollowUpFeature,
|
|
setFollowUpPrompt,
|
|
setFollowUpImagePaths,
|
|
setFollowUpPreviewMap,
|
|
setShowFollowUpDialog,
|
|
inProgressFeaturesForShortcuts,
|
|
outputFeature,
|
|
projectPath,
|
|
onWorktreeCreated,
|
|
currentWorktreeBranch,
|
|
}: UseBoardActionsProps) {
|
|
const {
|
|
addFeature,
|
|
updateFeature,
|
|
removeFeature,
|
|
moveFeature,
|
|
useWorktrees,
|
|
enableDependencyBlocking,
|
|
} = useAppStore();
|
|
const autoMode = useAutoMode();
|
|
|
|
/**
|
|
* Get or create the worktree path for a feature based on its branchName.
|
|
* - If branchName is "main" or empty, returns the project path
|
|
* - Otherwise, creates a worktree for that branch if needed
|
|
*/
|
|
const getOrCreateWorktreeForFeature = useCallback(
|
|
async (feature: Feature): Promise<string | null> => {
|
|
if (!projectPath) return null;
|
|
|
|
const branchName = feature.branchName || "main";
|
|
|
|
// If targeting main branch, use the project path directly
|
|
if (branchName === "main" || branchName === "master") {
|
|
return projectPath;
|
|
}
|
|
|
|
// For other branches, create a worktree if it doesn't exist
|
|
try {
|
|
const api = getElectronAPI();
|
|
if (!api?.worktree?.create) {
|
|
console.error("[BoardActions] Worktree API not available");
|
|
return projectPath;
|
|
}
|
|
|
|
// Try to create the worktree (will return existing if already exists)
|
|
const result = await api.worktree.create(projectPath, branchName);
|
|
|
|
if (result.success && result.worktree) {
|
|
console.log(
|
|
`[BoardActions] Worktree ready for branch "${branchName}": ${result.worktree.path}`
|
|
);
|
|
if (result.worktree.isNew) {
|
|
toast.success(`Worktree created for branch "${branchName}"`, {
|
|
description: "A new worktree was created for this feature.",
|
|
});
|
|
}
|
|
return result.worktree.path;
|
|
} else {
|
|
console.error(
|
|
"[BoardActions] Failed to create worktree:",
|
|
result.error
|
|
);
|
|
toast.error("Failed to create worktree", {
|
|
description:
|
|
result.error || "Could not create worktree for this branch.",
|
|
});
|
|
return projectPath; // Fall back to project path
|
|
}
|
|
} catch (error) {
|
|
console.error("[BoardActions] Error creating worktree:", error);
|
|
toast.error("Error creating worktree", {
|
|
description: error instanceof Error ? error.message : "Unknown error",
|
|
});
|
|
return projectPath; // Fall back to project path
|
|
}
|
|
},
|
|
[projectPath]
|
|
);
|
|
|
|
const handleAddFeature = useCallback(
|
|
async (featureData: {
|
|
category: string;
|
|
description: string;
|
|
steps: string[];
|
|
images: FeatureImage[];
|
|
imagePaths: DescriptionImagePath[];
|
|
skipTests: boolean;
|
|
model: AgentModel;
|
|
thinkingLevel: ThinkingLevel;
|
|
branchName: string;
|
|
priority: number;
|
|
planningMode: PlanningMode;
|
|
requirePlanApproval: boolean;
|
|
}) => {
|
|
let worktreePath: string | undefined;
|
|
|
|
// If worktrees are enabled and a non-main branch is selected, create the worktree
|
|
if (useWorktrees && featureData.branchName) {
|
|
const branchName = featureData.branchName;
|
|
if (branchName !== "main" && branchName !== "master") {
|
|
// Create a temporary feature-like object for getOrCreateWorktreeForFeature
|
|
const tempFeature = { branchName } as Feature;
|
|
const path = await getOrCreateWorktreeForFeature(tempFeature);
|
|
if (path && path !== projectPath) {
|
|
worktreePath = path;
|
|
// Refresh worktree selector after creating worktree
|
|
onWorktreeCreated?.();
|
|
}
|
|
}
|
|
}
|
|
|
|
const newFeatureData = {
|
|
...featureData,
|
|
status: "backlog" as const,
|
|
worktreePath,
|
|
};
|
|
const createdFeature = addFeature(newFeatureData);
|
|
// Must await to ensure feature exists on server before user can drag it
|
|
await persistFeatureCreate(createdFeature);
|
|
saveCategory(featureData.category);
|
|
},
|
|
[addFeature, persistFeatureCreate, saveCategory, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
|
|
);
|
|
|
|
const handleUpdateFeature = useCallback(
|
|
async (
|
|
featureId: string,
|
|
updates: {
|
|
category: string;
|
|
description: string;
|
|
steps: string[];
|
|
skipTests: boolean;
|
|
model: AgentModel;
|
|
thinkingLevel: ThinkingLevel;
|
|
imagePaths: DescriptionImagePath[];
|
|
branchName: string;
|
|
priority: number;
|
|
planningMode?: PlanningMode;
|
|
requirePlanApproval?: boolean;
|
|
}
|
|
) => {
|
|
// Get the current feature to check if branch is changing
|
|
const currentFeature = features.find((f) => f.id === featureId);
|
|
const currentBranch = currentFeature?.branchName || "main";
|
|
const newBranch = updates.branchName || "main";
|
|
const branchIsChanging = currentBranch !== newBranch;
|
|
|
|
let worktreePath: string | undefined;
|
|
let shouldClearWorktreePath = false;
|
|
|
|
// If worktrees are enabled and branch is changing to a non-main branch, create worktree
|
|
if (useWorktrees && branchIsChanging) {
|
|
if (newBranch === "main" || newBranch === "master") {
|
|
// Changing to main - clear the worktreePath
|
|
shouldClearWorktreePath = true;
|
|
} else {
|
|
// Changing to a feature branch - create worktree if needed
|
|
const tempFeature = { branchName: newBranch } as Feature;
|
|
const path = await getOrCreateWorktreeForFeature(tempFeature);
|
|
if (path && path !== projectPath) {
|
|
worktreePath = path;
|
|
// Refresh worktree selector after creating worktree
|
|
onWorktreeCreated?.();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build final updates with worktreePath if it was changed
|
|
let finalUpdates: typeof updates & { worktreePath?: string };
|
|
if (branchIsChanging && useWorktrees) {
|
|
if (shouldClearWorktreePath) {
|
|
// Use null to clear the value in persistence (cast to work around type system)
|
|
finalUpdates = { ...updates, worktreePath: null as unknown as string | undefined };
|
|
} else {
|
|
finalUpdates = { ...updates, worktreePath };
|
|
}
|
|
} else {
|
|
finalUpdates = updates;
|
|
}
|
|
|
|
updateFeature(featureId, finalUpdates);
|
|
persistFeatureUpdate(featureId, finalUpdates);
|
|
if (updates.category) {
|
|
saveCategory(updates.category);
|
|
}
|
|
setEditingFeature(null);
|
|
},
|
|
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, features, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
|
|
);
|
|
|
|
const handleDeleteFeature = useCallback(
|
|
async (featureId: string) => {
|
|
const feature = features.find((f) => f.id === featureId);
|
|
if (!feature) return;
|
|
|
|
const isRunning = runningAutoTasks.includes(featureId);
|
|
|
|
if (isRunning) {
|
|
try {
|
|
await autoMode.stopFeature(featureId);
|
|
toast.success("Agent stopped", {
|
|
description: `Stopped and deleted: ${truncateDescription(
|
|
feature.description
|
|
)}`,
|
|
});
|
|
} catch (error) {
|
|
console.error("[Board] Error stopping feature before delete:", error);
|
|
toast.error("Failed to stop agent", {
|
|
description: "The feature will still be deleted.",
|
|
});
|
|
}
|
|
}
|
|
|
|
if (feature.imagePaths && feature.imagePaths.length > 0) {
|
|
try {
|
|
const api = getElectronAPI();
|
|
for (const imagePathObj of feature.imagePaths) {
|
|
try {
|
|
await api.deleteFile(imagePathObj.path);
|
|
console.log(`[Board] Deleted image: ${imagePathObj.path}`);
|
|
} catch (error) {
|
|
console.error(
|
|
`[Board] Failed to delete image ${imagePathObj.path}:`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
`[Board] Error deleting images for feature ${featureId}:`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
removeFeature(featureId);
|
|
persistFeatureDelete(featureId);
|
|
},
|
|
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]
|
|
);
|
|
|
|
const handleRunFeature = useCallback(
|
|
async (feature: Feature) => {
|
|
if (!currentProject) return;
|
|
|
|
try {
|
|
const api = getElectronAPI();
|
|
if (!api?.autoMode) {
|
|
console.error("Auto mode API not available");
|
|
return;
|
|
}
|
|
|
|
// Use the feature's assigned worktreePath (set when moving to in_progress)
|
|
// This ensures work happens in the correct worktree based on the feature's branchName
|
|
const featureWorktreePath = feature.worktreePath;
|
|
|
|
const result = await api.autoMode.runFeature(
|
|
currentProject.path,
|
|
feature.id,
|
|
useWorktrees,
|
|
featureWorktreePath || undefined
|
|
);
|
|
|
|
if (result.success) {
|
|
console.log(
|
|
"[Board] Feature run started successfully in worktree:",
|
|
featureWorktreePath || "main"
|
|
);
|
|
} else {
|
|
console.error("[Board] Failed to run feature:", result.error);
|
|
await loadFeatures();
|
|
}
|
|
} catch (error) {
|
|
console.error("[Board] Error running feature:", error);
|
|
await loadFeatures();
|
|
}
|
|
},
|
|
[currentProject, useWorktrees, loadFeatures]
|
|
);
|
|
|
|
const handleStartImplementation = useCallback(
|
|
async (feature: Feature) => {
|
|
if (!autoMode.canStartNewTask) {
|
|
toast.error("Concurrency limit reached", {
|
|
description: `You can only have ${autoMode.maxConcurrency} task${
|
|
autoMode.maxConcurrency > 1 ? "s" : ""
|
|
} running at a time. Wait for a task to complete or increase the limit.`,
|
|
});
|
|
return false;
|
|
}
|
|
|
|
// Check for blocking dependencies and show warning if enabled
|
|
if (enableDependencyBlocking) {
|
|
const blockingDeps = getBlockingDependencies(feature, features);
|
|
if (blockingDeps.length > 0) {
|
|
const depDescriptions = blockingDeps.map(depId => {
|
|
const dep = features.find(f => f.id === depId);
|
|
return dep ? truncateDescription(dep.description, 40) : depId;
|
|
}).join(", ");
|
|
|
|
toast.warning("Starting feature with incomplete dependencies", {
|
|
description: `This feature depends on: ${depDescriptions}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const updates = {
|
|
status: "in_progress" as const,
|
|
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;
|
|
},
|
|
[autoMode, enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
|
|
);
|
|
|
|
const handleVerifyFeature = useCallback(
|
|
async (feature: Feature) => {
|
|
if (!currentProject) return;
|
|
|
|
try {
|
|
const api = getElectronAPI();
|
|
if (!api?.autoMode) {
|
|
console.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");
|
|
} else {
|
|
console.error("[Board] Failed to verify feature:", result.error);
|
|
await loadFeatures();
|
|
}
|
|
} catch (error) {
|
|
console.error("[Board] Error verifying feature:", error);
|
|
await loadFeatures();
|
|
}
|
|
},
|
|
[currentProject, loadFeatures]
|
|
);
|
|
|
|
const handleResumeFeature = useCallback(
|
|
async (feature: Feature) => {
|
|
if (!currentProject) return;
|
|
|
|
try {
|
|
const api = getElectronAPI();
|
|
if (!api?.autoMode) {
|
|
console.error("Auto mode API not available");
|
|
return;
|
|
}
|
|
|
|
const result = await api.autoMode.resumeFeature(
|
|
currentProject.path,
|
|
feature.id,
|
|
useWorktrees
|
|
);
|
|
|
|
if (result.success) {
|
|
console.log("[Board] Feature resume started successfully");
|
|
} else {
|
|
console.error("[Board] Failed to resume feature:", result.error);
|
|
await loadFeatures();
|
|
}
|
|
} catch (error) {
|
|
console.error("[Board] Error resuming feature:", error);
|
|
await loadFeatures();
|
|
}
|
|
},
|
|
[currentProject, loadFeatures, useWorktrees]
|
|
);
|
|
|
|
const handleManualVerify = useCallback(
|
|
(feature: Feature) => {
|
|
moveFeature(feature.id, "verified");
|
|
persistFeatureUpdate(feature.id, {
|
|
status: "verified",
|
|
justFinishedAt: undefined,
|
|
});
|
|
toast.success("Feature verified", {
|
|
description: `Marked as verified: ${truncateDescription(
|
|
feature.description
|
|
)}`,
|
|
});
|
|
},
|
|
[moveFeature, persistFeatureUpdate]
|
|
);
|
|
|
|
const handleMoveBackToInProgress = useCallback(
|
|
(feature: Feature) => {
|
|
const updates = {
|
|
status: "in_progress" as const,
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
updateFeature(feature.id, updates);
|
|
persistFeatureUpdate(feature.id, updates);
|
|
toast.info("Feature moved back", {
|
|
description: `Moved back to In Progress: ${truncateDescription(
|
|
feature.description
|
|
)}`,
|
|
});
|
|
},
|
|
[updateFeature, persistFeatureUpdate]
|
|
);
|
|
|
|
const handleOpenFollowUp = useCallback(
|
|
(feature: Feature) => {
|
|
setFollowUpFeature(feature);
|
|
setFollowUpPrompt("");
|
|
setFollowUpImagePaths([]);
|
|
setShowFollowUpDialog(true);
|
|
},
|
|
[
|
|
setFollowUpFeature,
|
|
setFollowUpPrompt,
|
|
setFollowUpImagePaths,
|
|
setShowFollowUpDialog,
|
|
]
|
|
);
|
|
|
|
const handleSendFollowUp = useCallback(async () => {
|
|
if (!currentProject || !followUpFeature || !followUpPrompt.trim()) return;
|
|
|
|
const featureId = followUpFeature.id;
|
|
const featureDescription = followUpFeature.description;
|
|
const prompt = followUpPrompt;
|
|
|
|
const api = getElectronAPI();
|
|
if (!api?.autoMode?.followUpFeature) {
|
|
console.error("Follow-up feature API not available");
|
|
toast.error("Follow-up not available", {
|
|
description: "This feature is not available in the current version.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const updates = {
|
|
status: "in_progress" as const,
|
|
startedAt: new Date().toISOString(),
|
|
justFinishedAt: undefined,
|
|
};
|
|
updateFeature(featureId, updates);
|
|
persistFeatureUpdate(featureId, updates);
|
|
|
|
setShowFollowUpDialog(false);
|
|
setFollowUpFeature(null);
|
|
setFollowUpPrompt("");
|
|
setFollowUpImagePaths([]);
|
|
setFollowUpPreviewMap(new Map());
|
|
|
|
toast.success("Follow-up started", {
|
|
description: `Continuing work on: ${truncateDescription(
|
|
featureDescription
|
|
)}`,
|
|
});
|
|
|
|
const imagePaths = followUpImagePaths.map((img) => img.path);
|
|
// Use the feature's worktreePath to ensure work happens in the correct branch
|
|
const featureWorktreePath = followUpFeature.worktreePath;
|
|
api.autoMode
|
|
.followUpFeature(
|
|
currentProject.path,
|
|
followUpFeature.id,
|
|
followUpPrompt,
|
|
imagePaths,
|
|
featureWorktreePath
|
|
)
|
|
.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();
|
|
});
|
|
}, [
|
|
currentProject,
|
|
followUpFeature,
|
|
followUpPrompt,
|
|
followUpImagePaths,
|
|
updateFeature,
|
|
persistFeatureUpdate,
|
|
setShowFollowUpDialog,
|
|
setFollowUpFeature,
|
|
setFollowUpPrompt,
|
|
setFollowUpImagePaths,
|
|
setFollowUpPreviewMap,
|
|
loadFeatures,
|
|
]);
|
|
|
|
const handleCommitFeature = useCallback(
|
|
async (feature: Feature) => {
|
|
if (!currentProject) return;
|
|
|
|
try {
|
|
const api = getElectronAPI();
|
|
if (!api?.autoMode?.commitFeature) {
|
|
console.error("Commit feature API not available");
|
|
toast.error("Commit not available", {
|
|
description:
|
|
"This feature is not available in the current version.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Pass the feature's worktreePath to ensure commits happen in the correct worktree
|
|
const result = await api.autoMode.commitFeature(
|
|
currentProject.path,
|
|
feature.id,
|
|
feature.worktreePath
|
|
);
|
|
|
|
if (result.success) {
|
|
moveFeature(feature.id, "verified");
|
|
persistFeatureUpdate(feature.id, { status: "verified" });
|
|
toast.success("Feature committed", {
|
|
description: `Committed and verified: ${truncateDescription(
|
|
feature.description
|
|
)}`,
|
|
});
|
|
// Refresh worktree selector to update commit counts
|
|
onWorktreeCreated?.();
|
|
} else {
|
|
console.error("[Board] 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);
|
|
toast.error("Failed to commit feature", {
|
|
description:
|
|
error instanceof Error ? error.message : "An error occurred",
|
|
});
|
|
await loadFeatures();
|
|
}
|
|
},
|
|
[
|
|
currentProject,
|
|
moveFeature,
|
|
persistFeatureUpdate,
|
|
loadFeatures,
|
|
onWorktreeCreated,
|
|
]
|
|
);
|
|
|
|
const handleMergeFeature = useCallback(
|
|
async (feature: Feature) => {
|
|
if (!currentProject) return;
|
|
|
|
try {
|
|
const api = getElectronAPI();
|
|
if (!api?.worktree?.mergeFeature) {
|
|
console.error("Worktree API not available");
|
|
toast.error("Merge not available", {
|
|
description:
|
|
"This feature is not available in the current version.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const result = await api.worktree.mergeFeature(
|
|
currentProject.path,
|
|
feature.id
|
|
);
|
|
|
|
if (result.success) {
|
|
await loadFeatures();
|
|
toast.success("Feature merged", {
|
|
description: `Changes merged to main branch: ${truncateDescription(
|
|
feature.description
|
|
)}`,
|
|
});
|
|
} else {
|
|
console.error("[Board] 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);
|
|
toast.error("Failed to merge feature", {
|
|
description:
|
|
error instanceof Error ? error.message : "An error occurred",
|
|
});
|
|
}
|
|
},
|
|
[currentProject, loadFeatures]
|
|
);
|
|
|
|
const handleCompleteFeature = useCallback(
|
|
(feature: Feature) => {
|
|
const updates = {
|
|
status: "completed" as const,
|
|
};
|
|
updateFeature(feature.id, updates);
|
|
persistFeatureUpdate(feature.id, updates);
|
|
|
|
toast.success("Feature completed", {
|
|
description: `Archived: ${truncateDescription(feature.description)}`,
|
|
});
|
|
},
|
|
[updateFeature, persistFeatureUpdate]
|
|
);
|
|
|
|
const handleUnarchiveFeature = useCallback(
|
|
(feature: Feature) => {
|
|
const updates = {
|
|
status: "verified" as const,
|
|
};
|
|
updateFeature(feature.id, updates);
|
|
persistFeatureUpdate(feature.id, updates);
|
|
|
|
toast.success("Feature restored", {
|
|
description: `Moved back to verified: ${truncateDescription(
|
|
feature.description
|
|
)}`,
|
|
});
|
|
},
|
|
[updateFeature, persistFeatureUpdate]
|
|
);
|
|
|
|
const handleViewOutput = useCallback(
|
|
(feature: Feature) => {
|
|
setOutputFeature(feature);
|
|
setShowOutputModal(true);
|
|
},
|
|
[setOutputFeature, setShowOutputModal]
|
|
);
|
|
|
|
const handleOutputModalNumberKeyPress = useCallback(
|
|
(key: string) => {
|
|
const index = key === "0" ? 9 : parseInt(key, 10) - 1;
|
|
const targetFeature = inProgressFeaturesForShortcuts[index];
|
|
|
|
if (!targetFeature) {
|
|
return;
|
|
}
|
|
|
|
if (targetFeature.id === outputFeature?.id) {
|
|
setShowOutputModal(false);
|
|
} else {
|
|
setOutputFeature(targetFeature);
|
|
}
|
|
},
|
|
[
|
|
inProgressFeaturesForShortcuts,
|
|
outputFeature?.id,
|
|
setShowOutputModal,
|
|
setOutputFeature,
|
|
]
|
|
);
|
|
|
|
const handleForceStopFeature = useCallback(
|
|
async (feature: Feature) => {
|
|
try {
|
|
await autoMode.stopFeature(feature.id);
|
|
|
|
const targetStatus =
|
|
feature.skipTests && feature.status === "waiting_approval"
|
|
? "waiting_approval"
|
|
: "backlog";
|
|
|
|
if (targetStatus !== feature.status) {
|
|
moveFeature(feature.id, targetStatus);
|
|
// Must await to ensure file is written before user can restart
|
|
await persistFeatureUpdate(feature.id, { status: targetStatus });
|
|
}
|
|
|
|
toast.success("Agent stopped", {
|
|
description:
|
|
targetStatus === "waiting_approval"
|
|
? `Stopped commit - returned to waiting approval: ${truncateDescription(
|
|
feature.description
|
|
)}`
|
|
: `Stopped working on: ${truncateDescription(
|
|
feature.description
|
|
)}`,
|
|
});
|
|
} catch (error) {
|
|
console.error("[Board] Error stopping feature:", error);
|
|
toast.error("Failed to stop agent", {
|
|
description:
|
|
error instanceof Error ? error.message : "An error occurred",
|
|
});
|
|
}
|
|
},
|
|
[autoMode, moveFeature, persistFeatureUpdate]
|
|
);
|
|
|
|
const handleStartNextFeatures = useCallback(async () => {
|
|
// Filter backlog features by the currently selected worktree branch
|
|
// This ensures "G" only starts features from the filtered list
|
|
const backlogFeatures = features.filter((f) => {
|
|
if (f.status !== "backlog") return false;
|
|
|
|
// Determine the feature's branch (default to "main" if not set)
|
|
const featureBranch = f.branchName || "main";
|
|
|
|
// If no worktree is selected (currentWorktreeBranch is null or main-like),
|
|
// show features with no branch or "main"/"master" branch
|
|
if (
|
|
!currentWorktreeBranch ||
|
|
currentWorktreeBranch === "main" ||
|
|
currentWorktreeBranch === "master"
|
|
) {
|
|
return (
|
|
!f.branchName ||
|
|
featureBranch === "main" ||
|
|
featureBranch === "master"
|
|
);
|
|
}
|
|
|
|
// Otherwise, only show features matching the selected worktree branch
|
|
return featureBranch === currentWorktreeBranch;
|
|
});
|
|
|
|
const availableSlots =
|
|
useAppStore.getState().maxConcurrency - runningAutoTasks.length;
|
|
|
|
if (availableSlots <= 0) {
|
|
toast.error("Concurrency limit reached", {
|
|
description:
|
|
"Wait for a task to complete or increase the concurrency limit.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (backlogFeatures.length === 0) {
|
|
toast.info("Backlog empty", {
|
|
description:
|
|
currentWorktreeBranch &&
|
|
currentWorktreeBranch !== "main" &&
|
|
currentWorktreeBranch !== "master"
|
|
? `No features in backlog for branch "${currentWorktreeBranch}".`
|
|
: "No features in backlog to start.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Sort by priority (lower number = higher priority, priority 1 is highest)
|
|
// This matches the auto mode service behavior for consistency
|
|
const sortedBacklog = [...backlogFeatures].sort(
|
|
(a, b) => (a.priority || 999) - (b.priority || 999)
|
|
);
|
|
|
|
// Start only one feature per keypress (user must press again for next)
|
|
const featuresToStart = sortedBacklog.slice(0, 1);
|
|
|
|
for (const feature of featuresToStart) {
|
|
// Only create worktrees if the feature is enabled
|
|
let worktreePath: string | null = null;
|
|
if (useWorktrees) {
|
|
// Get or create worktree based on the feature's assigned branch (same as drag-to-in-progress)
|
|
worktreePath = await getOrCreateWorktreeForFeature(feature);
|
|
if (worktreePath) {
|
|
await persistFeatureUpdate(feature.id, { worktreePath });
|
|
}
|
|
// Refresh worktree selector after creating worktree
|
|
onWorktreeCreated?.();
|
|
}
|
|
// Start the implementation
|
|
// Pass feature with worktreePath so handleRunFeature uses the correct path
|
|
await handleStartImplementation({
|
|
...feature,
|
|
worktreePath: worktreePath || undefined,
|
|
});
|
|
}
|
|
}, [
|
|
features,
|
|
runningAutoTasks,
|
|
handleStartImplementation,
|
|
getOrCreateWorktreeForFeature,
|
|
persistFeatureUpdate,
|
|
onWorktreeCreated,
|
|
currentWorktreeBranch,
|
|
useWorktrees,
|
|
]);
|
|
|
|
const handleDeleteAllVerified = useCallback(async () => {
|
|
const verifiedFeatures = features.filter((f) => f.status === "verified");
|
|
|
|
for (const feature of verifiedFeatures) {
|
|
const isRunning = runningAutoTasks.includes(feature.id);
|
|
if (isRunning) {
|
|
try {
|
|
await autoMode.stopFeature(feature.id);
|
|
} catch (error) {
|
|
console.error("[Board] Error stopping feature before delete:", error);
|
|
}
|
|
}
|
|
removeFeature(feature.id);
|
|
persistFeatureDelete(feature.id);
|
|
}
|
|
|
|
toast.success("All verified features deleted", {
|
|
description: `Deleted ${verifiedFeatures.length} feature(s).`,
|
|
});
|
|
}, [
|
|
features,
|
|
runningAutoTasks,
|
|
autoMode,
|
|
removeFeature,
|
|
persistFeatureDelete,
|
|
]);
|
|
|
|
return {
|
|
handleAddFeature,
|
|
handleUpdateFeature,
|
|
handleDeleteFeature,
|
|
handleStartImplementation,
|
|
handleVerifyFeature,
|
|
handleResumeFeature,
|
|
handleManualVerify,
|
|
handleMoveBackToInProgress,
|
|
handleOpenFollowUp,
|
|
handleSendFollowUp,
|
|
handleCommitFeature,
|
|
handleMergeFeature,
|
|
handleCompleteFeature,
|
|
handleUnarchiveFeature,
|
|
handleViewOutput,
|
|
handleOutputModalNumberKeyPress,
|
|
handleForceStopFeature,
|
|
handleStartNextFeatures,
|
|
handleDeleteAllVerified,
|
|
};
|
|
}
|