mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
refactor: move from next js to vite and tanstack router
This commit is contained in:
10
apps/ui/src/components/views/board-view/hooks/index.ts
Normal file
10
apps/ui/src/components/views/board-view/hooks/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { useBoardFeatures } from "./use-board-features";
|
||||
export { useBoardDragDrop } from "./use-board-drag-drop";
|
||||
export { useBoardActions } from "./use-board-actions";
|
||||
export { useBoardKeyboardShortcuts } from "./use-board-keyboard-shortcuts";
|
||||
export { useBoardColumnFeatures } from "./use-board-column-features";
|
||||
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";
|
||||
@@ -0,0 +1,878 @@
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
Feature,
|
||||
FeatureImage,
|
||||
AgentModel,
|
||||
ThinkingLevel,
|
||||
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";
|
||||
|
||||
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,
|
||||
} = 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;
|
||||
}) => {
|
||||
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;
|
||||
}
|
||||
) => {
|
||||
// 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;
|
||||
}
|
||||
|
||||
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, 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMemo } from "react";
|
||||
import { useAppStore, defaultBackgroundSettings } from "@/store/app-store";
|
||||
|
||||
interface UseBoardBackgroundProps {
|
||||
currentProject: { path: string; id: string } | null;
|
||||
}
|
||||
|
||||
export function useBoardBackground({ currentProject }: UseBoardBackgroundProps) {
|
||||
const boardBackgroundByProject = useAppStore(
|
||||
(state) => state.boardBackgroundByProject
|
||||
);
|
||||
|
||||
// Get background settings for current project
|
||||
const backgroundSettings = useMemo(() => {
|
||||
return (
|
||||
(currentProject && boardBackgroundByProject[currentProject.path]) ||
|
||||
defaultBackgroundSettings
|
||||
);
|
||||
}, [currentProject, boardBackgroundByProject]);
|
||||
|
||||
// Build background image style if image exists
|
||||
const backgroundImageStyle = useMemo(() => {
|
||||
if (!backgroundSettings.imagePath || !currentProject) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundImage: `url(${
|
||||
import.meta.env.VITE_SERVER_URL || "http://localhost:3008"
|
||||
}/api/fs/image?path=${encodeURIComponent(
|
||||
backgroundSettings.imagePath
|
||||
)}&projectPath=${encodeURIComponent(currentProject.path)}${
|
||||
backgroundSettings.imageVersion
|
||||
? `&v=${backgroundSettings.imageVersion}`
|
||||
: ""
|
||||
})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
} as React.CSSProperties;
|
||||
}, [backgroundSettings, currentProject]);
|
||||
|
||||
return {
|
||||
backgroundSettings,
|
||||
backgroundImageStyle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { pathsEqual } from "@/lib/utils";
|
||||
|
||||
type ColumnId = Feature["status"];
|
||||
|
||||
interface UseBoardColumnFeaturesProps {
|
||||
features: Feature[];
|
||||
runningAutoTasks: string[];
|
||||
searchQuery: string;
|
||||
currentWorktreePath: string | null; // Currently selected worktree path
|
||||
currentWorktreeBranch: string | null; // Branch name of the selected worktree (null = main)
|
||||
projectPath: string | null; // Main project path (for main worktree)
|
||||
}
|
||||
|
||||
export function useBoardColumnFeatures({
|
||||
features,
|
||||
runningAutoTasks,
|
||||
searchQuery,
|
||||
currentWorktreePath,
|
||||
currentWorktreeBranch,
|
||||
projectPath,
|
||||
}: UseBoardColumnFeaturesProps) {
|
||||
// Memoize column features to prevent unnecessary re-renders
|
||||
const columnFeaturesMap = useMemo(() => {
|
||||
const map: Record<ColumnId, Feature[]> = {
|
||||
backlog: [],
|
||||
in_progress: [],
|
||||
waiting_approval: [],
|
||||
verified: [],
|
||||
completed: [], // Completed features are shown in the archive modal, not as a column
|
||||
};
|
||||
|
||||
// Filter features by search query (case-insensitive)
|
||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||
const filteredFeatures = normalizedQuery
|
||||
? features.filter(
|
||||
(f) =>
|
||||
f.description.toLowerCase().includes(normalizedQuery) ||
|
||||
f.category?.toLowerCase().includes(normalizedQuery)
|
||||
)
|
||||
: features;
|
||||
|
||||
// Determine the effective worktree path and branch for filtering
|
||||
// If currentWorktreePath is null, we're on the main worktree
|
||||
const effectiveWorktreePath = currentWorktreePath || projectPath;
|
||||
// Use the branch name from the selected worktree
|
||||
// If we're selecting main (currentWorktreePath is null), currentWorktreeBranch
|
||||
// should contain the main branch's actual name, defaulting to "main"
|
||||
// If we're selecting a non-main worktree but can't find it, currentWorktreeBranch is null
|
||||
// In that case, we can't do branch-based filtering, so we'll handle it specially below
|
||||
const effectiveBranch = currentWorktreeBranch;
|
||||
|
||||
filteredFeatures.forEach((f) => {
|
||||
// If feature has a running agent, always show it in "in_progress"
|
||||
const isRunning = runningAutoTasks.includes(f.id);
|
||||
|
||||
// Check if feature matches the current worktree
|
||||
// Match by worktreePath if set, OR by branchName if set
|
||||
// Features with neither are considered unassigned (show on ALL worktrees)
|
||||
const featureBranch = f.branchName || "main";
|
||||
const hasWorktreeAssigned = f.worktreePath || f.branchName;
|
||||
|
||||
let matchesWorktree: boolean;
|
||||
if (!hasWorktreeAssigned) {
|
||||
// No worktree or branch assigned - show on ALL worktrees (unassigned)
|
||||
matchesWorktree = true;
|
||||
} else if (f.worktreePath) {
|
||||
// Has worktreePath - match by path (use pathsEqual for cross-platform compatibility)
|
||||
matchesWorktree = pathsEqual(f.worktreePath, effectiveWorktreePath);
|
||||
} else if (effectiveBranch === null) {
|
||||
// We're viewing main but branch hasn't been initialized yet
|
||||
// (worktrees disabled or haven't loaded yet).
|
||||
// Show features assigned to main/master branch since we're on the main worktree.
|
||||
matchesWorktree = featureBranch === "main" || featureBranch === "master";
|
||||
} else {
|
||||
// Has branchName but no worktreePath - match by branch name
|
||||
matchesWorktree = featureBranch === effectiveBranch;
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
// Only show running tasks if they match the current worktree
|
||||
if (matchesWorktree) {
|
||||
map.in_progress.push(f);
|
||||
}
|
||||
} else {
|
||||
// Otherwise, use the feature's status (fallback to backlog for unknown statuses)
|
||||
const status = f.status as ColumnId;
|
||||
|
||||
// Filter all items by worktree, including backlog
|
||||
// This ensures backlog items with a branch assigned only show in that branch
|
||||
if (status === "backlog") {
|
||||
if (matchesWorktree) {
|
||||
map.backlog.push(f);
|
||||
}
|
||||
} else if (map[status]) {
|
||||
// Only show if matches current worktree or has no worktree assigned
|
||||
if (matchesWorktree) {
|
||||
map[status].push(f);
|
||||
}
|
||||
} else {
|
||||
// Unknown status, default to backlog
|
||||
map.backlog.push(f);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sort backlog by priority: 1 (high) -> 2 (medium) -> 3 (low) -> no priority
|
||||
map.backlog.sort((a, b) => {
|
||||
const aPriority = a.priority ?? 999; // Features without priority go last
|
||||
const bPriority = b.priority ?? 999;
|
||||
return aPriority - bPriority;
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);
|
||||
|
||||
const getColumnFeatures = useCallback(
|
||||
(columnId: ColumnId) => {
|
||||
return columnFeaturesMap[columnId];
|
||||
},
|
||||
[columnFeaturesMap]
|
||||
);
|
||||
|
||||
// Memoize completed features for the archive modal
|
||||
const completedFeatures = useMemo(() => {
|
||||
return features.filter((f) => f.status === "completed");
|
||||
}, [features]);
|
||||
|
||||
return {
|
||||
columnFeaturesMap,
|
||||
getColumnFeatures,
|
||||
completedFeatures,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useState, useCallback } from "react";
|
||||
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";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
|
||||
interface UseBoardDragDropProps {
|
||||
features: Feature[];
|
||||
currentProject: { path: string; id: string } | null;
|
||||
runningAutoTasks: string[];
|
||||
persistFeatureUpdate: (
|
||||
featureId: string,
|
||||
updates: Partial<Feature>
|
||||
) => Promise<void>;
|
||||
handleStartImplementation: (feature: Feature) => Promise<boolean>;
|
||||
projectPath: string | null; // Main project path
|
||||
onWorktreeCreated?: () => void; // Callback when a new worktree is created
|
||||
}
|
||||
|
||||
export function useBoardDragDrop({
|
||||
features,
|
||||
currentProject,
|
||||
runningAutoTasks,
|
||||
persistFeatureUpdate,
|
||||
handleStartImplementation,
|
||||
projectPath,
|
||||
onWorktreeCreated,
|
||||
}: UseBoardDragDropProps) {
|
||||
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||
const { moveFeature, useWorktrees } = useAppStore();
|
||||
|
||||
/**
|
||||
* 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("[DragDrop] 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(
|
||||
`[DragDrop] 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("[DragDrop] 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("[DragDrop] 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 handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
const feature = features.find((f) => f.id === active.id);
|
||||
if (feature) {
|
||||
setActiveFeature(feature);
|
||||
}
|
||||
},
|
||||
[features]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveFeature(null);
|
||||
|
||||
if (!over) return;
|
||||
|
||||
const featureId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
// Find the feature being dragged
|
||||
const draggedFeature = features.find((f) => f.id === featureId);
|
||||
if (!draggedFeature) return;
|
||||
|
||||
// Check if this is a running task (non-skipTests, TDD)
|
||||
const isRunningTask = runningAutoTasks.includes(featureId);
|
||||
|
||||
// Determine if dragging is allowed based on status and skipTests
|
||||
// - Backlog items can always be dragged
|
||||
// - waiting_approval items can always be dragged (to allow manual verification via drag)
|
||||
// - verified items can always be dragged (to allow moving back to waiting_approval)
|
||||
// - skipTests (non-TDD) items can be dragged between in_progress and verified
|
||||
// - Non-skipTests (TDD) items that are in progress cannot be dragged (they are running)
|
||||
if (
|
||||
draggedFeature.status !== "backlog" &&
|
||||
draggedFeature.status !== "waiting_approval" &&
|
||||
draggedFeature.status !== "verified"
|
||||
) {
|
||||
// Only allow dragging in_progress if it's a skipTests feature and not currently running
|
||||
if (!draggedFeature.skipTests || isRunningTask) {
|
||||
console.log(
|
||||
"[Board] Cannot drag feature - TDD feature or currently running"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let targetStatus: ColumnId | null = null;
|
||||
|
||||
// Check if we dropped on a column
|
||||
const column = COLUMNS.find((c) => c.id === overId);
|
||||
if (column) {
|
||||
targetStatus = column.id;
|
||||
} else {
|
||||
// Dropped on another feature - find its column
|
||||
const overFeature = features.find((f) => f.id === overId);
|
||||
if (overFeature) {
|
||||
targetStatus = overFeature.status;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetStatus) return;
|
||||
|
||||
// Same column, nothing to do
|
||||
if (targetStatus === draggedFeature.status) return;
|
||||
|
||||
// Handle different drag scenarios
|
||||
if (draggedFeature.status === "backlog") {
|
||||
// From backlog
|
||||
if (targetStatus === "in_progress") {
|
||||
// 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
|
||||
worktreePath = await getOrCreateWorktreeForFeature(draggedFeature);
|
||||
if (worktreePath) {
|
||||
await persistFeatureUpdate(featureId, { worktreePath });
|
||||
}
|
||||
// Refresh worktree selector after moving to in_progress
|
||||
onWorktreeCreated?.();
|
||||
}
|
||||
// Use helper function to handle concurrency check and start implementation
|
||||
// Pass feature with worktreePath so handleRunFeature uses the correct path
|
||||
await handleStartImplementation({ ...draggedFeature, worktreePath: worktreePath || undefined });
|
||||
} else {
|
||||
moveFeature(featureId, targetStatus);
|
||||
persistFeatureUpdate(featureId, { status: targetStatus });
|
||||
}
|
||||
} else if (draggedFeature.status === "waiting_approval") {
|
||||
// waiting_approval features can be dragged to verified for manual verification
|
||||
// NOTE: This check must come BEFORE skipTests check because waiting_approval
|
||||
// features often have skipTests=true, and we want status-based handling first
|
||||
if (targetStatus === "verified") {
|
||||
moveFeature(featureId, "verified");
|
||||
// Clear justFinishedAt timestamp when manually verifying via drag
|
||||
persistFeatureUpdate(featureId, {
|
||||
status: "verified",
|
||||
justFinishedAt: undefined,
|
||||
});
|
||||
toast.success("Feature verified", {
|
||||
description: `Manually verified: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
} else if (targetStatus === "backlog") {
|
||||
// Allow moving waiting_approval cards back to backlog
|
||||
moveFeature(featureId, "backlog");
|
||||
// Clear justFinishedAt timestamp and worktreePath when moving back to backlog
|
||||
persistFeatureUpdate(featureId, {
|
||||
status: "backlog",
|
||||
justFinishedAt: undefined,
|
||||
worktreePath: undefined,
|
||||
});
|
||||
toast.info("Feature moved to backlog", {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
}
|
||||
} else if (draggedFeature.skipTests) {
|
||||
// skipTests feature being moved between in_progress and verified
|
||||
if (
|
||||
targetStatus === "verified" &&
|
||||
draggedFeature.status === "in_progress"
|
||||
) {
|
||||
// Manual verify via drag
|
||||
moveFeature(featureId, "verified");
|
||||
persistFeatureUpdate(featureId, { status: "verified" });
|
||||
toast.success("Feature verified", {
|
||||
description: `Marked as verified: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
} else if (
|
||||
targetStatus === "waiting_approval" &&
|
||||
draggedFeature.status === "verified"
|
||||
) {
|
||||
// Move verified feature back to waiting_approval
|
||||
moveFeature(featureId, "waiting_approval");
|
||||
persistFeatureUpdate(featureId, { status: "waiting_approval" });
|
||||
toast.info("Feature moved back", {
|
||||
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
} else if (targetStatus === "backlog") {
|
||||
// Allow moving skipTests cards back to backlog
|
||||
moveFeature(featureId, "backlog");
|
||||
// Clear worktreePath when moving back to backlog
|
||||
persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
|
||||
toast.info("Feature moved to backlog", {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
}
|
||||
} else if (draggedFeature.status === "verified") {
|
||||
// Handle verified TDD (non-skipTests) features being moved back
|
||||
if (targetStatus === "waiting_approval") {
|
||||
// Move verified feature back to waiting_approval
|
||||
moveFeature(featureId, "waiting_approval");
|
||||
persistFeatureUpdate(featureId, { status: "waiting_approval" });
|
||||
toast.info("Feature moved back", {
|
||||
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
} else if (targetStatus === "backlog") {
|
||||
// Allow moving verified cards back to backlog
|
||||
moveFeature(featureId, "backlog");
|
||||
// Clear worktreePath when moving back to backlog
|
||||
persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
|
||||
toast.info("Feature moved to backlog", {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
features,
|
||||
runningAutoTasks,
|
||||
moveFeature,
|
||||
persistFeatureUpdate,
|
||||
handleStartImplementation,
|
||||
getOrCreateWorktreeForFeature,
|
||||
onWorktreeCreated,
|
||||
useWorktrees,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
activeFeature,
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||
|
||||
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;
|
||||
setFeaturesWithContext: (set: Set<string>) => void;
|
||||
}
|
||||
|
||||
export function useBoardEffects({
|
||||
currentProject,
|
||||
specCreatingForProject,
|
||||
setSpecCreatingForProject,
|
||||
setSuggestionsCount,
|
||||
setFeatureSuggestions,
|
||||
setIsGeneratingSuggestions,
|
||||
checkContextExists,
|
||||
features,
|
||||
isLoading,
|
||||
setFeaturesWithContext,
|
||||
}: UseBoardEffectsProps) {
|
||||
const autoMode = useAutoMode();
|
||||
|
||||
// Make current project available globally for modal
|
||||
useEffect(() => {
|
||||
if (currentProject) {
|
||||
(window as any).__currentProject = currentProject;
|
||||
}
|
||||
return () => {
|
||||
(window as any).__currentProject = null;
|
||||
};
|
||||
}, [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
|
||||
);
|
||||
|
||||
if (event.projectPath !== specCreatingForProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "spec_regeneration_complete") {
|
||||
setSpecCreatingForProject(null);
|
||||
} else if (event.type === "spec_regeneration_error") {
|
||||
setSpecCreatingForProject(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [specCreatingForProject, setSpecCreatingForProject]);
|
||||
|
||||
// Sync running tasks from electron backend on mount
|
||||
useEffect(() => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const syncRunningTasks = async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.status) return;
|
||||
|
||||
const status = await api.autoMode.status(currentProject.path);
|
||||
if (status.success) {
|
||||
const projectId = currentProject.id;
|
||||
const { clearRunningTasks, addRunningTask, setAutoModeRunning } =
|
||||
useAppStore.getState();
|
||||
|
||||
if (status.runningFeatures) {
|
||||
console.log(
|
||||
"[Board] Syncing running tasks from backend:",
|
||||
status.runningFeatures
|
||||
);
|
||||
|
||||
clearRunningTasks(projectId);
|
||||
|
||||
status.runningFeatures.forEach((featureId: string) => {
|
||||
addRunningTask(projectId, featureId);
|
||||
});
|
||||
}
|
||||
|
||||
const isAutoModeRunning =
|
||||
status.autoLoopRunning ?? status.isRunning ?? false;
|
||||
console.log(
|
||||
"[Board] Syncing auto mode running state:",
|
||||
isAutoModeRunning
|
||||
);
|
||||
setAutoModeRunning(projectId, isAutoModeRunning);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Failed to sync running tasks:", error);
|
||||
}
|
||||
};
|
||||
|
||||
syncRunningTasks();
|
||||
}, [currentProject]);
|
||||
|
||||
// Check which features have context files
|
||||
useEffect(() => {
|
||||
const checkAllContexts = async () => {
|
||||
const featuresWithPotentialContext = features.filter(
|
||||
(f) =>
|
||||
f.status === "in_progress" ||
|
||||
f.status === "waiting_approval" ||
|
||||
f.status === "verified"
|
||||
);
|
||||
const contextChecks = await Promise.all(
|
||||
featuresWithPotentialContext.map(async (f) => ({
|
||||
id: f.id,
|
||||
hasContext: await checkContextExists(f.id),
|
||||
}))
|
||||
);
|
||||
|
||||
const newSet = new Set<string>();
|
||||
contextChecks.forEach(({ id, hasContext }) => {
|
||||
if (hasContext) {
|
||||
newSet.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
setFeaturesWithContext(newSet);
|
||||
};
|
||||
|
||||
if (features.length > 0 && !isLoading) {
|
||||
checkAllContexts();
|
||||
}
|
||||
}, [features, isLoading, checkContextExists, setFeaturesWithContext]);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useAppStore, Feature } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface UseBoardFeaturesProps {
|
||||
currentProject: { path: string; id: string } | null;
|
||||
}
|
||||
|
||||
export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||
const { features, setFeatures } = useAppStore();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
|
||||
|
||||
// Track previous project path to detect project switches
|
||||
const prevProjectPathRef = useRef<string | null>(null);
|
||||
const isInitialLoadRef = useRef(true);
|
||||
const isSwitchingProjectRef = useRef(false);
|
||||
|
||||
// Load features using features API
|
||||
// IMPORTANT: Do NOT add 'features' to dependency array - it would cause infinite reload loop
|
||||
const loadFeatures = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const currentPath = currentProject.path;
|
||||
const previousPath = prevProjectPathRef.current;
|
||||
const isProjectSwitch =
|
||||
previousPath !== null && currentPath !== previousPath;
|
||||
|
||||
// Get cached features from store (without adding to dependencies)
|
||||
const cachedFeatures = useAppStore.getState().features;
|
||||
|
||||
// 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}`
|
||||
);
|
||||
isSwitchingProjectRef.current = true;
|
||||
isInitialLoadRef.current = true;
|
||||
}
|
||||
|
||||
// Update the ref to track current project
|
||||
prevProjectPathRef.current = currentPath;
|
||||
|
||||
// Only show loading spinner on initial load to prevent board flash during reloads
|
||||
if (isInitialLoadRef.current) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.features) {
|
||||
console.error("[BoardView] Features API not available");
|
||||
// Keep cached features if API is unavailable
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.features.getAll(currentProject.path);
|
||||
|
||||
if (result.success && result.features) {
|
||||
const featuresWithIds = result.features.map(
|
||||
(f: any, index: number) => ({
|
||||
...f,
|
||||
id: f.id || `feature-${index}-${Date.now()}`,
|
||||
status: f.status || "backlog",
|
||||
startedAt: f.startedAt, // Preserve startedAt timestamp
|
||||
// Ensure model and thinkingLevel are set for backward compatibility
|
||||
model: f.model || "opus",
|
||||
thinkingLevel: f.thinkingLevel || "none",
|
||||
})
|
||||
);
|
||||
// Successfully loaded features - now safe to set them
|
||||
setFeatures(featuresWithIds);
|
||||
|
||||
// Only clear categories on project switch AFTER successful load
|
||||
if (isProjectSwitch) {
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
} else if (!result.success && result.error) {
|
||||
console.error("[BoardView] 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) {
|
||||
setFeatures([]);
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
// Otherwise keep cached features
|
||||
}
|
||||
} catch (error) {
|
||||
console.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) {
|
||||
setFeatures([]);
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isInitialLoadRef.current = false;
|
||||
isSwitchingProjectRef.current = false;
|
||||
}
|
||||
}, [currentProject, setFeatures]);
|
||||
|
||||
// Load persisted categories from file
|
||||
const loadCategories = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.readFile(
|
||||
`${currentProject.path}/.automaker/categories.json`
|
||||
);
|
||||
|
||||
if (result.success && result.content) {
|
||||
const parsed = JSON.parse(result.content);
|
||||
if (Array.isArray(parsed)) {
|
||||
setPersistedCategories(parsed);
|
||||
}
|
||||
} else {
|
||||
// File doesn't exist, ensure categories are cleared
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load categories:", error);
|
||||
// If file doesn't exist, ensure categories are cleared
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
}, [currentProject]);
|
||||
|
||||
// Save a new category to the persisted categories file
|
||||
const saveCategory = useCallback(
|
||||
async (category: string) => {
|
||||
if (!currentProject || !category.trim()) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Read existing categories
|
||||
let categories: string[] = [...persistedCategories];
|
||||
|
||||
// Add new category if it doesn't exist
|
||||
if (!categories.includes(category)) {
|
||||
categories.push(category);
|
||||
categories.sort(); // Keep sorted
|
||||
|
||||
// Write back to file
|
||||
await api.writeFile(
|
||||
`${currentProject.path}/.automaker/categories.json`,
|
||||
JSON.stringify(categories, null, 2)
|
||||
);
|
||||
|
||||
// Update state
|
||||
setPersistedCategories(categories);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save category:", error);
|
||||
}
|
||||
},
|
||||
[currentProject, persistedCategories]
|
||||
);
|
||||
|
||||
// Subscribe to spec regeneration complete events to refresh kanban board
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) return;
|
||||
|
||||
const unsubscribe = api.specRegeneration.onEvent((event) => {
|
||||
// Refresh the kanban board when spec regeneration completes for the current project
|
||||
if (
|
||||
event.type === "spec_regeneration_complete" &&
|
||||
currentProject &&
|
||||
event.projectPath === currentProject.path
|
||||
) {
|
||||
console.log(
|
||||
"[BoardView] Spec regeneration complete, refreshing features"
|
||||
);
|
||||
loadFeatures();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [currentProject, loadFeatures]);
|
||||
|
||||
// Listen for auto mode feature completion and errors to reload features
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode || !currentProject) return;
|
||||
|
||||
const { removeRunningTask } = useAppStore.getState();
|
||||
const projectId = currentProject.id;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||
// Use event's projectPath or projectId if available, otherwise use current project
|
||||
// Board view only reacts to events for the currently selected project
|
||||
const eventProjectId =
|
||||
("projectId" in event && event.projectId) || projectId;
|
||||
|
||||
if (event.type === "auto_mode_feature_complete") {
|
||||
// Reload features when a feature is completed
|
||||
console.log("[Board] 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));
|
||||
}
|
||||
} 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
|
||||
);
|
||||
|
||||
// Remove from running tasks so it moves to the correct column
|
||||
if (event.featureId) {
|
||||
removeRunningTask(eventProjectId, event.featureId);
|
||||
}
|
||||
|
||||
loadFeatures();
|
||||
|
||||
// Check for authentication errors and show a more helpful message
|
||||
const isAuthError =
|
||||
event.errorType === "authentication" ||
|
||||
(event.error &&
|
||||
(event.error.includes("Authentication failed") ||
|
||||
event.error.includes("Invalid API key")));
|
||||
|
||||
if (isAuthError) {
|
||||
toast.error("Authentication Failed", {
|
||||
description:
|
||||
"Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.",
|
||||
duration: 10000,
|
||||
});
|
||||
} else {
|
||||
toast.error("Agent encountered an error", {
|
||||
description: event.error || "Check the logs for details",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [loadFeatures, currentProject]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFeatures();
|
||||
}, [loadFeatures]);
|
||||
|
||||
// Load persisted categories on mount
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, [loadCategories]);
|
||||
|
||||
return {
|
||||
features,
|
||||
isLoading,
|
||||
persistedCategories,
|
||||
loadFeatures,
|
||||
loadCategories,
|
||||
saveCategory,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useMemo, useRef, useEffect } from "react";
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
useKeyboardShortcutsConfig,
|
||||
KeyboardShortcut,
|
||||
} from "@/hooks/use-keyboard-shortcuts";
|
||||
import { Feature } from "@/store/app-store";
|
||||
|
||||
interface UseBoardKeyboardShortcutsProps {
|
||||
features: Feature[];
|
||||
runningAutoTasks: string[];
|
||||
onAddFeature: () => void;
|
||||
onStartNextFeatures: () => void;
|
||||
onViewOutput: (feature: Feature) => void;
|
||||
}
|
||||
|
||||
export function useBoardKeyboardShortcuts({
|
||||
features,
|
||||
runningAutoTasks,
|
||||
onAddFeature,
|
||||
onStartNextFeatures,
|
||||
onViewOutput,
|
||||
}: UseBoardKeyboardShortcutsProps) {
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
|
||||
// Get in-progress features for keyboard shortcuts (memoized for shortcuts)
|
||||
const inProgressFeaturesForShortcuts = useMemo(() => {
|
||||
return features.filter((f) => {
|
||||
const isRunning = runningAutoTasks.includes(f.id);
|
||||
return isRunning || f.status === "in_progress";
|
||||
});
|
||||
}, [features, runningAutoTasks]);
|
||||
|
||||
// Ref to hold the start next callback (to avoid dependency issues)
|
||||
const startNextFeaturesRef = useRef<() => void>(() => {});
|
||||
|
||||
// Update ref when callback changes
|
||||
useEffect(() => {
|
||||
startNextFeaturesRef.current = onStartNextFeatures;
|
||||
}, [onStartNextFeatures]);
|
||||
|
||||
// Keyboard shortcuts for this view
|
||||
const boardShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||
const shortcutsList: KeyboardShortcut[] = [
|
||||
{
|
||||
key: shortcuts.addFeature,
|
||||
action: onAddFeature,
|
||||
description: "Add new feature",
|
||||
},
|
||||
{
|
||||
key: shortcuts.startNext,
|
||||
action: () => startNextFeaturesRef.current(),
|
||||
description: "Start next features from backlog",
|
||||
},
|
||||
];
|
||||
|
||||
// Add shortcuts for in-progress cards (1-9 and 0 for 10th)
|
||||
inProgressFeaturesForShortcuts.slice(0, 10).forEach((feature, index) => {
|
||||
// Keys 1-9 for first 9 cards, 0 for 10th card
|
||||
const key = index === 9 ? "0" : String(index + 1);
|
||||
shortcutsList.push({
|
||||
key,
|
||||
action: () => {
|
||||
onViewOutput(feature);
|
||||
},
|
||||
description: `View output for in-progress card ${index + 1}`,
|
||||
});
|
||||
});
|
||||
|
||||
return shortcutsList;
|
||||
}, [inProgressFeaturesForShortcuts, shortcuts, onAddFeature, onViewOutput]);
|
||||
|
||||
useKeyboardShortcuts(boardShortcuts);
|
||||
|
||||
return {
|
||||
inProgressFeaturesForShortcuts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useCallback } from "react";
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
interface UseBoardPersistenceProps {
|
||||
currentProject: { path: string; id: string } | null;
|
||||
}
|
||||
|
||||
export function useBoardPersistence({
|
||||
currentProject,
|
||||
}: UseBoardPersistenceProps) {
|
||||
const { updateFeature } = useAppStore();
|
||||
|
||||
// Persist feature update to API (replaces saveFeatures)
|
||||
const persistFeatureUpdate = useCallback(
|
||||
async (featureId: string, updates: Partial<Feature>) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.features) {
|
||||
console.error("[BoardView] Features API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.features.update(
|
||||
currentProject.path,
|
||||
featureId,
|
||||
updates
|
||||
);
|
||||
if (result.success && result.feature) {
|
||||
updateFeature(result.feature.id, result.feature);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to persist feature update:", error);
|
||||
}
|
||||
},
|
||||
[currentProject, updateFeature]
|
||||
);
|
||||
|
||||
// Persist feature creation to API
|
||||
const persistFeatureCreate = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.features) {
|
||||
console.error("[BoardView] Features API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.features.create(currentProject.path, feature);
|
||||
if (result.success && result.feature) {
|
||||
updateFeature(result.feature.id, result.feature);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to persist feature creation:", error);
|
||||
}
|
||||
},
|
||||
[currentProject, updateFeature]
|
||||
);
|
||||
|
||||
// Persist feature deletion to API
|
||||
const persistFeatureDelete = useCallback(
|
||||
async (featureId: string) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.features) {
|
||||
console.error("[BoardView] Features API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
await api.features.delete(currentProject.path, featureId);
|
||||
} catch (error) {
|
||||
console.error("Failed to persist feature deletion:", error);
|
||||
}
|
||||
},
|
||||
[currentProject]
|
||||
);
|
||||
|
||||
return {
|
||||
persistFeatureCreate,
|
||||
persistFeatureUpdate,
|
||||
persistFeatureDelete,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Feature } from "@/store/app-store";
|
||||
import {
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
ImagePreviewMap,
|
||||
} from "@/components/ui/description-image-dropzone";
|
||||
|
||||
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 resetFollowUpState = useCallback(() => {
|
||||
setShowFollowUpDialog(false);
|
||||
setFollowUpFeature(null);
|
||||
setFollowUpPrompt("");
|
||||
setFollowUpImagePaths([]);
|
||||
setFollowUpPreviewMap(new Map());
|
||||
}, []);
|
||||
|
||||
const handleFollowUpDialogChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
resetFollowUpState();
|
||||
} else {
|
||||
setShowFollowUpDialog(open);
|
||||
}
|
||||
}, [resetFollowUpState]);
|
||||
|
||||
return {
|
||||
// State
|
||||
showFollowUpDialog,
|
||||
followUpFeature,
|
||||
followUpPrompt,
|
||||
followUpImagePaths,
|
||||
followUpPreviewMap,
|
||||
// Setters
|
||||
setShowFollowUpDialog,
|
||||
setFollowUpFeature,
|
||||
setFollowUpPrompt,
|
||||
setFollowUpImagePaths,
|
||||
setFollowUpPreviewMap,
|
||||
// Helpers
|
||||
resetFollowUpState,
|
||||
handleFollowUpDialogChange,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user