diff --git a/apps/web/app/tasks/columns.tsx b/apps/web/app/tasks/columns.tsx index ee9b915d6a..d7f0618b04 100644 --- a/apps/web/app/tasks/columns.tsx +++ b/apps/web/app/tasks/columns.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import type { Row, ColumnDef } from "@tanstack/react-table"; import type { Task, Workflow, WorkflowStep, Repository } from "@/lib/types/http"; import Link from "next/link"; @@ -8,6 +9,8 @@ import { Button } from "@kandev/ui/button"; import { Badge } from "@kandev/ui/badge"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { formatDistanceToNow } from "date-fns"; +import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog"; +import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog"; type TaskWithResolution = Task & { workflowName?: string; @@ -66,6 +69,8 @@ function ActionsCell({ row, ctx }: { row: Row; ctx: ActionsC const task = row.original; const isDeleting = ctx.deletingTaskId === task.id; const isArchived = !!task.archived_at; + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [showArchiveConfirm, setShowArchiveConfirm] = useState(false); return (
{!isArchived && ( @@ -77,7 +82,7 @@ function ActionsCell({ row, ctx }: { row: Row; ctx: ActionsC className="cursor-pointer h-7 w-7 p-0" onClick={(e) => { e.stopPropagation(); - ctx.onArchive(task.id); + setShowArchiveConfirm(true); }} > @@ -95,7 +100,7 @@ function ActionsCell({ row, ctx }: { row: Row; ctx: ActionsC disabled={isDeleting} onClick={(e) => { e.stopPropagation(); - ctx.onDelete(task.id); + setShowDeleteConfirm(true); }} > {isDeleting ? ( @@ -107,6 +112,19 @@ function ActionsCell({ row, ctx }: { row: Row; ctx: ActionsC Delete + ctx.onDelete(task.id)} + /> + ctx.onArchive(task.id)} + />
); } diff --git a/apps/web/app/tasks/tasks-page-client.tsx b/apps/web/app/tasks/tasks-page-client.tsx index 3b6afe4ac6..b3f7d84303 100644 --- a/apps/web/app/tasks/tasks-page-client.tsx +++ b/apps/web/app/tasks/tasks-page-client.tsx @@ -108,7 +108,6 @@ function useTaskOperations({ setDeletingTaskId(taskId); try { await deleteTask(taskId); - toast({ title: "Task deleted", description: "The task has been deleted successfully." }); fetchTasks(); } catch (err) { toast({ diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 1b61013b48..1ed36e545c 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -13,16 +13,7 @@ import { import { Checkbox } from "@kandev/ui/checkbox"; import { Card, CardContent } from "@kandev/ui/card"; import { Badge } from "@kandev/ui/badge"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@kandev/ui/alert-dialog"; +import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog"; import { DropdownMenu, DropdownMenuContent, @@ -41,7 +32,7 @@ import { needsAction } from "@/lib/utils/needs-action"; import { useAppStore } from "@/components/state-provider"; import { PRTaskIcon } from "@/components/github/pr-task-icon"; import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip"; -import { ArchiveConfirmDialog } from "@/components/task/archive-confirm-dialog"; +import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog"; export interface Task { id: string; @@ -258,48 +249,6 @@ function KanbanCardActions({ ); } -function DeleteConfirmDialog({ - open, - onOpenChange, - taskTitle, - isDeleting, - onConfirm, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - taskTitle: string; - isDeleting?: boolean; - onConfirm: () => void; -}) { - return ( - - e.stopPropagation()}> - - Delete task - - Are you sure you want to delete "{taskTitle}"? This action cannot be undone. - - - - Cancel - { - if (isDeleting) return; - onConfirm(); - onOpenChange(false); - }} - > - {isDeleting ? : null} - Delete - - - - - ); -} - function MoveToSubmenu({ task, steps, @@ -420,14 +369,14 @@ function KanbanCardMenu(props: KanbanCardMenuProps) { - onDelete?.(task)} /> - void; onOpenTask: (task: Task) => void; onDeleteTask: (task: Task) => void; + onArchiveTask?: (task: Task) => void; isMoving?: boolean; isDeleting?: boolean; + isArchiving?: boolean; }; function getStepPhase(index: number, currentStepIndex: number): "past" | "current" | "future" { @@ -97,6 +101,7 @@ function PipelineStepNodes({ onPreviewTask={onPreviewTask} isMoving={isMoving} /> + {connectorType && } ); @@ -105,6 +110,69 @@ function PipelineStepNodes({ ); } +function TaskActions({ + task, + onDeleteTask, + onArchiveTask, + isDeleting, + isArchiving, +}: Pick< + Graph2TaskPipelineProps, + "task" | "onDeleteTask" | "onArchiveTask" | "isDeleting" | "isArchiving" +>) { + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [showArchiveConfirm, setShowArchiveConfirm] = useState(false); + + return ( + <> + + + + + + {onArchiveTask && ( + setShowArchiveConfirm(true)} + disabled={isArchiving} + className="cursor-pointer" + > + + Archive task + + )} + setShowDeleteConfirm(true)} + disabled={isDeleting} + className="text-destructive focus:text-destructive cursor-pointer" + > + + Delete task + + + + onDeleteTask(task)} + /> + onArchiveTask?.(task)} + /> + + ); +} + export function Graph2TaskPipeline({ task, steps, @@ -112,14 +180,15 @@ export function Graph2TaskPipeline({ onPreviewTask, onOpenTask, onDeleteTask, + onArchiveTask, isMoving, isDeleting, + isArchiving, }: Graph2TaskPipelineProps) { const currentStepIndex = useMemo( () => steps.findIndex((s) => s.id === task.workflowStepId), [steps, task.workflowStepId], ); - const hasAction = needsAction(task); const sessionCount = task.sessionCount ?? 0; @@ -152,7 +221,6 @@ export function Graph2TaskPipeline({ )} - - - - - - - - onDeleteTask(task)} - disabled={isDeleting} - className="text-destructive focus:text-destructive cursor-pointer" - > - - Delete task - - - + ); diff --git a/apps/web/components/kanban/swimlane-graph2-content.tsx b/apps/web/components/kanban/swimlane-graph2-content.tsx index d63bee8f40..4a669a5199 100644 --- a/apps/web/components/kanban/swimlane-graph2-content.tsx +++ b/apps/web/components/kanban/swimlane-graph2-content.tsx @@ -12,8 +12,10 @@ export function SwimlaneGraph2Content({ onPreviewTask, onOpenTask, onDeleteTask, + onArchiveTask, onMoveError, deletingTaskId, + archivingTaskId, }: ViewContentProps) { const { moveTask } = useSwimlaneMove(workflowId, { onMoveError, @@ -60,8 +62,10 @@ export function SwimlaneGraph2Content({ onPreviewTask={onPreviewTask} onOpenTask={onOpenTask} onDeleteTask={onDeleteTask} + onArchiveTask={onArchiveTask} isMoving={movingTaskId === task.id} isDeleting={deletingTaskId === task.id} + isArchiving={archivingTaskId === task.id} /> ))} diff --git a/apps/web/components/kanban/task-multi-select-toolbar.tsx b/apps/web/components/kanban/task-multi-select-toolbar.tsx index 24d84d646d..9353667a2e 100644 --- a/apps/web/components/kanban/task-multi-select-toolbar.tsx +++ b/apps/web/components/kanban/task-multi-select-toolbar.tsx @@ -1,18 +1,10 @@ "use client"; import { useState } from "react"; -import { IconLoader, IconTrash, IconArchive, IconChevronRight, IconX } from "@tabler/icons-react"; +import { IconTrash, IconArchive, IconChevronRight, IconX } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@kandev/ui/alert-dialog"; +import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog"; +import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog"; import { DropdownMenu, DropdownMenuContent, @@ -33,6 +25,43 @@ interface TaskMultiSelectToolbarProps { onBulkMove: (targetStepId: string) => Promise; } +function BulkArchiveDialog({ + count, + isProcessing, + onConfirm, +}: { + count: number; + isProcessing: boolean; + onConfirm: () => void; +}) { + const [open, setOpen] = useState(false); + + return ( + <> + + + + ); +} + function BulkDeleteDialog({ count, isProcessing, @@ -57,32 +86,15 @@ function BulkDeleteDialog({ Delete {count} - - - - Delete {count} tasks - - Are you sure you want to delete {count} task{count !== 1 ? "s" : ""}? This action - cannot be undone. - - - - Cancel - { - onConfirm(); - setOpen(false); - }} - data-testid="bulk-delete-confirm" - > - {isProcessing ? : null} - Delete - - - - + ); } @@ -143,17 +155,11 @@ export function TaskMultiSelectToolbar({ )} - + onBulkArchive()} + /> - { if (!open) setArchivingTask(null); diff --git a/apps/web/components/task/archive-confirm-dialog.tsx b/apps/web/components/task/task-archive-confirm-dialog.tsx similarity index 64% rename from apps/web/components/task/archive-confirm-dialog.tsx rename to apps/web/components/task/task-archive-confirm-dialog.tsx index 3eb8dbc58a..c4419a033f 100644 --- a/apps/web/components/task/archive-confirm-dialog.tsx +++ b/apps/web/components/task/task-archive-confirm-dialog.tsx @@ -12,29 +12,42 @@ import { AlertDialogTitle, } from "@kandev/ui/alert-dialog"; -type ArchiveConfirmDialogProps = { +type TaskArchiveConfirmDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; - taskTitle: string; + taskTitle?: string; + isBulkOperation?: boolean; + count?: number; isArchiving?: boolean; onConfirm: () => void; + confirmTestId?: string; }; -export function ArchiveConfirmDialog({ +export function TaskArchiveConfirmDialog({ open, onOpenChange, taskTitle, + isBulkOperation, + count, isArchiving, onConfirm, -}: ArchiveConfirmDialogProps) { + confirmTestId, +}: TaskArchiveConfirmDialogProps) { + const safeCount = count ?? 0; + const label = isBulkOperation ? `task${safeCount !== 1 ? "s" : ""}` : "task"; + const title = isBulkOperation ? `Archive ${safeCount} ${label}?` : "Archive task?"; + const firstLine = isBulkOperation + ? `Are you sure you want to archive ${safeCount} ${label}?` + : `Are you sure you want to archive "${taskTitle}"?`; + return ( e.stopPropagation()}> - Archive task? + {title}
-

Are you sure you want to archive "{taskTitle}"?

+

{firstLine}

This will delete the task's worktree and stop any running agent sessions.

@@ -46,10 +59,11 @@ export function ArchiveConfirmDialog({ { - e.preventDefault(); + data-testid={confirmTestId} + onClick={() => { if (isArchiving) return; onConfirm(); + onOpenChange(false); }} > {isArchiving ? : null} diff --git a/apps/web/components/task/task-delete-confirm-dialog.tsx b/apps/web/components/task/task-delete-confirm-dialog.tsx new file mode 100644 index 0000000000..a8309b6ac0 --- /dev/null +++ b/apps/web/components/task/task-delete-confirm-dialog.tsx @@ -0,0 +1,65 @@ +import { IconLoader } from "@tabler/icons-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@kandev/ui/alert-dialog"; + +export function TaskDeleteConfirmDialog({ + open, + onOpenChange, + taskTitle, + isBulkOperation, + count, + isDeleting, + onConfirm, + confirmTestId, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + taskTitle?: string; + isBulkOperation?: boolean; + count?: number; + isDeleting?: boolean; + onConfirm: () => void; + confirmTestId?: string; +}) { + const safeCount = count ?? 0; + const label = isBulkOperation ? `task${safeCount !== 1 ? "s" : ""}` : "task"; + const title = isBulkOperation ? `Delete ${safeCount} ${label}` : "Delete task"; + const description = isBulkOperation + ? `Are you sure you want to delete ${safeCount} ${label}? This action cannot be undone.` + : `Are you sure you want to delete "${taskTitle}"? This action cannot be undone.`; + + return ( + + e.stopPropagation()}> + + {title} + {description} + + + Cancel + { + if (isDeleting) return; + onConfirm(); + onOpenChange(false); + }} + > + {isDeleting ? : null} + Delete + + + + + ); +} diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 5eb1743e04..ef3260ccbc 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -8,7 +8,7 @@ import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types"; import { TaskSwitcher } from "./task-switcher"; import type { TaskSwitcherItem } from "./task-switcher"; import { TaskRenameDialog } from "./task-rename-dialog"; -import { ArchiveConfirmDialog } from "./archive-confirm-dialog"; +import { TaskArchiveConfirmDialog } from "./task-archive-confirm-dialog"; import { Button } from "@kandev/ui/button"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { IconPlus } from "@tabler/icons-react"; @@ -662,7 +662,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ currentTitle={renamingTask?.title ?? ""} onSubmit={handleRenameSubmit} /> - { if (!open) setArchivingTask(null); diff --git a/apps/web/e2e/pages/kanban-page.ts b/apps/web/e2e/pages/kanban-page.ts index 8ad121fa65..3b1291c06c 100644 --- a/apps/web/e2e/pages/kanban-page.ts +++ b/apps/web/e2e/pages/kanban-page.ts @@ -9,6 +9,7 @@ export class KanbanPage { readonly bulkMoveButton: Locator; readonly bulkClearButton: Locator; readonly bulkDeleteConfirm: Locator; + readonly bulkArchiveConfirm: Locator; readonly multiSelectToggle: Locator; @@ -22,6 +23,7 @@ export class KanbanPage { this.bulkMoveButton = page.getByTestId("bulk-move-button"); this.bulkClearButton = page.getByTestId("bulk-clear-selection"); this.bulkDeleteConfirm = page.getByTestId("bulk-delete-confirm"); + this.bulkArchiveConfirm = page.getByTestId("bulk-archive-confirm"); } async goto() { diff --git a/apps/web/e2e/tests/kanban/task-multi-select.spec.ts b/apps/web/e2e/tests/kanban/task-multi-select.spec.ts index 443704cdf4..23c3a7cf6c 100644 --- a/apps/web/e2e/tests/kanban/task-multi-select.spec.ts +++ b/apps/web/e2e/tests/kanban/task-multi-select.spec.ts @@ -111,6 +111,7 @@ test.describe("Multi-select bulk actions", () => { await kanban.selectTask(t2.id); await kanban.bulkArchiveButton.click(); + await kanban.bulkArchiveConfirm.click(); await expect(kanban.taskCard(t1.id)).not.toBeVisible({ timeout: 10000 }); await expect(kanban.taskCard(t2.id)).not.toBeVisible(); @@ -142,6 +143,7 @@ test.describe("Multi-select bulk actions", () => { await expect(testPage.locator('[data-multi-select-active="true"]').first()).toBeVisible(); await kanban.bulkArchiveButton.click(); + await kanban.bulkArchiveConfirm.click(); await expect(kanban.taskCard(t1.id)).not.toBeVisible({ timeout: 10000 }); await expect(kanban.taskCard(t2.id)).not.toBeVisible();