diff --git a/apps/frontend/src/renderer/components/BulkPRDialog.tsx b/apps/frontend/src/renderer/components/BulkPRDialog.tsx new file mode 100644 index 00000000..290b32ce --- /dev/null +++ b/apps/frontend/src/renderer/components/BulkPRDialog.tsx @@ -0,0 +1,452 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { + GitPullRequest, + Loader2, + ExternalLink, + CheckCircle2, + XCircle, + AlertTriangle, + MinusCircle, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/dialog'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { Checkbox } from './ui/checkbox'; +import { Progress } from './ui/progress'; +import { ScrollArea } from './ui/scroll-area'; +import type { Task, WorktreeCreatePRResult } from '../../shared/types'; + +/** + * Check if an error message indicates a worktree-related issue (missing worktree, no branch, etc.) + * This is used to show 'skipped' status instead of 'error' for tasks without worktrees. + * + * TODO: This string-based error detection is brittle. The API should ideally return typed error codes + * instead of relying on message parsing which may break with i18n or message changes. + */ +function isWorktreeRelatedError(errorMsg: string): boolean { + const lowerMsg = errorMsg.toLowerCase(); + return lowerMsg.includes('worktree') || + lowerMsg.includes('no branch') || + lowerMsg.includes('not found'); +} + +/** + * Result for a single task in the bulk PR creation + */ +interface TaskPRResult { + taskId: string; + taskTitle: string; + status: 'pending' | 'creating' | 'success' | 'skipped' | 'error'; + result?: WorktreeCreatePRResult; + error?: string; + alreadyExists?: boolean; +} + +interface BulkPRDialogProps { + open: boolean; + tasks: Task[]; + onOpenChange: (open: boolean) => void; + onComplete?: () => void; +} + +/** + * Dialog for creating Pull Requests for multiple tasks in bulk + * Shows progress tracking and results per task + */ +export function BulkPRDialog({ + open, + tasks, + onOpenChange, + onComplete +}: BulkPRDialogProps) { + const { t } = useTranslation(['taskReview', 'common', 'tasks']); + + // Common options for all PRs + const [targetBranch, setTargetBranch] = useState(''); + const [isDraft, setIsDraft] = useState(false); + + // Progress tracking + const [step, setStep] = useState<'options' | 'creating' | 'results'>('options'); + const [taskResults, setTaskResults] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const isCancelledRef = useRef(false); + + const prevOpenRef = useRef(open); + + // Only reset when transitioning closed→open (not on tasks array changes during async operation) + useEffect(() => { + const wasOpen = prevOpenRef.current; + prevOpenRef.current = open; + + if (open && !wasOpen) { + setTargetBranch(''); + setIsDraft(false); + setStep('options'); + setCurrentIndex(0); + isCancelledRef.current = false; + setTaskResults(tasks.map(task => ({ + taskId: task.id, + taskTitle: task.title, + status: 'pending' + }))); + } + }, [open, tasks]); + + // Validation + const validateBranchName = useCallback((branch: string): string | null => { + if (!branch.trim()) return null; // Empty is OK, will use default + if (!/^[a-zA-Z0-9/_-]+$/.test(branch)) { + return t('taskReview:pr.errors.invalidBranchName'); + } + return null; + }, [t]); + + const handleCreatePRs = useCallback(async () => { + const branchError = validateBranchName(targetBranch); + if (branchError) { + return; + } + + setStep('creating'); + isCancelledRef.current = false; + + const results: TaskPRResult[] = tasks.map(task => ({ + taskId: task.id, + taskTitle: task.title, + status: 'pending' as const + })); + setTaskResults(results); + + for (let i = 0; i < tasks.length; i++) { + if (isCancelledRef.current) break; + + setCurrentIndex(i); + + setTaskResults(prev => prev.map((r, idx) => + idx === i ? { ...r, status: 'creating' as const } : r + )); + + try { + const prResult = await window.electronAPI?.createWorktreePR(tasks[i].id, { + targetBranch: targetBranch || undefined, + draft: isDraft + }); + + if (isCancelledRef.current) break; + + if (prResult?.success && prResult.data) { + const data = prResult.data; + setTaskResults(prev => prev.map((r, idx) => + idx === i ? { + ...r, + status: data.success ? 'success' as const : 'error' as const, + result: data, + alreadyExists: data.alreadyExists, + error: data.success ? undefined : (data.error || t('taskReview:pr.errors.unknown')) + } : r + )); + } else { + const errorMsg = prResult?.error || ''; + setTaskResults(prev => prev.map((r, idx) => + idx === i ? { + ...r, + status: isWorktreeRelatedError(errorMsg) ? 'skipped' as const : 'error' as const, + error: isWorktreeRelatedError(errorMsg) + ? t('taskReview:bulkPR.noWorktree') + : (prResult?.error || t('taskReview:pr.errors.unknown')) + } : r + )); + } + } catch (err) { + if (isCancelledRef.current) break; + + const errorMsg = err instanceof Error ? err.message : ''; + setTaskResults(prev => prev.map((r, idx) => + idx === i ? { + ...r, + status: isWorktreeRelatedError(errorMsg) ? 'skipped' as const : 'error' as const, + error: isWorktreeRelatedError(errorMsg) + ? t('taskReview:bulkPR.noWorktree') + : (err instanceof Error ? err.message : t('taskReview:pr.errors.unknown')) + } : r + )); + } + } + + if (!isCancelledRef.current) { + setStep('results'); + } + }, [tasks, targetBranch, isDraft, t, validateBranchName]); + + const handleClose = () => { + isCancelledRef.current = true; + if (step === 'results' && onComplete) { + onComplete(); + } + onOpenChange(false); + }; + + const handleOpenPR = (url: string) => { + if (window.electronAPI?.openExternal) { + window.electronAPI.openExternal(url); + } + }; + + // Calculate progress + const completedCount = taskResults.filter(r => r.status === 'success' || r.status === 'error' || r.status === 'skipped').length; + const successCount = taskResults.filter(r => r.status === 'success').length; + const errorCount = taskResults.filter(r => r.status === 'error').length; + const skippedCount = taskResults.filter(r => r.status === 'skipped').length; + const progress = tasks.length > 0 ? (completedCount / tasks.length) * 100 : 0; + + return ( + + + + + + {t('taskReview:bulkPR.title')} + + + {step === 'options' && t('taskReview:bulkPR.description', { count: tasks.length })} + {step === 'creating' && t('taskReview:bulkPR.creating', { current: currentIndex + 1, total: tasks.length })} + {step === 'results' && (skippedCount > 0 + ? t('taskReview:bulkPR.resultsDescriptionWithSkipped', { success: successCount, skipped: skippedCount, failed: errorCount }) + : t('taskReview:bulkPR.resultsDescription', { success: successCount, failed: errorCount }) + )} + + + + {/* Options Step */} + {step === 'options' && ( +
+ {/* Task List Preview */} +
+ + +
+ {tasks.map((task, idx) => ( +
+ {idx + 1}. + {task.title} +
+ ))} +
+
+
+ + {/* Common Options */} +
+
+ + setTargetBranch(e.target.value)} + placeholder="main" + /> +

