feat: add confirmation dialog before archiving tasks (#621)
This commit is contained in:
@@ -41,6 +41,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";
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
@@ -341,18 +342,7 @@ function MoveToSubmenu({
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanCardMenu({
|
||||
task,
|
||||
effectiveMenuOpen,
|
||||
setMenuOpen,
|
||||
isDeleting,
|
||||
isArchiving,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onMove,
|
||||
steps,
|
||||
}: {
|
||||
type KanbanCardMenuProps = {
|
||||
task: Task;
|
||||
effectiveMenuOpen: boolean;
|
||||
setMenuOpen: (open: boolean) => void;
|
||||
@@ -363,8 +353,13 @@ function KanbanCardMenu({
|
||||
onArchive?: (task: Task) => void;
|
||||
onMove?: (task: Task, targetStepId: string) => void;
|
||||
steps?: WorkflowStep[];
|
||||
}) {
|
||||
};
|
||||
|
||||
function KanbanCardMenu(props: KanbanCardMenuProps) {
|
||||
const { task, effectiveMenuOpen, setMenuOpen, isDeleting, isArchiving } = props;
|
||||
const { onEdit, onDelete, onArchive, onMove, steps } = props;
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
|
||||
const isProcessing = isDeleting || isArchiving;
|
||||
|
||||
return (
|
||||
@@ -380,8 +375,8 @@ function KanbanCardMenu({
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted rounded-sm p-1 -m-1 transition-colors cursor-pointer"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
aria-label="More options"
|
||||
>
|
||||
<IconDots className="h-4 w-4" />
|
||||
@@ -390,8 +385,8 @@ function KanbanCardMenu({
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={isProcessing}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(task);
|
||||
}}
|
||||
>
|
||||
@@ -403,9 +398,9 @@ function KanbanCardMenu({
|
||||
{onArchive && (
|
||||
<DropdownMenuItem
|
||||
disabled={isProcessing}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onArchive(task);
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowArchiveConfirm(true);
|
||||
}}
|
||||
>
|
||||
{isArchiving ? <IconLoader className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
@@ -416,8 +411,8 @@ function KanbanCardMenu({
|
||||
<DropdownMenuItem
|
||||
disabled={isProcessing}
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowDeleteConfirm(true);
|
||||
}}
|
||||
>
|
||||
@@ -425,7 +420,6 @@ function KanbanCardMenu({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={showDeleteConfirm}
|
||||
onOpenChange={setShowDeleteConfirm}
|
||||
@@ -433,6 +427,16 @@ function KanbanCardMenu({
|
||||
isDeleting={isDeleting}
|
||||
onConfirm={() => onDelete?.(task)}
|
||||
/>
|
||||
<ArchiveConfirmDialog
|
||||
open={showArchiveConfirm}
|
||||
onOpenChange={setShowArchiveConfirm}
|
||||
taskTitle={task.title}
|
||||
isArchiving={isArchiving}
|
||||
onConfirm={() => {
|
||||
onArchive?.(task);
|
||||
setShowArchiveConfirm(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { IconLoader } from "@tabler/icons-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@kandev/ui/alert-dialog";
|
||||
|
||||
type ArchiveConfirmDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
taskTitle: string;
|
||||
isArchiving?: boolean;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function ArchiveConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
taskTitle,
|
||||
isArchiving,
|
||||
onConfirm,
|
||||
}: ArchiveConfirmDialogProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Archive task?</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div>
|
||||
<p>Are you sure you want to archive "{taskTitle}"?</p>
|
||||
<p className="mt-2">
|
||||
This will delete the task's worktree and stop any running agent sessions.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isArchiving}
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (isArchiving) return;
|
||||
onConfirm();
|
||||
}}
|
||||
>
|
||||
{isArchiving ? <IconLoader className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Archive
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -7,13 +7,14 @@ import { Button } from "@kandev/ui/button";
|
||||
import { TaskSwitcher } from "../task-switcher";
|
||||
import { WorkspaceSwitcher } from "../workspace-switcher";
|
||||
import { TaskCreateDialog } from "@/components/task-create-dialog";
|
||||
import { ArchiveConfirmDialog } from "../archive-confirm-dialog";
|
||||
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
import { replaceTaskUrl } from "@/lib/links";
|
||||
import { fetchWorkflowSnapshot, listWorkflows } from "@/lib/api";
|
||||
import { launchSession } from "@/lib/services/session-launch-service";
|
||||
import { buildPrepareRequest } from "@/lib/services/session-launch-helpers";
|
||||
import { useTasks } from "@/hooks/use-tasks";
|
||||
import { useTaskActions } from "@/hooks/use-task-actions";
|
||||
import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
|
||||
import { useTaskRemoval } from "@/hooks/use-task-removal";
|
||||
import { getSessionInfoForTask } from "@/lib/utils/session-info";
|
||||
import type {
|
||||
@@ -273,7 +274,8 @@ function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolea
|
||||
const setActiveSession = useAppStore((state) => state.setActiveSession);
|
||||
const store = useAppStoreApi();
|
||||
const [deletingTaskId, setDeletingTaskId] = useState<string | null>(null);
|
||||
const { deleteTaskById, archiveTaskById } = useTaskActions();
|
||||
const { deleteTaskById } = useTaskActions();
|
||||
const archiveAndSwitch = useArchiveAndSwitchTask();
|
||||
const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store });
|
||||
|
||||
const handleSelectTask = useCallback(
|
||||
@@ -313,22 +315,30 @@ function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolea
|
||||
[loadTaskSessionsForTask, setActiveSession, setActiveTask, store, onOpenChange],
|
||||
);
|
||||
|
||||
const [archivingTask, setArchivingTask] = useState<{ id: string; title: string } | null>(null);
|
||||
const [isArchiving, setIsArchiving] = useState(false);
|
||||
|
||||
const handleArchiveTask = useCallback(
|
||||
async (taskId: string) => {
|
||||
// Capture active state before the async API call — the WS "task.updated"
|
||||
// handler removes the archived task from kanbans before removeTaskFromBoard runs.
|
||||
const { activeTaskId: wasActiveTaskId, activeSessionId: wasActiveSessionId } =
|
||||
store.getState().tasks;
|
||||
try {
|
||||
await archiveTaskById(taskId);
|
||||
await removeTaskFromBoard(taskId, { wasActiveTaskId, wasActiveSessionId });
|
||||
} catch (error) {
|
||||
console.error("Failed to archive task:", error);
|
||||
}
|
||||
(taskId: string) => {
|
||||
const task = store.getState().kanban.tasks.find((t) => t.id === taskId);
|
||||
setArchivingTask({ id: taskId, title: task?.title ?? "this task" });
|
||||
},
|
||||
[archiveTaskById, removeTaskFromBoard, store],
|
||||
[store],
|
||||
);
|
||||
|
||||
const handleArchiveConfirm = useCallback(async () => {
|
||||
if (!archivingTask) return;
|
||||
setIsArchiving(true);
|
||||
try {
|
||||
await archiveAndSwitch(archivingTask.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to archive task:", error);
|
||||
} finally {
|
||||
setIsArchiving(false);
|
||||
setArchivingTask(null);
|
||||
}
|
||||
}, [archivingTask, archiveAndSwitch]);
|
||||
|
||||
const handleDeleteTask = useCallback(
|
||||
async (taskId: string) => {
|
||||
setDeletingTaskId(taskId);
|
||||
@@ -362,6 +372,10 @@ function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolea
|
||||
handleDeleteTask,
|
||||
handleWorkspaceChange,
|
||||
handleTaskCreated,
|
||||
archivingTask,
|
||||
setArchivingTask,
|
||||
isArchiving,
|
||||
handleArchiveConfirm,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -388,6 +402,10 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
|
||||
handleDeleteTask,
|
||||
handleWorkspaceChange,
|
||||
handleTaskCreated,
|
||||
archivingTask,
|
||||
setArchivingTask,
|
||||
isArchiving,
|
||||
handleArchiveConfirm,
|
||||
} = useSheetActions(workspaceId, onOpenChange);
|
||||
|
||||
return (
|
||||
@@ -448,6 +466,16 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
|
||||
steps={dialogSteps}
|
||||
onSuccess={handleTaskCreated}
|
||||
/>
|
||||
|
||||
<ArchiveConfirmDialog
|
||||
open={archivingTask !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setArchivingTask(null);
|
||||
}}
|
||||
taskTitle={archivingTask?.title ?? ""}
|
||||
isArchiving={isArchiving}
|
||||
onConfirm={handleArchiveConfirm}
|
||||
/>
|
||||
</Sheet>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,6 +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 { Button } from "@kandev/ui/button";
|
||||
import { PanelRoot, PanelBody } from "./panel-primitives";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
@@ -451,13 +452,41 @@ function useMoveToStep(store: StoreApi) {
|
||||
);
|
||||
}
|
||||
|
||||
function useArchiveActions(store: StoreApi) {
|
||||
const archiveAndSwitch = useArchiveAndSwitchTask({ useLayoutSwitch: true });
|
||||
const [archivingTask, setArchivingTask] = useState<{ id: string; title: string } | null>(null);
|
||||
const [isArchiving, setIsArchiving] = useState(false);
|
||||
|
||||
const handleArchiveTask = useCallback(
|
||||
(taskId: string) => {
|
||||
const task = findTaskInSnapshots(store.getState().kanbanMulti.snapshots, taskId);
|
||||
setArchivingTask({ id: taskId, title: task?.title ?? "this task" });
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
const handleArchiveConfirm = useCallback(async () => {
|
||||
if (!archivingTask) return;
|
||||
setIsArchiving(true);
|
||||
try {
|
||||
await archiveAndSwitch(archivingTask.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to archive task:", error);
|
||||
} finally {
|
||||
setIsArchiving(false);
|
||||
setArchivingTask(null);
|
||||
}
|
||||
}, [archivingTask, archiveAndSwitch]);
|
||||
|
||||
return { archivingTask, setArchivingTask, isArchiving, handleArchiveTask, handleArchiveConfirm };
|
||||
}
|
||||
|
||||
function useSidebarActions(store: StoreApi) {
|
||||
const setActiveTask = useAppStore((state) => state.setActiveTask);
|
||||
const setActiveSession = useAppStore((state) => state.setActiveSession);
|
||||
const [deletingTaskId, setDeletingTaskId] = useState<string | null>(null);
|
||||
const [preparingTaskId, setPreparingTaskId] = useState<string | null>(null);
|
||||
const { deleteTaskById, renameTaskById } = useTaskActions();
|
||||
const archiveAndSwitch = useArchiveAndSwitchTask({ useLayoutSwitch: true });
|
||||
const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({
|
||||
store,
|
||||
useLayoutSwitch: true,
|
||||
@@ -498,16 +527,7 @@ function useSidebarActions(store: StoreApi) {
|
||||
[loadTaskSessionsForTask, switchToSession, setActiveTask, store],
|
||||
);
|
||||
|
||||
const handleArchiveTask = useCallback(
|
||||
async (taskId: string) => {
|
||||
try {
|
||||
await archiveAndSwitch(taskId);
|
||||
} catch (error) {
|
||||
console.error("Failed to archive task:", error);
|
||||
}
|
||||
},
|
||||
[archiveAndSwitch],
|
||||
);
|
||||
const archiveActions = useArchiveActions(store);
|
||||
|
||||
const handleDeleteTask = useCallback(
|
||||
async (taskId: string) => {
|
||||
@@ -551,13 +571,13 @@ function useSidebarActions(store: StoreApi) {
|
||||
deletingTaskId,
|
||||
preparingTaskId,
|
||||
handleSelectTask,
|
||||
handleArchiveTask,
|
||||
handleDeleteTask,
|
||||
handleMoveToStep,
|
||||
renamingTask,
|
||||
setRenamingTask,
|
||||
handleRenameTask,
|
||||
handleRenameSubmit,
|
||||
...archiveActions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -601,6 +621,10 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
||||
setRenamingTask,
|
||||
handleRenameTask,
|
||||
handleRenameSubmit,
|
||||
archivingTask,
|
||||
setArchivingTask,
|
||||
isArchiving,
|
||||
handleArchiveConfirm,
|
||||
} = useSidebarActions(store);
|
||||
|
||||
const displayTasks = useMemo(() => {
|
||||
@@ -638,6 +662,15 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
||||
currentTitle={renamingTask?.title ?? ""}
|
||||
onSubmit={handleRenameSubmit}
|
||||
/>
|
||||
<ArchiveConfirmDialog
|
||||
open={archivingTask !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setArchivingTask(null);
|
||||
}}
|
||||
taskTitle={archivingTask?.title ?? ""}
|
||||
isArchiving={isArchiving}
|
||||
onConfirm={handleArchiveConfirm}
|
||||
/>
|
||||
</PanelRoot>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -277,10 +277,16 @@ export class SessionPage {
|
||||
|
||||
/**
|
||||
* Archive a task via the sidebar context menu.
|
||||
* Hovers to reveal the menu trigger, opens it, and clicks "Archive".
|
||||
* Hovers to reveal the menu trigger, opens it, clicks "Archive",
|
||||
* and confirms the archive dialog.
|
||||
*/
|
||||
async archiveTaskInSidebar(title: string): Promise<void> {
|
||||
await this.openSidebarMenuAndClick(title, "Archive");
|
||||
// Confirm the archive dialog
|
||||
const confirmButton = this.page
|
||||
.getByRole("alertdialog")
|
||||
.getByRole("button", { name: "Archive" });
|
||||
await confirmButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user