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:
Andy
2026-01-09 20:04:33 +01:00
committed by GitHub
parent 11710c5587
commit 91bd2401cf
12 changed files with 444 additions and 53 deletions
@@ -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
+7 -3
View File
@@ -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,
};
}
@@ -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": {
+2 -1
View File
@@ -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 }>>;