+ {t('taskReview:bulkPR.targetBranchHint')} +

+
+ +
+ setIsDraft(checked === true)} + /> + +
+
+ + + + + +
+ )} + + {/* Creating Step */} + {step === 'creating' && ( +
+
+ +
+

+ {t('taskReview:bulkPR.creatingPR', { current: currentIndex + 1, total: tasks.length })} +

+

+ {tasks[currentIndex]?.title} +

+
+
+ +
+ +

+ {completedCount} / {tasks.length} {t('taskReview:bulkPR.completed')} +

+
+ + {/* Task Status List */} + +
+ {taskResults.map((result, idx) => ( + + ))} +
+
+
+ )} + + {/* Results Step */} + {step === 'results' && ( +
+ {/* Summary */} +
+ {successCount > 0 && ( +
+ + {successCount} {t('taskReview:bulkPR.succeeded')} +
+ )} + {skippedCount > 0 && ( +
+ + {skippedCount} {t('taskReview:bulkPR.skipped')} +
+ )} + {errorCount > 0 && ( +
+ + {errorCount} {t('taskReview:bulkPR.failed')} +
+ )} +
+ + {/* Results List */} + +
+ {taskResults.map((result, idx) => ( + + ))} +
+
+ + + + +
+ )} +
+
+ ); +} + +/** + * Individual task result row component + */ +interface TaskResultRowProps { + result: TaskPRResult; + index: number; + showDetails?: boolean; + onOpenPR?: (url: string) => void; +} + +function TaskResultRow({ result, index, showDetails, onOpenPR }: TaskResultRowProps) { + const { t } = useTranslation(['taskReview']); + + const getStatusIcon = () => { + switch (result.status) { + case 'pending': + return
; + case 'creating': + return ; + case 'success': + // Show warning icon for already exists case + return result.alreadyExists + ? + : ; + case 'skipped': + return ; + case 'error': + return ; + } + }; + + return ( +
+
+ {getStatusIcon()} +
+
+
+ {index + 1}. + {result.taskTitle} +
+ + {showDetails && result.status === 'success' && result.result?.prUrl && ( + + )} + + {showDetails && result.status === 'skipped' && result.error && ( +
+ + {result.error} +
+ )} + + {showDetails && result.status === 'error' && result.error && ( +
+ + {result.error} +
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/renderer/components/KanbanBoard.tsx b/apps/frontend/src/renderer/components/KanbanBoard.tsx index 05731f44..5e225e98 100644 --- a/apps/frontend/src/renderer/components/KanbanBoard.tsx +++ b/apps/frontend/src/renderer/components/KanbanBoard.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, memo } from 'react'; +import { useState, useMemo, memo, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useViewState } from '../contexts/ViewStateContext'; import { @@ -19,7 +19,8 @@ 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, GitPullRequest, X } from 'lucide-react'; +import { Checkbox } from './ui/checkbox'; import { ScrollArea } from './ui/scroll-area'; import { Button } from './ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; @@ -30,6 +31,7 @@ import { cn } from '../lib/utils'; import { persistTaskStatus, forceCompleteTask, archiveTasks } from '../stores/task-store'; import { useToast } from '../hooks/use-toast'; import { WorktreeCleanupDialog } from './WorktreeCleanupDialog'; +import { BulkPRDialog } from './BulkPRDialog'; import type { Task, TaskStatus } from '../../shared/types'; // Type guard for valid drop column targets - preserves literal type from TASK_STATUS_COLUMNS @@ -57,6 +59,11 @@ interface DroppableColumnProps { archivedCount?: number; showArchived?: boolean; onToggleArchived?: () => void; + // Selection props for human_review column + selectedTaskIds?: Set; + onSelectAll?: () => void; + onDeselectAll?: () => void; + onToggleSelect?: (taskId: string) => void; } /** @@ -100,6 +107,20 @@ function droppableColumnPropsAreEqual( if (prevProps.archivedCount !== nextProps.archivedCount) return false; if (prevProps.showArchived !== nextProps.showArchived) return false; if (prevProps.onToggleArchived !== nextProps.onToggleArchived) return false; + if (prevProps.onSelectAll !== nextProps.onSelectAll) return false; + if (prevProps.onDeselectAll !== nextProps.onDeselectAll) return false; + if (prevProps.onToggleSelect !== nextProps.onToggleSelect) return false; + + // Compare selectedTaskIds Set + if (prevProps.selectedTaskIds !== nextProps.selectedTaskIds) { + // If one is undefined and other isn't, different + if (!prevProps.selectedTaskIds || !nextProps.selectedTaskIds) return false; + // Compare Set contents + if (prevProps.selectedTaskIds.size !== nextProps.selectedTaskIds.size) return false; + for (const id of prevProps.selectedTaskIds) { + if (!nextProps.selectedTaskIds.has(id)) return false; + } + } // Deep compare tasks const tasksEqual = tasksAreEquivalent(prevProps.tasks, nextProps.tasks); @@ -153,12 +174,35 @@ const getEmptyStateContent = (status: TaskStatus, t: (key: string) => string): { } }; -const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskClick, onStatusChange, isOver, onAddClick, onArchiveAll, archivedCount, showArchived, onToggleArchived }: DroppableColumnProps) { +const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskClick, onStatusChange, isOver, onAddClick, onArchiveAll, archivedCount, showArchived, onToggleArchived, selectedTaskIds, onSelectAll, onDeselectAll, onToggleSelect }: DroppableColumnProps) { const { t } = useTranslation(['tasks', 'common']); const { setNodeRef } = useDroppable({ id: status }); + // Calculate selection state for human_review column + const isHumanReview = status === 'human_review'; + const selectedCount = selectedTaskIds?.size ?? 0; + const taskCount = tasks.length; + const isAllSelected = isHumanReview && taskCount > 0 && selectedCount === taskCount; + const isSomeSelected = isHumanReview && selectedCount > 0 && selectedCount < taskCount; + + // Determine checkbox checked state: true (all), 'indeterminate' (some), false (none) + const selectAllCheckedState: boolean | 'indeterminate' = isAllSelected + ? true + : isSomeSelected + ? 'indeterminate' + : false; + + // Handle select all checkbox change + const handleSelectAllChange = useCallback(() => { + if (isAllSelected) { + onDeselectAll?.(); + } else { + onSelectAll?.(); + } + }, [isAllSelected, onSelectAll, onDeselectAll]); + // Memoize taskIds to prevent SortableContext from re-rendering unnecessarily const taskIds = useMemo(() => tasks.map((t) => t.id), [tasks]); @@ -180,18 +224,32 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli return handlers; }, [tasks, onStatusChange]); + // Create stable onToggleSelect handlers for each task (only for human_review column) + const onToggleSelectHandlers = useMemo(() => { + if (!onToggleSelect) return null; + const handlers = new Map void>(); + tasks.forEach((task) => { + handlers.set(task.id, () => onToggleSelect(task.id)); + }); + return handlers; + }, [tasks, onToggleSelect]); + // Memoize task card elements to prevent recreation on every render const taskCards = useMemo(() => { if (tasks.length === 0) return null; + const isSelectable = !!onToggleSelectHandlers; return tasks.map((task) => ( )); - }, [tasks, onClickHandlers, onStatusChangeHandlers]); + }, [tasks, onClickHandlers, onStatusChangeHandlers, onToggleSelectHandlers, selectedTaskIds]); const getColumnBorderColor = (): string => { switch (status) { @@ -225,6 +283,25 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli {/* Column header - enhanced styling */}
+ {/* Select All checkbox for human_review column */} + {isHumanReview && onSelectAll && onDeselectAll && ( + + +
+ +
+
+ + {isAllSelected ? t('kanban.deselectAll') : t('kanban.selectAll')} + +
+ )}

{t(TASK_STATUS_LABELS[status])}

@@ -339,6 +416,12 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR const [overColumnId, setOverColumnId] = useState(null); const { showArchived, toggleShowArchived } = useViewState(); + // Selection state for bulk actions (Human Review column) + const [selectedTaskIds, setSelectedTaskIds] = useState>(new Set()); + + // Bulk PR dialog state + const [bulkPRDialogOpen, setBulkPRDialogOpen] = useState(false); + // Worktree cleanup dialog state const [worktreeCleanupDialog, setWorktreeCleanupDialog] = useState<{ open: boolean; @@ -411,6 +494,55 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR return grouped; }, [filteredTasks]); + // Prune stale IDs when tasks move out of human_review column + useEffect(() => { + const validIds = new Set(tasksByStatus.human_review.map(t => t.id)); + setSelectedTaskIds(prev => { + const filtered = new Set([...prev].filter(id => validIds.has(id))); + return filtered.size === prev.size ? prev : filtered; + }); + }, [tasksByStatus.human_review]); + + // Selection callbacks for bulk actions (Human Review column) + const toggleTaskSelection = useCallback((taskId: string) => { + setSelectedTaskIds(prev => { + const next = new Set(prev); + if (next.has(taskId)) { + next.delete(taskId); + } else { + next.add(taskId); + } + return next; + }); + }, []); + + const selectAllTasks = useCallback(() => { + const humanReviewTasks = tasksByStatus.human_review; + const allIds = new Set(humanReviewTasks.map(t => t.id)); + setSelectedTaskIds(allIds); + }, [tasksByStatus.human_review]); + + const deselectAllTasks = useCallback(() => { + setSelectedTaskIds(new Set()); + }, []); + + // Get selected task objects for the BulkPRDialog + const selectedTasks = useMemo(() => { + return tasksByStatus.human_review.filter(task => selectedTaskIds.has(task.id)); + }, [tasksByStatus.human_review, selectedTaskIds]); + + // Handle opening the bulk PR dialog + const handleOpenBulkPRDialog = useCallback(() => { + if (selectedTaskIds.size > 0) { + setBulkPRDialogOpen(true); + } + }, [selectedTaskIds.size]); + + // Handle bulk PR dialog completion - clear selection + const handleBulkPRComplete = useCallback(() => { + deselectAllTasks(); + }, [deselectAllTasks]); + const handleArchiveAll = async () => { // Get projectId from the first task (all tasks should have the same projectId) const projectId = tasks[0]?.projectId; @@ -594,6 +726,10 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR archivedCount={status === 'done' ? archivedCount : undefined} showArchived={status === 'done' ? showArchived : undefined} onToggleArchived={status === 'done' ? toggleShowArchived : undefined} + selectedTaskIds={status === 'human_review' ? selectedTaskIds : undefined} + onSelectAll={status === 'human_review' ? selectAllTasks : undefined} + onDeselectAll={status === 'human_review' ? deselectAllTasks : undefined} + onToggleSelect={status === 'human_review' ? toggleTaskSelection : undefined} /> ))}
@@ -608,6 +744,35 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR + {selectedTaskIds.size > 0 && ( +
+
+ + {t('kanban.selectedCountOther', { count: selectedTaskIds.size })} + +
+ + +
+
+ )} + {/* Worktree cleanup confirmation dialog */} + + {/* Bulk PR creation dialog */} +
); } diff --git a/apps/frontend/src/renderer/components/SortableTaskCard.tsx b/apps/frontend/src/renderer/components/SortableTaskCard.tsx index b830a181..1bc21fb2 100644 --- a/apps/frontend/src/renderer/components/SortableTaskCard.tsx +++ b/apps/frontend/src/renderer/components/SortableTaskCard.tsx @@ -9,6 +9,10 @@ interface SortableTaskCardProps { task: Task; onClick: () => void; onStatusChange?: (newStatus: TaskStatus) => unknown; + // Optional selection props for multi-selection in Human Review column + isSelectable?: boolean; + isSelected?: boolean; + onToggleSelect?: () => void; } // Custom comparator - only re-render when task or onClick actually changed @@ -21,11 +25,14 @@ function sortableTaskCardPropsAreEqual( return ( prevProps.task === nextProps.task && prevProps.onClick === nextProps.onClick && - prevProps.onStatusChange === nextProps.onStatusChange + prevProps.onStatusChange === nextProps.onStatusChange && + prevProps.isSelectable === nextProps.isSelectable && + prevProps.isSelected === nextProps.isSelected && + prevProps.onToggleSelect === nextProps.onToggleSelect ); } -export const SortableTaskCard = memo(function SortableTaskCard({ task, onClick, onStatusChange }: SortableTaskCardProps) { +export const SortableTaskCard = memo(function SortableTaskCard({ task, onClick, onStatusChange, isSelectable, isSelected, onToggleSelect }: SortableTaskCardProps) { const { attributes, listeners, @@ -60,7 +67,14 @@ export const SortableTaskCard = memo(function SortableTaskCard({ task, onClick, {...attributes} {...listeners} > - +
); }, sortableTaskCardPropsAreEqual); diff --git a/apps/frontend/src/renderer/components/TaskCard.tsx b/apps/frontend/src/renderer/components/TaskCard.tsx index 3df8acee..a0a87dfa 100644 --- a/apps/frontend/src/renderer/components/TaskCard.tsx +++ b/apps/frontend/src/renderer/components/TaskCard.tsx @@ -4,6 +4,7 @@ import { Play, Square, Clock, Zap, Target, Shield, Gauge, Palette, FileCode, Bug import { Card, CardContent } from './ui/card'; import { Badge } from './ui/badge'; import { Button } from './ui/button'; +import { Checkbox } from './ui/checkbox'; import { DropdownMenu, DropdownMenuContent, @@ -50,6 +51,10 @@ interface TaskCardProps { task: Task; onClick: () => void; onStatusChange?: (newStatus: TaskStatus) => unknown; + // Optional selectable mode props for multi-selection + isSelectable?: boolean; + isSelected?: boolean; + onToggleSelect?: () => void; } // Custom comparator for React.memo - only re-render when relevant task data changes @@ -57,11 +62,26 @@ function taskCardPropsAreEqual(prevProps: TaskCardProps, nextProps: TaskCardProp const prevTask = prevProps.task; const nextTask = nextProps.task; - // Fast path: same reference - if (prevTask === nextTask && prevProps.onClick === nextProps.onClick && prevProps.onStatusChange === nextProps.onStatusChange) { + // Fast path: same reference (include selectable props) + if ( + prevTask === nextTask && + prevProps.onClick === nextProps.onClick && + prevProps.onStatusChange === nextProps.onStatusChange && + prevProps.isSelectable === nextProps.isSelectable && + prevProps.isSelected === nextProps.isSelected && + prevProps.onToggleSelect === nextProps.onToggleSelect + ) { return true; } + // Check selectable props first (cheap comparison) + if ( + prevProps.isSelectable !== nextProps.isSelectable || + prevProps.isSelected !== nextProps.isSelected + ) { + return false; + } + // Compare only the fields that affect rendering const isEqual = ( prevTask.id === nextTask.id && @@ -97,7 +117,14 @@ function taskCardPropsAreEqual(prevProps: TaskCardProps, nextProps: TaskCardProp return isEqual; } -export const TaskCard = memo(function TaskCard({ task, onClick, onStatusChange }: TaskCardProps) { +export const TaskCard = memo(function TaskCard({ + task, + onClick, + onStatusChange, + isSelectable, + isSelected, + onToggleSelect +}: TaskCardProps) { const { t } = useTranslation(['tasks', 'errors']); const [isStuck, setIsStuck] = useState(false); const [isRecovering, setIsRecovering] = useState(false); @@ -333,18 +360,33 @@ export const TaskCard = memo(function TaskCard({ task, onClick, onStatusChange } 'card-surface task-card-enhanced cursor-pointer', isRunning && !isStuck && 'ring-2 ring-primary border-primary task-running-pulse', isStuck && 'ring-2 ring-warning border-warning task-stuck-pulse', - isArchived && 'opacity-60 hover:opacity-80' + isArchived && 'opacity-60 hover:opacity-80', + isSelectable && isSelected && 'ring-2 ring-ring border-ring bg-accent/10' )} onClick={onClick} > - {/* Title - full width, no wrapper */} -

- {displayTitle} -

+
+ {/* Checkbox for selectable mode - stops event propagation */} + {isSelectable && ( +
+ e.stopPropagation()} + aria-label={t('tasks:actions.selectTask', { title: displayTitle })} + /> +
+ )} + +
+ {/* Title - full width, no wrapper */} +

+ {displayTitle} +

{/* Description - sanitized to handle markdown content (memoized) */} {sanitizedDescription && ( @@ -614,6 +656,10 @@ export const TaskCard = memo(function TaskCard({ task, onClick, onStatusChange } )}
+ {/* Close content wrapper for selectable mode */} +
+ {/* Close flex container for selectable mode */} + ); diff --git a/apps/frontend/src/renderer/components/ui/checkbox.tsx b/apps/frontend/src/renderer/components/ui/checkbox.tsx index cecb04d8..1588eed7 100644 --- a/apps/frontend/src/renderer/components/ui/checkbox.tsx +++ b/apps/frontend/src/renderer/components/ui/checkbox.tsx @@ -1,19 +1,21 @@ import * as React from 'react'; import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; -import { Check } from 'lucide-react'; +import { Check, Minus } from 'lucide-react'; import { cn } from '../../lib/utils'; const Checkbox = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +>(({ className, checked, ...props }, ref) => ( - + {checked === 'indeterminate' ? ( + + ) : ( + + )} )); diff --git a/apps/frontend/src/shared/i18n/locales/en/taskReview.json b/apps/frontend/src/shared/i18n/locales/en/taskReview.json index b045ffcb..a884d6d6 100644 --- a/apps/frontend/src/shared/i18n/locales/en/taskReview.json +++ b/apps/frontend/src/shared/i18n/locales/en/taskReview.json @@ -62,5 +62,22 @@ "targetBranch": "Leave empty to use the default branch", "prTitle": "Leave empty to use the task title" } + }, + "bulkPR": { + "title": "Create Pull Requests", + "description": "Create pull requests for {{count}} selected tasks", + "creating": "Creating PR {{current}} of {{total}}...", + "creatingPR": "Creating PR {{current}} of {{total}}", + "resultsDescription": "{{success}} succeeded, {{failed}} failed", + "tasksToProcess": "Tasks to process", + "targetBranchHint": "Leave empty to use each task's default branch. This will be applied to all PRs.", + "createAll": "Create {{count}} PRs", + "completed": "completed", + "succeeded": "succeeded", + "failed": "failed", + "skipped": "skipped", + "alreadyExisted": "already existed", + "noWorktree": "No worktree found for this task", + "resultsDescriptionWithSkipped": "{{success}} succeeded, {{skipped}} skipped, {{failed}} failed" } } diff --git a/apps/frontend/src/shared/i18n/locales/en/tasks.json b/apps/frontend/src/shared/i18n/locales/en/tasks.json index 6bfa9c1d..dea4989a 100644 --- a/apps/frontend/src/shared/i18n/locales/en/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/en/tasks.json @@ -19,7 +19,8 @@ "view": "View Details", "viewPR": "View PR", "moveTo": "Move to", - "taskActions": "Task actions" + "taskActions": "Task actions", + "selectTask": "Select task: {{title}}" }, "labels": { "running": "Running", @@ -83,7 +84,14 @@ "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", - "refreshTasks": "Refresh Tasks" + "refreshTasks": "Refresh Tasks", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "selectedCount": "{{count}} selected", + "selectedCountOne": "{{count}} task selected", + "selectedCountOther": "{{count}} tasks selected", + "createPRs": "Create PRs", + "clearSelection": "Clear Selection" }, "execution": { "phases": { @@ -239,5 +247,14 @@ }, "subtasks": { "untitled": "Untitled subtask" + }, + "bulkPR": { + "selectAllInColumn": "Select all tasks in column", + "deselectAllInColumn": "Deselect all tasks", + "selectionMode": "Selection mode active", + "exitSelectionMode": "Exit selection mode", + "noTasksToSelect": "No tasks available to select", + "confirmBulkAction": "Confirm bulk action for {{count}} tasks", + "processingTasks": "Processing selected tasks..." } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/taskReview.json b/apps/frontend/src/shared/i18n/locales/fr/taskReview.json index b30934f1..46d7f84f 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/taskReview.json +++ b/apps/frontend/src/shared/i18n/locales/fr/taskReview.json @@ -62,5 +62,22 @@ "targetBranch": "Laissez vide pour utiliser la branche par défaut", "prTitle": "Laissez vide pour utiliser le titre de la tâche" } + }, + "bulkPR": { + "title": "Créer des Pull Requests", + "description": "Créer des pull requests pour {{count}} tâches sélectionnées", + "creating": "Création de la PR {{current}} sur {{total}}...", + "creatingPR": "Création de la PR {{current}} sur {{total}}", + "resultsDescription": "{{success}} réussies, {{failed}} échouées", + "tasksToProcess": "Tâches à traiter", + "targetBranchHint": "Laissez vide pour utiliser la branche par défaut de chaque tâche. Ceci sera appliqué à toutes les PRs.", + "createAll": "Créer {{count}} PRs", + "completed": "terminées", + "succeeded": "réussies", + "failed": "échouées", + "skipped": "ignorées", + "alreadyExisted": "existait déjà", + "noWorktree": "Aucun worktree trouvé pour cette tâche", + "resultsDescriptionWithSkipped": "{{success}} réussies, {{skipped}} ignorées, {{failed}} échouées" } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/tasks.json b/apps/frontend/src/shared/i18n/locales/fr/tasks.json index 302335ff..666286d0 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/fr/tasks.json @@ -19,7 +19,8 @@ "view": "Voir les détails", "viewPR": "Voir la PR", "moveTo": "Déplacer vers", - "taskActions": "Actions de la tâche" + "taskActions": "Actions de la tâche", + "selectTask": "Sélectionner la tâche : {{title}}" }, "labels": { "running": "En cours", @@ -83,7 +84,14 @@ "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é", - "refreshTasks": "Actualiser les tâches" + "refreshTasks": "Actualiser les tâches", + "selectAll": "Tout sélectionner", + "deselectAll": "Tout désélectionner", + "selectedCount": "{{count}} sélectionné(s)", + "selectedCountOne": "{{count}} tâche sélectionnée", + "selectedCountOther": "{{count}} tâches sélectionnées", + "createPRs": "Créer les PRs", + "clearSelection": "Effacer la sélection" }, "execution": { "phases": { @@ -239,5 +247,14 @@ }, "subtasks": { "untitled": "Sous-tâche sans titre" + }, + "bulkPR": { + "selectAllInColumn": "Sélectionner toutes les tâches de la colonne", + "deselectAllInColumn": "Désélectionner toutes les tâches", + "selectionMode": "Mode sélection actif", + "exitSelectionMode": "Quitter le mode sélection", + "noTasksToSelect": "Aucune tâche disponible à sélectionner", + "confirmBulkAction": "Confirmer l'action groupée pour {{count}} tâches", + "processingTasks": "Traitement des tâches sélectionnées..." } }