fix(ui): persist staged task state across app restarts (#800)
* fix(ui): persist staged task state across app restarts
Previously, when a task was staged and the app restarted, the UI showed
the staging interface again instead of recognizing the task was already
staged. This happened because the condition order checked worktree
existence before checking the stagedInMainProject flag.
Changes:
- Fix condition priority in TaskReview.tsx to check stagedInMainProject
before worktreeStatus.exists
- Add 'Mark Done Only' button to mark task complete without deleting
worktree
- Add 'Review Again' button to clear staged state and re-show staging UI
- Add TASK_CLEAR_STAGED_STATE IPC handler to reset staged flags in
implementation plan files
- Add handleReviewAgain callback in useTaskDetail hook
* feat(ui): add worktree cleanup dialog when marking task as done
When dragging a task to the 'done' column, if the task has a worktree:
- Shows a confirmation dialog asking about worktree cleanup
- Staged tasks: Can 'Keep Worktree' or 'Delete Worktree & Mark Done'
- Non-staged tasks: Must delete worktree or cancel (to prevent losing work)
Also fixes a race condition where discardWorktree sent 'backlog' status
before persistTaskStatus('done') could execute, causing task to briefly
appear in Done then jump back to Planning.
Added skipStatusChange parameter to discardWorktree IPC to prevent this.
* fix(frontend): Address PR #800 feedback - type errors, TOCTOU race, and i18n
- Fix TypeScript error in KanbanBoard by using isValidDropColumn type guard
instead of incorrect includes() cast with TaskStatus
- Fix TOCTOU race condition in clearStagedState handler by using EAFP
pattern (try/catch) instead of existsSync before read/write
- Fix task data refresh in handleReviewAgain by calling loadTasks after
clearing staged state to reflect updated task data
- Add workspaceError reset in handleReviewAgain
- Add missing i18n translation keys for kanban worktree cleanup dialog
(en/fr: worktreeCleanupTitle, worktreeCleanupStaged, worktreeCleanupNotStaged,
keepWorktree, deleteWorktree)
- Remove unused Trash2 import and WorktreeStatus type import
- Remove unused worktreeStatus prop from StagedInProjectMessage
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2588,7 +2588,7 @@ export function registerWorktreeHandlers(
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TASK_WORKTREE_DISCARD,
|
||||
async (_, taskId: string): Promise<IPCResult<WorktreeDiscardResult>> => {
|
||||
async (_, taskId: string, skipStatusChange?: boolean): Promise<IPCResult<WorktreeDiscardResult>> => {
|
||||
try {
|
||||
const { task, project } = findTaskAndProject(taskId);
|
||||
if (!task || !project) {
|
||||
@@ -2631,9 +2631,13 @@ export function registerWorktreeHandlers(
|
||||
// Branch might already be deleted or not exist
|
||||
}
|
||||
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_STATUS_CHANGE, taskId, 'backlog');
|
||||
// Only send status change to backlog if not skipped
|
||||
// (skip when caller will set a different status, e.g., 'done')
|
||||
if (!skipStatusChange) {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_STATUS_CHANGE, taskId, 'backlog');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -2844,6 +2848,82 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Clear the staged state for a task
|
||||
* This allows the user to re-stage changes if needed
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TASK_CLEAR_STAGED_STATE,
|
||||
async (_, taskId: string): Promise<IPCResult<{ cleared: boolean }>> => {
|
||||
try {
|
||||
const { task, project } = findTaskAndProject(taskId);
|
||||
if (!task || !project) {
|
||||
return { success: false, error: 'Task not found' };
|
||||
}
|
||||
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specDir = path.join(project.path, specsBaseDir, task.specId);
|
||||
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
|
||||
|
||||
// Use EAFP pattern (try/catch) instead of LBYL (existsSync check) to avoid TOCTOU race conditions
|
||||
const { promises: fsPromises } = require('fs');
|
||||
const isFileNotFound = (err: unknown): boolean =>
|
||||
!!(err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT');
|
||||
|
||||
// Read, update, and write the plan file
|
||||
let planContent: string;
|
||||
try {
|
||||
planContent = await fsPromises.readFile(planPath, 'utf-8');
|
||||
} catch (readErr) {
|
||||
if (isFileNotFound(readErr)) {
|
||||
return { success: false, error: 'Implementation plan not found' };
|
||||
}
|
||||
throw readErr;
|
||||
}
|
||||
|
||||
const plan = JSON.parse(planContent);
|
||||
|
||||
// Clear the staged state flags
|
||||
delete plan.stagedInMainProject;
|
||||
delete plan.stagedAt;
|
||||
plan.updated_at = new Date().toISOString();
|
||||
|
||||
await fsPromises.writeFile(planPath, JSON.stringify(plan, null, 2));
|
||||
|
||||
// Also update worktree plan if it exists
|
||||
const worktreePath = findTaskWorktree(project.path, task.specId);
|
||||
if (worktreePath) {
|
||||
const worktreePlanPath = path.join(worktreePath, specsBaseDir, task.specId, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
|
||||
try {
|
||||
const worktreePlanContent = await fsPromises.readFile(worktreePlanPath, 'utf-8');
|
||||
const worktreePlan = JSON.parse(worktreePlanContent);
|
||||
delete worktreePlan.stagedInMainProject;
|
||||
delete worktreePlan.stagedAt;
|
||||
worktreePlan.updated_at = new Date().toISOString();
|
||||
await fsPromises.writeFile(worktreePlanPath, JSON.stringify(worktreePlan, null, 2));
|
||||
} catch (e) {
|
||||
// Non-fatal - worktree plan update is best-effort
|
||||
// ENOENT is expected when worktree has no plan file
|
||||
if (!isFileNotFound(e)) {
|
||||
console.warn('[CLEAR_STAGED_STATE] Failed to update worktree plan:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate tasks cache to force reload
|
||||
projectStore.invalidateTasksCache(project.id);
|
||||
|
||||
return { success: true, data: { cleared: true } };
|
||||
} catch (error) {
|
||||
console.error('Failed to clear staged state:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to clear staged state'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a Pull Request from the worktree branch
|
||||
* Pushes the branch to origin and creates a GitHub PR using gh CLI
|
||||
|
||||
@@ -52,7 +52,8 @@ export interface TaskAPI {
|
||||
getWorktreeDiff: (taskId: string) => Promise<IPCResult<import('../../shared/types').WorktreeDiff>>;
|
||||
mergeWorktree: (taskId: string, options?: { noCommit?: boolean }) => Promise<IPCResult<import('../../shared/types').WorktreeMergeResult>>;
|
||||
mergeWorktreePreview: (taskId: string) => Promise<IPCResult<import('../../shared/types').WorktreeMergeResult>>;
|
||||
discardWorktree: (taskId: string) => Promise<IPCResult<import('../../shared/types').WorktreeDiscardResult>>;
|
||||
discardWorktree: (taskId: string, skipStatusChange?: boolean) => Promise<IPCResult<import('../../shared/types').WorktreeDiscardResult>>;
|
||||
clearStagedState: (taskId: string) => Promise<IPCResult<{ cleared: boolean }>>;
|
||||
listWorktrees: (projectId: string) => Promise<IPCResult<import('../../shared/types').WorktreeListResult>>;
|
||||
worktreeOpenInIDE: (worktreePath: string, ide: SupportedIDE, customPath?: string) => Promise<IPCResult<{ opened: boolean }>>;
|
||||
worktreeOpenInTerminal: (worktreePath: string, terminal: SupportedTerminal, customPath?: string) => Promise<IPCResult<{ opened: boolean }>>;
|
||||
@@ -142,8 +143,11 @@ export const createTaskAPI = (): TaskAPI => ({
|
||||
mergeWorktreePreview: (taskId: string): Promise<IPCResult<import('../../shared/types').WorktreeMergeResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_MERGE_PREVIEW, taskId),
|
||||
|
||||
discardWorktree: (taskId: string): Promise<IPCResult<import('../../shared/types').WorktreeDiscardResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_DISCARD, taskId),
|
||||
discardWorktree: (taskId: string, skipStatusChange?: boolean): Promise<IPCResult<import('../../shared/types').WorktreeDiscardResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_DISCARD, taskId, skipStatusChange),
|
||||
|
||||
clearStagedState: (taskId: string): Promise<IPCResult<{ cleared: boolean }>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_CLEAR_STAGED_STATE, taskId),
|
||||
|
||||
listWorktrees: (projectId: string): Promise<IPCResult<import('../../shared/types').WorktreeListResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_LIST_WORKTREES, projectId),
|
||||
|
||||
@@ -19,10 +19,18 @@ import {
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, RefreshCw } from 'lucide-react';
|
||||
import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, RefreshCw, Trash2, FolderCheck } from 'lucide-react';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Button } from './ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from './ui/alert-dialog';
|
||||
import { TaskCard } from './TaskCard';
|
||||
import { SortableTaskCard } from './SortableTaskCard';
|
||||
import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants';
|
||||
@@ -336,6 +344,11 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
const [overColumnId, setOverColumnId] = useState<string | null>(null);
|
||||
const { showArchived, toggleShowArchived } = useViewState();
|
||||
|
||||
// Worktree cleanup dialog state
|
||||
const [worktreeDialogOpen, setWorktreeDialogOpen] = useState(false);
|
||||
const [pendingDoneTask, setPendingDoneTask] = useState<Task | null>(null);
|
||||
const [isCleaningUp, setIsCleaningUp] = useState(false);
|
||||
|
||||
// Calculate archived count for Done column button
|
||||
const archivedCount = useMemo(() =>
|
||||
tasks.filter(t => t.metadata?.archivedAt).length,
|
||||
@@ -439,7 +452,39 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
// Check if a task has a worktree (async check)
|
||||
const checkTaskHasWorktree = async (taskId: string): Promise<boolean> => {
|
||||
try {
|
||||
const result = await window.electronAPI.getWorktreeStatus(taskId);
|
||||
return result.success && result.data?.exists === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle moving task to done with worktree cleanup option
|
||||
const handleMoveToDone = async (task: Task, deleteWorktree: boolean) => {
|
||||
setIsCleaningUp(true);
|
||||
try {
|
||||
if (deleteWorktree) {
|
||||
// Delete worktree first, skip automatic status change to backlog
|
||||
// since we're about to set status to 'done'
|
||||
const result = await window.electronAPI.discardWorktree(task.id, true);
|
||||
if (!result.success) {
|
||||
console.error('Failed to delete worktree:', result.error);
|
||||
// Continue anyway - user can clean up manually
|
||||
}
|
||||
}
|
||||
// Mark as done
|
||||
await persistTaskStatus(task.id, 'done');
|
||||
} finally {
|
||||
setIsCleaningUp(false);
|
||||
setWorktreeDialogOpen(false);
|
||||
setPendingDoneTask(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveTask(null);
|
||||
setOverColumnId(null);
|
||||
@@ -449,27 +494,38 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
const activeTaskId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
// Check if dropped on a column
|
||||
// Determine target status
|
||||
let targetStatus: TaskStatus | null = null;
|
||||
|
||||
if (isValidDropColumn(overId)) {
|
||||
const newStatus = overId;
|
||||
const task = tasks.find((t) => t.id === activeTaskId);
|
||||
|
||||
if (task && task.status !== newStatus) {
|
||||
// Persist status change to file and update local state
|
||||
persistTaskStatus(activeTaskId, newStatus);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if dropped on another task - move to that task's column
|
||||
const overTask = tasks.find((t) => t.id === overId);
|
||||
if (overTask) {
|
||||
const task = tasks.find((t) => t.id === activeTaskId);
|
||||
if (task && task.status !== overTask.status) {
|
||||
// Persist status change to file and update local state
|
||||
persistTaskStatus(activeTaskId, overTask.status);
|
||||
targetStatus = overId;
|
||||
} else {
|
||||
// Dropped on another task - get its column
|
||||
const overTask = tasks.find((t) => t.id === overId);
|
||||
if (overTask) {
|
||||
targetStatus = overTask.status;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetStatus) return;
|
||||
|
||||
const task = tasks.find((t) => t.id === activeTaskId);
|
||||
if (!task || task.status === targetStatus) return;
|
||||
|
||||
// Special handling for moving to "done" - check for worktree
|
||||
if (targetStatus === 'done') {
|
||||
const hasWorktree = await checkTaskHasWorktree(task.id);
|
||||
|
||||
if (hasWorktree) {
|
||||
// Show dialog asking about worktree cleanup
|
||||
setPendingDoneTask(task);
|
||||
setWorktreeDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Normal status change
|
||||
persistTaskStatus(activeTaskId, targetStatus);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -524,6 +580,73 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
{/* Worktree cleanup confirmation dialog */}
|
||||
<AlertDialog open={worktreeDialogOpen} onOpenChange={setWorktreeDialogOpen}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<FolderCheck className="h-5 w-5 text-primary" />
|
||||
{t('kanban.worktreeCleanupTitle', 'Worktree Cleanup')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="text-left space-y-2">
|
||||
{pendingDoneTask?.stagedInMainProject ? (
|
||||
<p>
|
||||
{t('kanban.worktreeCleanupStaged', 'This task has been staged and has a worktree. Would you like to clean up the worktree?')}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{t('kanban.worktreeCleanupNotStaged', 'This task has a worktree with changes that have not been merged. Delete the worktree to mark as done, or cancel to review the changes first.')}
|
||||
</p>
|
||||
)}
|
||||
{pendingDoneTask && (
|
||||
<p className="text-sm font-medium text-foreground/80 bg-muted/50 rounded px-2 py-1.5">
|
||||
{pendingDoneTask.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setWorktreeDialogOpen(false);
|
||||
setPendingDoneTask(null);
|
||||
}}
|
||||
disabled={isCleaningUp}
|
||||
>
|
||||
{t('common:cancel', 'Cancel')}
|
||||
</Button>
|
||||
{/* Only show "Keep Worktree" option if task is staged */}
|
||||
{pendingDoneTask?.stagedInMainProject && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => pendingDoneTask && handleMoveToDone(pendingDoneTask, false)}
|
||||
disabled={isCleaningUp}
|
||||
className="gap-2"
|
||||
>
|
||||
<FolderCheck className="h-4 w-4" />
|
||||
{t('kanban.keepWorktree', 'Keep Worktree')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={pendingDoneTask?.stagedInMainProject ? 'default' : 'destructive'}
|
||||
onClick={() => pendingDoneTask && handleMoveToDone(pendingDoneTask, true)}
|
||||
disabled={isCleaningUp}
|
||||
className="gap-2"
|
||||
>
|
||||
{isCleaningUp ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
{t('kanban.deleteWorktree', 'Delete Worktree & Mark Done')}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -526,6 +526,7 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
|
||||
onClose={handleClose}
|
||||
onSwitchToTerminals={onSwitchToTerminals}
|
||||
onOpenInbuiltTerminal={onOpenInbuiltTerminal}
|
||||
onReviewAgain={state.handleReviewAgain}
|
||||
showPRDialog={state.showPRDialog}
|
||||
isCreatingPR={state.isCreatingPR}
|
||||
onShowPRDialog={state.setShowPRDialog}
|
||||
|
||||
@@ -43,6 +43,7 @@ interface TaskReviewProps {
|
||||
onClose?: () => void;
|
||||
onSwitchToTerminals?: () => void;
|
||||
onOpenInbuiltTerminal?: (id: string, cwd: string) => void;
|
||||
onReviewAgain?: () => void;
|
||||
// PR creation
|
||||
showPRDialog: boolean;
|
||||
isCreatingPR: boolean;
|
||||
@@ -90,6 +91,7 @@ export function TaskReview({
|
||||
onClose,
|
||||
onSwitchToTerminals,
|
||||
onOpenInbuiltTerminal,
|
||||
onReviewAgain,
|
||||
showPRDialog,
|
||||
isCreatingPR,
|
||||
onShowPRDialog,
|
||||
@@ -108,10 +110,23 @@ export function TaskReview({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Workspace Status - hide if staging was successful (worktree is deleted after staging) */}
|
||||
{/* Workspace Status - priority: loading > fresh staging success > already staged (persisted) > worktree exists > no workspace */}
|
||||
{isLoadingWorktree ? (
|
||||
<LoadingMessage />
|
||||
) : worktreeStatus?.exists && !stagedSuccess ? (
|
||||
) : stagedSuccess ? (
|
||||
/* Fresh staging just completed - StagedSuccessMessage is rendered above */
|
||||
null
|
||||
) : task.stagedInMainProject ? (
|
||||
/* Task was previously staged (persisted state) - show even if worktree still exists */
|
||||
<StagedInProjectMessage
|
||||
task={task}
|
||||
projectPath={stagedProjectPath}
|
||||
hasWorktree={worktreeStatus?.exists || false}
|
||||
onClose={onClose}
|
||||
onReviewAgain={onReviewAgain}
|
||||
/>
|
||||
) : worktreeStatus?.exists ? (
|
||||
/* Worktree exists but not yet staged - show staging UI */
|
||||
<WorkspaceStatus
|
||||
worktreeStatus={worktreeStatus}
|
||||
workspaceError={workspaceError}
|
||||
@@ -132,13 +147,6 @@ export function TaskReview({
|
||||
onSwitchToTerminals={onSwitchToTerminals}
|
||||
onOpenInbuiltTerminal={onOpenInbuiltTerminal}
|
||||
/>
|
||||
) : task.stagedInMainProject && !stagedSuccess ? (
|
||||
<StagedInProjectMessage
|
||||
task={task}
|
||||
projectPath={stagedProjectPath}
|
||||
hasWorktree={worktreeStatus?.exists || false}
|
||||
onClose={onClose}
|
||||
/>
|
||||
) : (
|
||||
<NoWorkspaceMessage task={task} onClose={onClose} />
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import { checkTaskRunning, isIncompleteHumanReview, getTaskProgress, useTaskStore } from '../../../stores/task-store';
|
||||
import { checkTaskRunning, isIncompleteHumanReview, getTaskProgress, useTaskStore, loadTasks } from '../../../stores/task-store';
|
||||
import type { Task, TaskLogs, TaskLogPhase, WorktreeStatus, WorktreeDiff, MergeConflict, MergeStats, GitConflictInfo } from '../../../../shared/types';
|
||||
|
||||
/**
|
||||
@@ -282,6 +282,46 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
|
||||
}
|
||||
}, [task.id]);
|
||||
|
||||
// Handle "Review Again" - clears staged state and reloads worktree info
|
||||
const handleReviewAgain = useCallback(async () => {
|
||||
// Clear staged success state if it was set in this session
|
||||
setStagedSuccess(null);
|
||||
setStagedProjectPath(undefined);
|
||||
setSuggestedCommitMessage(undefined);
|
||||
|
||||
// Reset merge preview to force re-check
|
||||
setMergePreview(null);
|
||||
hasLoadedPreviewRef.current = null;
|
||||
|
||||
// Reset workspace error state
|
||||
setWorkspaceError(null);
|
||||
|
||||
// Reload worktree status
|
||||
setIsLoadingWorktree(true);
|
||||
try {
|
||||
const [statusResult, diffResult] = await Promise.all([
|
||||
window.electronAPI.getWorktreeStatus(task.id),
|
||||
window.electronAPI.getWorktreeDiff(task.id)
|
||||
]);
|
||||
if (statusResult.success && statusResult.data) {
|
||||
setWorktreeStatus(statusResult.data);
|
||||
}
|
||||
if (diffResult.success && diffResult.data) {
|
||||
setWorktreeDiff(diffResult.data);
|
||||
}
|
||||
|
||||
// Reload task data from store to reflect cleared staged state
|
||||
// (clearStagedState IPC already invalidated the cache)
|
||||
if (selectedProject) {
|
||||
await loadTasks(selectedProject.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to reload worktree info:', err);
|
||||
} finally {
|
||||
setIsLoadingWorktree(false);
|
||||
}
|
||||
}, [task.id, selectedProject]);
|
||||
|
||||
// NOTE: Merge preview is NO LONGER auto-loaded on modal open.
|
||||
// User must click "Check for Conflicts" button to trigger the expensive preview operation.
|
||||
// This improves modal open performance significantly (avoids 1-30+ second Python subprocess).
|
||||
@@ -442,6 +482,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
|
||||
handleLogsScroll,
|
||||
togglePhase,
|
||||
loadMergePreview,
|
||||
handleReviewAgain,
|
||||
reloadPlanForIncompleteTask,
|
||||
};
|
||||
}
|
||||
|
||||
+130
-13
@@ -1,4 +1,4 @@
|
||||
import { AlertCircle, GitMerge, Loader2, Trash2, Check } from 'lucide-react';
|
||||
import { AlertCircle, GitMerge, Loader2, Check, RotateCcw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../../ui/button';
|
||||
import { persistTaskStatus } from '../../../stores/task-store';
|
||||
@@ -89,13 +89,16 @@ interface StagedInProjectMessageProps {
|
||||
projectPath?: string;
|
||||
hasWorktree?: boolean;
|
||||
onClose?: () => void;
|
||||
onReviewAgain?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays message when changes have already been staged in the main project
|
||||
*/
|
||||
export function StagedInProjectMessage({ task, projectPath, hasWorktree = false, onClose }: StagedInProjectMessageProps) {
|
||||
export function StagedInProjectMessage({ task, projectPath, hasWorktree = false, onClose, onReviewAgain }: StagedInProjectMessageProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isMarkingDone, setIsMarkingDone] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleDeleteWorktreeAndMarkDone = async () => {
|
||||
@@ -124,6 +127,46 @@ export function StagedInProjectMessage({ task, projectPath, hasWorktree = false,
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkDoneOnly = async () => {
|
||||
setIsMarkingDone(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await persistTaskStatus(task.id, 'done');
|
||||
onClose?.();
|
||||
} catch (err) {
|
||||
console.error('Error marking task as done:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to mark as done');
|
||||
} finally {
|
||||
setIsMarkingDone(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReviewAgain = async () => {
|
||||
if (!onReviewAgain) return;
|
||||
|
||||
setIsResetting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Clear the staged flag via IPC
|
||||
const result = await window.electronAPI.clearStagedState(task.id);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Failed to reset staged state');
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger re-render by calling parent callback
|
||||
onReviewAgain();
|
||||
} catch (err) {
|
||||
console.error('Error resetting staged state:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to reset staged state');
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-success/30 bg-success/10 p-4">
|
||||
<h3 className="font-medium text-sm text-foreground mb-2 flex items-center gap-2">
|
||||
@@ -143,12 +186,13 @@ export function StagedInProjectMessage({ task, projectPath, hasWorktree = false,
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{hasWorktree && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
{/* Primary action: Mark Done or Delete Worktree & Mark Done */}
|
||||
{hasWorktree ? (
|
||||
<Button
|
||||
onClick={handleDeleteWorktreeAndMarkDone}
|
||||
disabled={isDeleting}
|
||||
disabled={isDeleting || isMarkingDone || isResetting}
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="flex-1"
|
||||
@@ -165,15 +209,88 @@ export function StagedInProjectMessage({ task, projectPath, hasWorktree = false,
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleMarkDoneOnly}
|
||||
disabled={isDeleting || isMarkingDone || isResetting}
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="flex-1"
|
||||
>
|
||||
{isMarkingDone ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Marking done...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Mark as Done
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This will delete the isolated workspace and mark the task as complete.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Secondary actions row */}
|
||||
<div className="flex gap-2">
|
||||
{/* Mark Done Only (when worktree exists) - allows keeping worktree */}
|
||||
{hasWorktree && (
|
||||
<Button
|
||||
onClick={handleMarkDoneOnly}
|
||||
disabled={isDeleting || isMarkingDone || isResetting}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
{isMarkingDone ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Marking done...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Mark Done Only
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Review Again button - only show if worktree exists and callback provided */}
|
||||
{hasWorktree && onReviewAgain && (
|
||||
<Button
|
||||
onClick={handleReviewAgain}
|
||||
disabled={isDeleting || isMarkingDone || isResetting}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
{isResetting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Resetting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Review Again
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
{hasWorktree && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
"Delete Worktree & Mark Done" cleans up the isolated workspace. "Mark Done Only" keeps it for reference.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@ export const workspaceMock = {
|
||||
}
|
||||
}),
|
||||
|
||||
clearStagedState: async () => ({
|
||||
success: true,
|
||||
data: { cleared: true }
|
||||
}),
|
||||
|
||||
listWorktrees: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
|
||||
@@ -42,6 +42,7 @@ export const IPC_CHANNELS = {
|
||||
TASK_LIST_WORKTREES: 'task:listWorktrees',
|
||||
TASK_ARCHIVE: 'task:archive',
|
||||
TASK_UNARCHIVE: 'task:unarchive',
|
||||
TASK_CLEAR_STAGED_STATE: 'task:clearStagedState',
|
||||
|
||||
// Task events (main -> renderer)
|
||||
TASK_PROGRESS: 'task:progress',
|
||||
|
||||
@@ -76,7 +76,12 @@
|
||||
"addTaskAriaLabel": "Add new task to backlog",
|
||||
"closeTaskDetailsAriaLabel": "Close task details",
|
||||
"editTask": "Edit task",
|
||||
"cannotEditWhileRunning": "Cannot edit while task is running"
|
||||
"cannotEditWhileRunning": "Cannot edit while task is running",
|
||||
"worktreeCleanupTitle": "Worktree Cleanup",
|
||||
"worktreeCleanupStaged": "This task has been staged and has a worktree. Would you like to clean up the worktree?",
|
||||
"worktreeCleanupNotStaged": "This task has a worktree with changes that have not been merged. Delete the worktree to mark as done, or cancel to review the changes first.",
|
||||
"keepWorktree": "Keep Worktree",
|
||||
"deleteWorktree": "Delete Worktree & Mark Done"
|
||||
},
|
||||
"execution": {
|
||||
"phases": {
|
||||
|
||||
@@ -76,7 +76,12 @@
|
||||
"addTaskAriaLabel": "Ajouter une nouvelle tâche au backlog",
|
||||
"closeTaskDetailsAriaLabel": "Fermer les détails de la tâche",
|
||||
"editTask": "Modifier la tâche",
|
||||
"cannotEditWhileRunning": "Impossible de modifier pendant l'exécution"
|
||||
"cannotEditWhileRunning": "Impossible de modifier pendant l'exécution",
|
||||
"worktreeCleanupTitle": "Nettoyage du Worktree",
|
||||
"worktreeCleanupStaged": "Cette tâche a été préparée et possède un worktree. Voulez-vous nettoyer le worktree ?",
|
||||
"worktreeCleanupNotStaged": "Cette tâche possède un worktree avec des changements non fusionnés. Supprimez le worktree pour marquer comme terminé, ou annulez pour réviser les changements d'abord.",
|
||||
"keepWorktree": "Garder le Worktree",
|
||||
"deleteWorktree": "Supprimer le Worktree & Marquer Terminé"
|
||||
},
|
||||
"execution": {
|
||||
"phases": {
|
||||
|
||||
@@ -170,7 +170,8 @@ export interface ElectronAPI {
|
||||
mergeWorktree: (taskId: string, options?: { noCommit?: boolean }) => Promise<IPCResult<WorktreeMergeResult>>;
|
||||
mergeWorktreePreview: (taskId: string) => Promise<IPCResult<WorktreeMergeResult>>;
|
||||
createWorktreePR: (taskId: string, options?: WorktreeCreatePROptions) => Promise<IPCResult<WorktreeCreatePRResult>>;
|
||||
discardWorktree: (taskId: string) => Promise<IPCResult<WorktreeDiscardResult>>;
|
||||
discardWorktree: (taskId: string, skipStatusChange?: boolean) => Promise<IPCResult<WorktreeDiscardResult>>;
|
||||
clearStagedState: (taskId: string) => Promise<IPCResult<{ cleared: boolean }>>;
|
||||
listWorktrees: (projectId: string) => Promise<IPCResult<WorktreeListResult>>;
|
||||
worktreeOpenInIDE: (worktreePath: string, ide: SupportedIDE, customPath?: string) => Promise<IPCResult<{ opened: boolean }>>;
|
||||
worktreeOpenInTerminal: (worktreePath: string, terminal: SupportedTerminal, customPath?: string) => Promise<IPCResult<{ opened: boolean }>>;
|
||||
|
||||
Reference in New Issue
Block a user