feat(ui): add task archive confirmations (#2945)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import type { Row, ColumnDef } from "@tanstack/react-table";
|
||||
import type { Task, Workflow, WorkflowStep, Repository } from "@/lib/types/http";
|
||||
import Link from "@/components/routing/app-link";
|
||||
@@ -10,10 +10,11 @@ import { Badge } from "@kandev/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { formatTimeDistance, useDateLocale } from "@/lib/i18n/date-locale";
|
||||
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
|
||||
import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog";
|
||||
import { TaskArchiveConfirmation } from "@/components/task/task-archive-confirmation";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
|
||||
type TaskWithResolution = Task & {
|
||||
workflowName?: string;
|
||||
@@ -76,12 +77,17 @@ function ActionsCell({ row, ctx }: { row: Row<TaskWithResolution>; ctx: ActionsC
|
||||
const isArchived = !!task.archived_at;
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
|
||||
const archiveAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
const { isFinePointer } = useResponsiveBreakpoint();
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
{!isArchived && (
|
||||
<div
|
||||
className={`flex items-center justify-end gap-0.5 ${showArchiveConfirm && !isFinePointer ? "flex-wrap" : ""}`}
|
||||
>
|
||||
{!isArchived && (!showArchiveConfirm || isFinePointer) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
ref={archiveAnchorRef}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="cursor-pointer h-7 w-7 p-0"
|
||||
@@ -126,8 +132,9 @@ function ActionsCell({ row, ctx }: { row: Row<TaskWithResolution>; ctx: ActionsC
|
||||
isDeleting={isDeleting}
|
||||
onConfirm={({ cascade }) => ctx.onDelete(task.id, { cascade })}
|
||||
/>
|
||||
<TaskArchiveConfirmDialog
|
||||
<TaskArchiveConfirmation
|
||||
open={showArchiveConfirm}
|
||||
anchorRef={archiveAnchorRef}
|
||||
onOpenChange={setShowArchiveConfirm}
|
||||
taskTitle={task.title}
|
||||
taskId={task.id}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { IconArchive, IconArchiveOff, IconLoader, IconTrash } from "@tabler/icons-react";
|
||||
import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog";
|
||||
import { TaskArchiveConfirmation } from "@/components/task/task-archive-confirmation";
|
||||
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
|
||||
import { primaryTaskRepository, type Repository, type Task, type Workflow } from "@/lib/types/http";
|
||||
import { formatTaskStateLabel } from "@/lib/ui/state-labels";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/lib/tasks/tasks-list-options";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
|
||||
export type TasksListViewProps = {
|
||||
total: number;
|
||||
@@ -489,12 +490,18 @@ function TaskRowActions({
|
||||
onDelete: (taskId: string, opts?: { cascade?: boolean }) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const archiveAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
const { isFinePointer } = useResponsiveBreakpoint();
|
||||
return (
|
||||
<div className="flex items-center gap-1" onClick={(event) => event.stopPropagation()}>
|
||||
{!isArchived && (
|
||||
<div
|
||||
className={`flex items-center gap-1 ${showArchiveConfirm && !isFinePointer ? "flex-wrap" : ""}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{!isArchived && (!showArchiveConfirm || isFinePointer) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
ref={archiveAnchorRef}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 cursor-pointer"
|
||||
@@ -539,8 +546,9 @@ function TaskRowActions({
|
||||
isDeleting={isDeleting}
|
||||
onConfirm={({ cascade }) => onDelete(task.id, { cascade })}
|
||||
/>
|
||||
<TaskArchiveConfirmDialog
|
||||
<TaskArchiveConfirmation
|
||||
open={showArchiveConfirm}
|
||||
anchorRef={archiveAnchorRef}
|
||||
onOpenChange={onArchiveOpenChange}
|
||||
taskTitle={task.title}
|
||||
taskId={task.id}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { CommandItem } from "@/lib/commands/types";
|
||||
|
||||
type CommandGroup = [string, CommandItem[]];
|
||||
|
||||
export function getCommandConfirmationState(commands: CommandItem[], grouped: CommandGroup[]) {
|
||||
const confirmationCommand = commands.find((command) => command.confirmation);
|
||||
const visibleCommands = confirmationCommand
|
||||
? commands.filter((command) => !command.confirmation)
|
||||
: commands;
|
||||
const visibleIds = new Set(visibleCommands.map((command) => command.id));
|
||||
const visibleGroups = confirmationCommand
|
||||
? grouped.map(
|
||||
([group, items]) =>
|
||||
[group, items.filter((command) => visibleIds.has(command.id))] as CommandGroup,
|
||||
)
|
||||
: grouped;
|
||||
return { confirmationCommand, visibleCommands, visibleGroups };
|
||||
}
|
||||
|
||||
export function dismissCommandConfirmation(commands: CommandItem[]) {
|
||||
commands.find((command) => command.confirmation)?.onConfirmationDismiss?.();
|
||||
}
|
||||
|
||||
export function CommandPanelConfirmation({ command }: { command: CommandItem }) {
|
||||
return (
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-label={command.label}
|
||||
data-testid="command-panel-confirmation"
|
||||
className="border-b border-border p-3"
|
||||
>
|
||||
{command.confirmation}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,23 @@ vi.mock("@kandev/ui/command", () => ({
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
CommandDialog: ({ children, open }: { children: ReactNode; open: boolean }) =>
|
||||
open ? <div>{children}</div> : null,
|
||||
CommandDialog: ({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) =>
|
||||
open ? (
|
||||
<div>
|
||||
<button type="button" onClick={() => onOpenChange(false)}>
|
||||
Dismiss test dialog
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
CommandEmpty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
CommandGroup: ({
|
||||
children,
|
||||
@@ -108,6 +123,8 @@ const result: WorkspaceContentSearchResult = {
|
||||
|
||||
const ARIA_SELECTED_ATTRIBUTE = "aria-selected";
|
||||
const CMDK_GROUP_HEADING_SELECTOR = "[cmdk-group-heading]";
|
||||
const ACTIONS_GROUP = "Actions";
|
||||
const CONFIRMATION_LABEL = "Remove selected item";
|
||||
|
||||
function viewProps(overrides: Partial<CommandPanelViewProps> = {}): CommandPanelViewProps {
|
||||
return {
|
||||
@@ -366,8 +383,8 @@ describe("CommandPanelView mode result safety", () => {
|
||||
mode: "commands",
|
||||
search: "needle",
|
||||
commands: [
|
||||
{ id: "matching-command", label: "Needle command", group: "Actions" },
|
||||
{ id: "unrelated-command", label: "Unrelated action", group: "Actions" },
|
||||
{ id: "matching-command", label: "Needle command", group: ACTIONS_GROUP },
|
||||
{ id: "unrelated-command", label: "Unrelated action", group: ACTIONS_GROUP },
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
@@ -419,3 +436,63 @@ describe("CommandPanelView mode result safety", () => {
|
||||
expect(groups[1].getAttribute("data-repository")).toBe("frontend");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommandPanelView confirmations", () => {
|
||||
it("labels generic confirmations from their command and hides the command row", () => {
|
||||
const confirmationCommand = {
|
||||
id: "destructive-command",
|
||||
label: CONFIRMATION_LABEL,
|
||||
group: ACTIONS_GROUP,
|
||||
confirmation: <div>Confirm removal</div>,
|
||||
};
|
||||
const regularCommand = {
|
||||
id: "regular-command",
|
||||
label: "Keep working",
|
||||
group: ACTIONS_GROUP,
|
||||
};
|
||||
|
||||
render(
|
||||
<CommandPanelView
|
||||
{...viewProps({
|
||||
mode: MODE_COMMANDS,
|
||||
search: "",
|
||||
commands: [confirmationCommand, regularCommand],
|
||||
grouped: [[ACTIONS_GROUP, [confirmationCommand, regularCommand]]],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alertdialog", { name: CONFIRMATION_LABEL })).toBeTruthy();
|
||||
expect(screen.getByText("Confirm removal")).toBeTruthy();
|
||||
expect(screen.queryByText(CONFIRMATION_LABEL)).toBeNull();
|
||||
expect(screen.getByText("Keep working")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("dismisses an owned confirmation when the palette closes", () => {
|
||||
const onConfirmationDismiss = vi.fn();
|
||||
const setOpen = vi.fn();
|
||||
const confirmationCommand = {
|
||||
id: "destructive-command",
|
||||
label: CONFIRMATION_LABEL,
|
||||
group: ACTIONS_GROUP,
|
||||
confirmation: <div>Confirm removal</div>,
|
||||
onConfirmationDismiss,
|
||||
};
|
||||
|
||||
render(
|
||||
<CommandPanelView
|
||||
{...viewProps({
|
||||
setOpen,
|
||||
mode: MODE_COMMANDS,
|
||||
commands: [confirmationCommand],
|
||||
grouped: [[ACTIONS_GROUP, [confirmationCommand]]],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss test dialog" }));
|
||||
|
||||
expect(onConfirmationDismiss).toHaveBeenCalledOnce();
|
||||
expect(setOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,11 @@ import {
|
||||
} from "@/components/command-panel-scope-switcher";
|
||||
import type { WorkspaceContentSearchError } from "@/hooks/domains/session/use-workspace-content-search";
|
||||
import type { WorkspaceContentSearchResult } from "@/lib/types/backend";
|
||||
import {
|
||||
CommandPanelConfirmation,
|
||||
dismissCommandConfirmation,
|
||||
getCommandConfirmationState,
|
||||
} from "@/components/command-panel-confirmation";
|
||||
|
||||
const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]);
|
||||
export const MODE_COMMANDS: CommandPanelMode = "commands";
|
||||
@@ -527,12 +532,17 @@ function CommandPanelResultList(props: CommandPanelViewProps) {
|
||||
repoMap,
|
||||
handleTaskSelect,
|
||||
} = props;
|
||||
const { confirmationCommand, visibleCommands, visibleGroups } = getCommandConfirmationState(
|
||||
commands,
|
||||
grouped,
|
||||
);
|
||||
return (
|
||||
<CommandList>
|
||||
{confirmationCommand && <CommandPanelConfirmation command={confirmationCommand} />}
|
||||
{mode === MODE_COMMANDS && (
|
||||
<CommandsListContent
|
||||
commands={commands}
|
||||
grouped={grouped}
|
||||
commands={visibleCommands}
|
||||
grouped={visibleGroups}
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
taskResults={taskResults}
|
||||
@@ -589,10 +599,15 @@ export function CommandPanelView(props: CommandPanelViewProps) {
|
||||
if (workspaceModeUnavailable) onScopeChange("commands");
|
||||
}, [onScopeChange, workspaceModeUnavailable]);
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen) dismissCommandConfirmation(props.commands);
|
||||
setOpen(nextOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
overlayClassName="supports-backdrop-filter:backdrop-blur-none!"
|
||||
>
|
||||
<Command
|
||||
|
||||
@@ -454,7 +454,7 @@ function useCommandPanelHandlers({
|
||||
return;
|
||||
}
|
||||
if (cmd.action) {
|
||||
setOpen(false);
|
||||
if (!cmd.keepOpen) setOpen(false);
|
||||
cmd.action();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "@/components/kanban-card-menu-items";
|
||||
import { useTaskPluginLinkActions } from "@/components/task/task-session-sidebar-link-actions";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog";
|
||||
import { TaskArchiveConfirmation } from "@/components/task/task-archive-confirmation";
|
||||
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
|
||||
import { TaskDetachConfirmationSurface } from "@/components/task/task-detach-confirm-dialog";
|
||||
import {
|
||||
@@ -324,6 +324,12 @@ function useKanbanCardMenus({
|
||||
window.setTimeout(() => dialogs.setShowDetachConfirm(true), 300);
|
||||
};
|
||||
|
||||
const requestArchiveConfirmation = () => {
|
||||
// Let Radix finish the menu's pointer sequence before the local surface
|
||||
// opens; otherwise the initiating menu event is treated as outside input.
|
||||
window.setTimeout(() => dialogs.setShowArchiveConfirm(true), 300);
|
||||
};
|
||||
|
||||
const menuBase = {
|
||||
currentWorkflowId: moveMenu.moveTargets.currentWorkflowId,
|
||||
currentStepId: task.workflowStepId,
|
||||
@@ -335,7 +341,7 @@ function useKanbanCardMenus({
|
||||
isDetaching,
|
||||
parentTaskId: task.parentTaskId,
|
||||
onEdit: onEdit ? () => onEdit(task) : undefined,
|
||||
onArchive: onArchive ? () => dialogs.setShowArchiveConfirm(true) : undefined,
|
||||
onArchive: onArchive ? requestArchiveConfirmation : undefined,
|
||||
onDelete: onDelete ? () => dialogs.setShowDeleteConfirm(true) : undefined,
|
||||
onDetach: task.parentTaskId && !actingOnMultiSelection ? requestDetachConfirmation : undefined,
|
||||
...buildLinkDialogHandlers(externalLinkAvailability, dialogs),
|
||||
@@ -361,6 +367,8 @@ function useKanbanCardMenus({
|
||||
isDetaching,
|
||||
detachAnchorRef,
|
||||
detachFocusReturnRef,
|
||||
archiveAnchorRef: detachFocusReturnRef,
|
||||
archiveFocusReturnRef: detachFocusReturnRef,
|
||||
handleDetachConfirm,
|
||||
};
|
||||
}
|
||||
@@ -373,18 +381,14 @@ function KanbanCardDialogs({
|
||||
repositories,
|
||||
menu,
|
||||
isDeleting,
|
||||
isArchiving,
|
||||
onDelete,
|
||||
onArchive,
|
||||
}: {
|
||||
task: Task;
|
||||
workspaceId: string | null;
|
||||
repositories: Repository[];
|
||||
menu: KanbanCardMenuState;
|
||||
isDeleting?: boolean;
|
||||
isArchiving?: boolean;
|
||||
onDelete?: KanbanCardProps["onDelete"];
|
||||
onArchive?: KanbanCardProps["onArchive"];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -397,15 +401,6 @@ function KanbanCardDialogs({
|
||||
isDeleting={isDeleting}
|
||||
onConfirm={({ cascade }) => onDelete?.(task, { cascade })}
|
||||
/>
|
||||
<TaskArchiveConfirmDialog
|
||||
open={menu.showArchiveConfirm}
|
||||
onOpenChange={menu.setShowArchiveConfirm}
|
||||
taskTitle={task.title}
|
||||
taskId={task.id}
|
||||
executorType={task.primaryExecutorType}
|
||||
isArchiving={isArchiving}
|
||||
onConfirm={({ cascade }) => onArchive?.(task, { cascade })}
|
||||
/>
|
||||
<TaskGitHubPRDialog
|
||||
workspaceId={workspaceId}
|
||||
open={menu.showPRDialog}
|
||||
@@ -491,6 +486,7 @@ function KanbanCardFrame({
|
||||
showMaximizeButton,
|
||||
isDeleting,
|
||||
isArchiving,
|
||||
onArchive,
|
||||
onClick,
|
||||
onToggleSelect,
|
||||
onOpenFullPage,
|
||||
@@ -503,6 +499,7 @@ function KanbanCardFrame({
|
||||
| "showMaximizeButton"
|
||||
| "isDeleting"
|
||||
| "isArchiving"
|
||||
| "onArchive"
|
||||
| "onToggleSelect"
|
||||
| "onOpenFullPage"
|
||||
> & {
|
||||
@@ -549,6 +546,17 @@ function KanbanCardFrame({
|
||||
onOpenChange={menu.setShowDetachConfirm}
|
||||
onConfirm={menu.handleDetachConfirm}
|
||||
/>
|
||||
<TaskArchiveConfirmation
|
||||
open={menu.showArchiveConfirm}
|
||||
anchorRef={menu.archiveAnchorRef}
|
||||
focusReturnRef={menu.archiveFocusReturnRef}
|
||||
taskTitle={task.title}
|
||||
taskId={task.id}
|
||||
executorType={task.primaryExecutorType}
|
||||
isArchiving={isArchiving}
|
||||
onOpenChange={menu.setShowArchiveConfirm}
|
||||
onConfirm={({ cascade }) => onArchive?.(task, { cascade })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -618,6 +626,7 @@ export function KanbanCard({
|
||||
showMaximizeButton={showMaximizeButton}
|
||||
isDeleting={isDeleting}
|
||||
isArchiving={isArchiving}
|
||||
onArchive={onArchive}
|
||||
onClick={handleClick}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onOpenFullPage={onOpenFullPage}
|
||||
@@ -628,9 +637,7 @@ export function KanbanCard({
|
||||
repositories={repositories}
|
||||
menu={menu}
|
||||
isDeleting={isDeleting}
|
||||
isArchiving={isArchiving}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { IconArchive, IconDots, IconTrash } from "@tabler/icons-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { Checkbox } from "@kandev/ui/checkbox";
|
||||
import { cn } from "@kandev/ui/lib/utils";
|
||||
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
|
||||
import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog";
|
||||
import { TaskArchiveConfirmation } from "@/components/task/task-archive-confirmation";
|
||||
import { formatRelativeTime } from "@/lib/utils";
|
||||
import { needsAction } from "@/lib/utils/needs-action";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
@@ -183,12 +183,14 @@ function TaskActions({
|
||||
const { t } = useTranslation();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
|
||||
const archiveAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-end gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
ref={archiveAnchorRef}
|
||||
type="button"
|
||||
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground/40 hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
|
||||
>
|
||||
@@ -198,7 +200,7 @@ function TaskActions({
|
||||
<DropdownMenuContent align="end" className="w-[160px]">
|
||||
{onArchiveTask && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setShowArchiveConfirm(true)}
|
||||
onClick={() => window.setTimeout(() => setShowArchiveConfirm(true), 300)}
|
||||
disabled={isArchiving}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
@@ -225,8 +227,9 @@ function TaskActions({
|
||||
isDeleting={isDeleting}
|
||||
onConfirm={({ cascade }) => onDeleteTask(task, { cascade })}
|
||||
/>
|
||||
<TaskArchiveConfirmDialog
|
||||
<TaskArchiveConfirmation
|
||||
open={showArchiveConfirm}
|
||||
anchorRef={archiveAnchorRef}
|
||||
onOpenChange={setShowArchiveConfirm}
|
||||
taskTitle={task.title}
|
||||
taskId={task.id}
|
||||
@@ -234,7 +237,7 @@ function TaskActions({
|
||||
isArchiving={isArchiving}
|
||||
onConfirm={({ cascade }) => onArchiveTask?.(task, { cascade })}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,19 @@ describe("buildTaskCommands", () => {
|
||||
expect(requestArchive).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("registers the archive confirmation dismiss callback", () => {
|
||||
const onDismiss = vi.fn();
|
||||
const archive = build({
|
||||
onArchiveConfirmationDismiss: onDismiss,
|
||||
} as Partial<Parameters<typeof buildTaskCommands>[0]>).find(
|
||||
(cmd) => cmd.id === ARCHIVE_COMMAND_ID,
|
||||
) as CommandItem & { onConfirmationDismiss?: () => void };
|
||||
|
||||
archive.onConfirmationDismiss?.();
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("hides the archive command for an archived task", () => {
|
||||
const commands = build({ isTaskArchived: true });
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
@@ -31,7 +31,7 @@ import { createFile } from "@/lib/ws/workspace-files";
|
||||
import { useDockviewStore } from "@/lib/state/dockview-store";
|
||||
import { NewSessionDialog } from "@/components/task/new-session-dialog";
|
||||
import { NewSubtaskDialog } from "@/components/task/new-subtask-dialog";
|
||||
import { TaskArchiveConfirmFlow } from "@/components/task/task-archive-confirm-flow";
|
||||
import { TaskArchiveConfirmation } from "@/components/task/task-archive-confirmation";
|
||||
import { useTaskArchiveConfirm } from "@/hooks/use-task-archive-confirm";
|
||||
import { searchKeywords } from "@/lib/commands/search-keywords";
|
||||
import type { CommandItem } from "@/lib/commands/types";
|
||||
@@ -195,6 +195,8 @@ type TaskCommandOptions = {
|
||||
openNewAgent: () => void;
|
||||
openSubtask: () => void;
|
||||
requestArchive: () => void;
|
||||
archiveConfirmation?: ReactNode;
|
||||
onArchiveConfirmationDismiss?: () => void;
|
||||
};
|
||||
|
||||
export function buildTaskCommands({
|
||||
@@ -204,6 +206,8 @@ export function buildTaskCommands({
|
||||
openNewAgent,
|
||||
openSubtask,
|
||||
requestArchive,
|
||||
archiveConfirmation,
|
||||
onArchiveConfirmationDismiss,
|
||||
}: TaskCommandOptions): CommandItem[] {
|
||||
if (!activeTaskId) return [];
|
||||
const items: CommandItem[] = [
|
||||
@@ -234,6 +238,9 @@ export function buildTaskCommands({
|
||||
icon: <IconArchive className="size-3.5" />,
|
||||
keywords: searchKeywords(t, "common:commandArchiveTaskKeywords"),
|
||||
action: requestArchive,
|
||||
keepOpen: true,
|
||||
confirmation: archiveConfirmation,
|
||||
onConfirmationDismiss: onArchiveConfirmationDismiss,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
@@ -308,7 +315,6 @@ type SessionCommandDialogsProps = {
|
||||
activeTaskId: string;
|
||||
activeTaskTitle: string;
|
||||
dialogs: ReturnType<typeof useCommandDialogState>;
|
||||
archive: ReturnType<typeof useTaskArchiveConfirm>;
|
||||
};
|
||||
|
||||
/** Dialogs the task-scoped palette commands open. */
|
||||
@@ -316,7 +322,6 @@ function SessionCommandDialogs({
|
||||
activeTaskId,
|
||||
activeTaskTitle,
|
||||
dialogs,
|
||||
archive,
|
||||
}: SessionCommandDialogsProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -331,15 +336,32 @@ function SessionCommandDialogs({
|
||||
parentTaskId={activeTaskId}
|
||||
parentTaskTitle={activeTaskTitle}
|
||||
/>
|
||||
<TaskArchiveConfirmFlow
|
||||
taskId={activeTaskId}
|
||||
archive={archive}
|
||||
confirmTestId="palette-archive-confirm"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function useArchiveCommandConfirmation(archive: ReturnType<typeof useTaskArchiveConfirm>) {
|
||||
const archiveAnchorRef = useRef<HTMLElement>(null);
|
||||
if (archive.target === null) return undefined;
|
||||
|
||||
return (
|
||||
<TaskArchiveConfirmation
|
||||
open
|
||||
inline
|
||||
anchorRef={archiveAnchorRef}
|
||||
taskId={archive.target.id}
|
||||
taskTitle={archive.target.title}
|
||||
executorType={archive.target.executorType}
|
||||
isArchiving={archive.isPending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) archive.closeConfirm();
|
||||
}}
|
||||
onConfirm={archive.confirmArchive}
|
||||
confirmTestId="palette-archive-confirm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionCommands({
|
||||
sessionId,
|
||||
baseBranch,
|
||||
@@ -378,6 +400,7 @@ export function SessionCommands({
|
||||
|
||||
const archive = useTaskArchiveConfirm(activeTaskId);
|
||||
const { requestArchive } = archive;
|
||||
const archiveConfirmation = useArchiveCommandConfirmation(archive);
|
||||
|
||||
// Session-scoped commands need a live session; task-scoped ones only need the
|
||||
// task, so they stay available while a session is still being ensured.
|
||||
@@ -408,6 +431,8 @@ export function SessionCommands({
|
||||
openNewAgent,
|
||||
openSubtask,
|
||||
requestArchive,
|
||||
archiveConfirmation,
|
||||
onArchiveConfirmationDismiss: archive.closeConfirm,
|
||||
}),
|
||||
];
|
||||
return items.map((cmd) => ({ ...cmd, priority: 0 }));
|
||||
@@ -429,6 +454,8 @@ export function SessionCommands({
|
||||
openNewAgent,
|
||||
openSubtask,
|
||||
requestArchive,
|
||||
archiveConfirmation,
|
||||
archive.closeConfirm,
|
||||
]);
|
||||
|
||||
useRegisterCommands(commands);
|
||||
@@ -440,7 +467,6 @@ export function SessionCommands({
|
||||
activeTaskId={activeTaskId}
|
||||
activeTaskTitle={activeTaskTitle}
|
||||
dialogs={dialogs}
|
||||
archive={archive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,12 @@ import { PRClosedBanner, PRMergedBanner } from "./pr-archive-banners";
|
||||
const MERGED_ARCHIVE_BUTTON = "pr-merged-archive-button";
|
||||
const MERGED_ARCHIVE_CONFIRM = "pr-merged-archive-confirm";
|
||||
|
||||
async function findEnabledConfirm(testId: string) {
|
||||
await screen.findByTestId<HTMLButtonElement>(testId);
|
||||
await waitFor(() => expect(screen.getByTestId<HTMLButtonElement>(testId).disabled).toBe(false));
|
||||
return screen.getByTestId<HTMLButtonElement>(testId);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
archiveAndSwitchMock.mockResolvedValue(undefined);
|
||||
mockGetSubtaskCount.mockResolvedValue({ count: 0 });
|
||||
@@ -81,7 +87,7 @@ describe("PRMergedBanner", () => {
|
||||
render(<PRMergedBanner taskId="task-1" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON));
|
||||
fireEvent.click(await screen.findByTestId(MERGED_ARCHIVE_CONFIRM));
|
||||
fireEvent.click(await findEnabledConfirm(MERGED_ARCHIVE_CONFIRM));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(archiveAndSwitchMock).toHaveBeenCalledWith("task-1", { cascade: false }),
|
||||
@@ -93,7 +99,8 @@ describe("PRMergedBanner", () => {
|
||||
render(<PRMergedBanner taskId="task-1" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON));
|
||||
fireEvent.click(await screen.findByText("Cancel"));
|
||||
await findEnabledConfirm(MERGED_ARCHIVE_CONFIRM);
|
||||
fireEvent.click(screen.getByText("Cancel"));
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId(MERGED_ARCHIVE_CONFIRM)).toBeNull());
|
||||
expect(archiveAndSwitchMock).not.toHaveBeenCalled();
|
||||
@@ -105,7 +112,7 @@ describe("PRMergedBanner", () => {
|
||||
render(<PRMergedBanner taskId="task-1" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON));
|
||||
fireEvent.click(await screen.findByTestId(MERGED_ARCHIVE_CONFIRM));
|
||||
fireEvent.click(await findEnabledConfirm(MERGED_ARCHIVE_CONFIRM));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
@@ -136,7 +143,7 @@ describe("PRMergedBanner", () => {
|
||||
render(<PRMergedBanner taskId="task-1" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON));
|
||||
fireEvent.click(await screen.findByTestId(MERGED_ARCHIVE_CONFIRM));
|
||||
fireEvent.click(await findEnabledConfirm(MERGED_ARCHIVE_CONFIRM));
|
||||
await waitFor(() => expect(archiveAndSwitchMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Reopen while the async archive is still pending: confirm must be disabled.
|
||||
@@ -174,7 +181,7 @@ describe("PRClosedBanner", () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId("pr-closed-archive-button"));
|
||||
expect(archiveAndSwitchMock).not.toHaveBeenCalled();
|
||||
fireEvent.click(await screen.findByTestId("pr-closed-archive-confirm"));
|
||||
fireEvent.click(await findEnabledConfirm("pr-closed-archive-confirm"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(archiveAndSwitchMock).toHaveBeenCalledWith("task-1", { cascade: false }),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { useCallback, useRef, useState, type ReactNode } from "react";
|
||||
import { IconGitMerge, IconGitPullRequestClosed, IconX } from "@tabler/icons-react";
|
||||
import { TaskArchiveConfirmFlow } from "@/components/task/task-archive-confirm-flow";
|
||||
import { TaskArchiveConfirmation } from "@/components/task/task-archive-confirmation";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useTaskArchiveConfirm } from "@/hooks/use-task-archive-confirm";
|
||||
import {
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
wasPRMergedBannerDismissed,
|
||||
} from "@/lib/local-storage";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
|
||||
// Presentational banner shared by PRMergedBanner / PRClosedBanner — an icon, a
|
||||
// message, and Archive + Dismiss controls. Colors/icon/testIds are supplied by
|
||||
@@ -38,19 +39,27 @@ function ArchiveDismissBanner({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const archive = useTaskArchiveConfirm(taskId);
|
||||
const archiveAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
const { isFinePointer } = useResponsiveBreakpoint();
|
||||
return (
|
||||
<>
|
||||
<div data-testid={`${testIdPrefix}-banner`} className={containerClass}>
|
||||
<div
|
||||
data-testid={`${testIdPrefix}-banner`}
|
||||
className={`${containerClass} ${archive.target && !isFinePointer ? "flex-wrap" : ""}`}
|
||||
>
|
||||
{icon}
|
||||
<span className="flex-1">{text}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${testIdPrefix}-archive-button`}
|
||||
onClick={archive.requestArchive}
|
||||
className={archiveClass}
|
||||
>
|
||||
{t("task:archive")}
|
||||
</button>
|
||||
{(!archive.target || isFinePointer) && (
|
||||
<button
|
||||
ref={archiveAnchorRef}
|
||||
type="button"
|
||||
data-testid={`${testIdPrefix}-archive-button`}
|
||||
onClick={archive.requestArchive}
|
||||
className={archiveClass}
|
||||
>
|
||||
{t("task:archive")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("task:dismiss")}
|
||||
@@ -60,12 +69,20 @@ function ArchiveDismissBanner({
|
||||
>
|
||||
<IconX className="h-3 w-3" />
|
||||
</button>
|
||||
<TaskArchiveConfirmation
|
||||
open={archive.target !== null}
|
||||
anchorRef={archiveAnchorRef}
|
||||
taskId={taskId}
|
||||
taskTitle={archive.target?.title}
|
||||
executorType={archive.target?.executorType}
|
||||
isArchiving={archive.isPending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) archive.closeConfirm();
|
||||
}}
|
||||
onConfirm={archive.confirmArchive}
|
||||
confirmTestId={`${testIdPrefix}-archive-confirm`}
|
||||
/>
|
||||
</div>
|
||||
<TaskArchiveConfirmFlow
|
||||
taskId={taskId}
|
||||
archive={archive}
|
||||
confirmTestId={`${testIdPrefix}-archive-confirm`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { toSheetItem } from "./session-task-switcher-sheet-item";
|
||||
import {
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
selectTaskFromSheet,
|
||||
} from "./session-task-switcher-sheet-selection";
|
||||
import type { TaskPendingAction, TaskSession } from "@/lib/types/http";
|
||||
import { useSheetArchiveActions } from "./session-task-switcher-sheet-hooks";
|
||||
|
||||
type SheetTask = Parameters<typeof toSheetItem>[0];
|
||||
type SheetCtx = Parameters<typeof toSheetItem>[1];
|
||||
@@ -580,3 +582,34 @@ describe("selectTaskFromSheet sheet lifecycle", () => {
|
||||
expect(onOpenChange.mock.calls).toEqual([[false], [true]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSheetArchiveActions", () => {
|
||||
it("tracks the directly archived row while its request is pending", async () => {
|
||||
let resolveArchive: () => void = () => undefined;
|
||||
const archiveAndSwitch = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveArchive = resolve;
|
||||
}),
|
||||
);
|
||||
const store = {
|
||||
getState: () => ({}),
|
||||
} as Parameters<typeof useSheetArchiveActions>[0];
|
||||
const { result } = renderHook(() =>
|
||||
useSheetArchiveActions(
|
||||
store,
|
||||
archiveAndSwitch as Parameters<typeof useSheetArchiveActions>[1],
|
||||
),
|
||||
);
|
||||
|
||||
act(() => result.current.handleArchiveTask("task-1", { cascade: false }));
|
||||
|
||||
await waitFor(() => expect(result.current.archivingTaskId).toBe("task-1"));
|
||||
expect(result.current.isArchiving).toBe(true);
|
||||
|
||||
await act(async () => resolveArchive());
|
||||
|
||||
await waitFor(() => expect(result.current.archivingTaskId).toBeNull());
|
||||
expect(result.current.isArchiving).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -403,16 +403,80 @@ function useSheetNestTask() {
|
||||
return useNestTaskByDrag();
|
||||
}
|
||||
|
||||
export function useSheetArchiveActions(
|
||||
store: ReturnType<typeof useAppStoreApi>,
|
||||
archiveAndSwitch: ReturnType<typeof useArchiveAndSwitchTask>,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const [archivingTask, setArchivingTask] = useState<{
|
||||
id: string;
|
||||
title: string;
|
||||
executorType?: string | null;
|
||||
} | null>(null);
|
||||
const [isArchiving, setIsArchiving] = useState(false);
|
||||
const [archivingTaskId, setArchivingTaskId] = useState<string | null>(null);
|
||||
|
||||
const runArchive = useCallback(
|
||||
async (taskId: string, opts?: { cascade?: boolean }) => {
|
||||
setIsArchiving(true);
|
||||
setArchivingTaskId(taskId);
|
||||
try {
|
||||
await archiveAndSwitch(taskId, opts);
|
||||
} catch (error) {
|
||||
console.error("Failed to archive task:", error);
|
||||
} finally {
|
||||
setIsArchiving(false);
|
||||
setArchivingTaskId((current) => (current === taskId ? null : current));
|
||||
setArchivingTask((current) => (current?.id === taskId ? null : current));
|
||||
}
|
||||
},
|
||||
[archiveAndSwitch],
|
||||
);
|
||||
|
||||
const handleArchiveTask = useCallback(
|
||||
(taskId: string, opts?: { cascade?: boolean }) => {
|
||||
if (opts) {
|
||||
void runArchive(taskId, opts);
|
||||
return;
|
||||
}
|
||||
const task = findSheetTask(store.getState(), taskId);
|
||||
setArchivingTask({
|
||||
id: taskId,
|
||||
title: task?.title ?? t("task:thisTask"),
|
||||
executorType: task?.primaryExecutorType,
|
||||
});
|
||||
},
|
||||
[runArchive, store, t],
|
||||
);
|
||||
|
||||
const handleArchiveConfirm = useCallback(
|
||||
async (opts?: { cascade?: boolean }) => {
|
||||
if (!archivingTask) return;
|
||||
await runArchive(archivingTask.id, opts);
|
||||
},
|
||||
[archivingTask, runArchive],
|
||||
);
|
||||
|
||||
return {
|
||||
handleArchiveTask,
|
||||
archivingTask,
|
||||
archivingTaskId,
|
||||
setArchivingTask,
|
||||
isArchiving,
|
||||
handleArchiveConfirm,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSheetActions(
|
||||
workspaceId: string | null,
|
||||
onOpenChange: (open: boolean) => void,
|
||||
selection: TaskSheetSelectionController,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const setActiveTask = useAppStore((state) => state.setActiveTask);
|
||||
const setActiveSession = useAppStore((state) => state.setActiveSession);
|
||||
const store = useAppStoreApi();
|
||||
const archiveAndSwitch = useArchiveAndSwitchTask();
|
||||
const archiveActions = useSheetArchiveActions(store, archiveAndSwitch);
|
||||
const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store });
|
||||
const deleteActions = useSheetDeleteActions(store, removeTaskFromBoard);
|
||||
const detachActions = useTaskDetachDialog(store);
|
||||
@@ -443,42 +507,6 @@ export function useSheetActions(
|
||||
[loadTaskSessionsForTask, setActiveSession, setActiveTask, store, onOpenChange, selection],
|
||||
);
|
||||
|
||||
const [archivingTask, setArchivingTask] = useState<{
|
||||
id: string;
|
||||
title: string;
|
||||
executorType?: string | null;
|
||||
} | null>(null);
|
||||
const [isArchiving, setIsArchiving] = useState(false);
|
||||
|
||||
const handleArchiveTask = useCallback(
|
||||
(taskId: string) => {
|
||||
const state = store.getState();
|
||||
const task = findSheetTask(state, taskId);
|
||||
setArchivingTask({
|
||||
id: taskId,
|
||||
title: task?.title ?? t("task:thisTask"),
|
||||
executorType: task?.primaryExecutorType,
|
||||
});
|
||||
},
|
||||
[store, t],
|
||||
);
|
||||
|
||||
const handleArchiveConfirm = useCallback(
|
||||
async (opts?: { cascade?: boolean }) => {
|
||||
if (!archivingTask) return;
|
||||
setIsArchiving(true);
|
||||
try {
|
||||
await archiveAndSwitch(archivingTask.id, opts);
|
||||
} catch (error) {
|
||||
console.error("Failed to archive task:", error);
|
||||
} finally {
|
||||
setIsArchiving(false);
|
||||
setArchivingTask(null);
|
||||
}
|
||||
},
|
||||
[archivingTask, archiveAndSwitch],
|
||||
);
|
||||
|
||||
const { handleWorkspaceChange, handleTaskCreated } = useWorkspaceAndTaskCreatedActions({
|
||||
workspaceId,
|
||||
store,
|
||||
@@ -490,14 +518,10 @@ export function useSheetActions(
|
||||
|
||||
return {
|
||||
handleSelectTask,
|
||||
handleArchiveTask,
|
||||
...archiveActions,
|
||||
handleWorkspaceChange,
|
||||
handleTaskCreated,
|
||||
handleNestTask,
|
||||
archivingTask,
|
||||
setArchivingTask,
|
||||
isArchiving,
|
||||
handleArchiveConfirm,
|
||||
...deleteActions,
|
||||
...detachActions,
|
||||
};
|
||||
|
||||
@@ -47,6 +47,8 @@ export function buildMobileTaskSwitcherProps(
|
||||
onReorderSubtasks: helpers.onReorderSubtasks,
|
||||
pinnedTaskIds: helpers.pinnedTaskIds,
|
||||
deletingTaskId: props.deletingTaskId,
|
||||
archivingTaskId: props.archivingTaskId,
|
||||
isArchiving: props.isArchiving,
|
||||
isLoading: props.isLoading,
|
||||
loadError: props.loadError,
|
||||
onRetryLoad: props.onRetryLoad,
|
||||
|
||||
@@ -88,9 +88,11 @@ export type MobileTaskListProps = {
|
||||
onEditTask?: (task: TaskSwitcherItem) => void;
|
||||
onRenameTask?: (taskId: string, currentTitle: string) => void;
|
||||
onCreateSubtask?: (taskId: string, taskTitle: string) => void;
|
||||
onArchiveTask: (taskId: string) => void;
|
||||
onArchiveTask: (taskId: string, opts?: { cascade?: boolean }) => void;
|
||||
onDeleteTask: (taskId: string) => Promise<void> | void;
|
||||
onDetachTask: (taskId: string) => Promise<void> | void;
|
||||
archivingTaskId?: string | null;
|
||||
isArchiving?: boolean;
|
||||
onNestTask?: (taskId: string, parentTaskId: string) => void;
|
||||
onLinkPullRequest?: (taskId: string, taskTitle?: string) => void;
|
||||
onLinkIssue?: (taskId: string, taskTitle?: string) => void;
|
||||
@@ -341,6 +343,8 @@ function TaskSwitcherSurfaceContent({
|
||||
linking.taskListHandlers.onLinkSentryIssue,
|
||||
)}
|
||||
deletingTaskId={actions.deletingTaskId}
|
||||
archivingTaskId={actions.archivingTaskId}
|
||||
isArchiving={actions.isArchiving}
|
||||
isLoading={data.tasksLoading}
|
||||
loadError={data.archivedError ? t("sidebar:archivedLoadFailed") : null}
|
||||
onRetryLoad={data.retryArchivedTasks}
|
||||
|
||||
@@ -126,6 +126,50 @@ describe("TaskArchiveConfirmDialog preference", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskArchiveConfirmDialog classification safety", () => {
|
||||
it("disables archive while descendant classification is pending", () => {
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
renderDialog(
|
||||
<TaskArchiveConfirmDialog
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
taskTitle="My task"
|
||||
taskId="task-1"
|
||||
executorType="worktree"
|
||||
subtaskClassification={{ status: "loading", total: 0 }}
|
||||
confirmTestId="archive-confirm"
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
const confirm = screen.getByTestId("archive-confirm");
|
||||
expect(confirm.hasAttribute("disabled")).toBe(true);
|
||||
fireEvent.click(confirm);
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the safe fallback actionable when classification fails", () => {
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
renderDialog(
|
||||
<TaskArchiveConfirmDialog
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
taskTitle="My task"
|
||||
taskId="task-1"
|
||||
executorType="worktree"
|
||||
subtaskClassification={{ status: "error", total: 0 }}
|
||||
confirmTestId="archive-confirm"
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("archive-confirm"));
|
||||
expect(onConfirm).toHaveBeenCalledWith({ cascade: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskArchiveConfirmDialog cleanup copy", () => {
|
||||
it("renders local-executor reassurance about untouched repo", () => {
|
||||
renderDialog(
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@kandev/ui/alert-dialog";
|
||||
import { Checkbox } from "@kandev/ui/checkbox";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useSubtaskCount } from "@/hooks/use-subtask-count";
|
||||
import { useSubtaskCountState, type SubtaskCountResult } from "@/hooks/use-subtask-count";
|
||||
import { useTaskInFlight } from "@/hooks/use-task-in-flight";
|
||||
import { getCleanupSummary, getBulkCleanupSummary } from "./task-cleanup-summary";
|
||||
import { StillWorkingWarning } from "./task-still-working-warning";
|
||||
@@ -41,6 +41,8 @@ type TaskArchiveConfirmDialogProps = {
|
||||
executorTypes?: Array<string | null | undefined>;
|
||||
onConfirm: (opts: { cascade: boolean }) => void;
|
||||
confirmTestId?: string;
|
||||
/** Preflight result supplied by the local confirmation adapter. */
|
||||
subtaskClassification?: SubtaskCountResult;
|
||||
};
|
||||
|
||||
type ArchiveOpenMode = "pending" | "confirm" | "bypass";
|
||||
@@ -85,6 +87,18 @@ function computeTaskIsInFlight(isInFlight: boolean | undefined, storeInFlight: b
|
||||
return Boolean(isInFlight) || storeInFlight;
|
||||
}
|
||||
|
||||
function isArchiveActionDisabled(
|
||||
isArchiving: boolean | undefined,
|
||||
classification: SubtaskCountResult,
|
||||
): boolean {
|
||||
return (
|
||||
Boolean(isArchiving) || classification.status === "idle" || classification.status === "loading"
|
||||
);
|
||||
}
|
||||
|
||||
// The legacy cascade dialog intentionally keeps its state, preference bypass,
|
||||
// and cleanup copy in one boundary.
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
export function TaskArchiveConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -99,6 +113,7 @@ export function TaskArchiveConfirmDialog({
|
||||
executorTypes,
|
||||
onConfirm,
|
||||
confirmTestId,
|
||||
subtaskClassification,
|
||||
}: TaskArchiveConfirmDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const confirmTaskArchive = useAppStore((state) => state.userSettings?.confirmTaskArchive ?? true);
|
||||
@@ -120,7 +135,14 @@ export function TaskArchiveConfirmDialog({
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
);
|
||||
const subtaskCount = useSubtaskCount(open && requiresConfirmation, taskId, taskIds);
|
||||
const fetchedSubtaskClassification = useSubtaskCountState(
|
||||
open && requiresConfirmation && !subtaskClassification,
|
||||
taskId,
|
||||
taskIds,
|
||||
);
|
||||
const classification = subtaskClassification ?? fetchedSubtaskClassification;
|
||||
const archiveDisabled = isArchiveActionDisabled(isArchiving, classification);
|
||||
const subtaskCount = classification.status === "resolved" ? classification.total : 0;
|
||||
const shouldCheckInFlight = shouldCheckTaskInFlight(open, requiresConfirmation);
|
||||
const storeInFlight = useTaskInFlight(taskId, taskIds, shouldCheckInFlight);
|
||||
const taskIsInFlight = computeTaskIsInFlight(isInFlight, storeInFlight);
|
||||
@@ -170,11 +192,11 @@ export function TaskArchiveConfirmDialog({
|
||||
{t("common:cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isArchiving}
|
||||
disabled={archiveDisabled}
|
||||
className="cursor-pointer !text-sm"
|
||||
data-testid={confirmTestId}
|
||||
onClick={() => {
|
||||
if (isArchiving) return;
|
||||
if (archiveDisabled) return;
|
||||
onConfirm({ cascade });
|
||||
handleOpenChange(false);
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useRef } from "react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
|
||||
const getSubtaskCountMock = vi.hoisted(() => vi.fn());
|
||||
const pointerState = vi.hoisted(() => ({ isFinePointer: false }));
|
||||
const CONFIRM_TEST_ID = "archive-task-confirm";
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
getSubtaskCount: (...args: unknown[]) => getSubtaskCountMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||
useResponsiveBreakpoint: () => pointerState,
|
||||
}));
|
||||
|
||||
import { TaskArchiveConfirmation } from "./task-archive-confirmation";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function ConfirmationHarness({
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
}: {
|
||||
onConfirm: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||
return (
|
||||
<>
|
||||
<button ref={anchorRef} type="button" data-testid="archive-anchor">
|
||||
Archive source
|
||||
</button>
|
||||
<TaskArchiveConfirmation
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
taskId="task-1"
|
||||
taskTitle="Task One"
|
||||
executorType="worktree"
|
||||
onConfirm={onConfirm}
|
||||
confirmTestId={CONFIRM_TEST_ID}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderConfirmation(onConfirm = vi.fn(), onOpenChange = vi.fn()) {
|
||||
return render(
|
||||
<StateProvider>
|
||||
<ConfirmationHarness onConfirm={onConfirm} onOpenChange={onOpenChange} />
|
||||
</StateProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TaskArchiveConfirmation classification", () => {
|
||||
beforeEach(() => {
|
||||
pointerState.isFinePointer = false;
|
||||
getSubtaskCountMock.mockReset();
|
||||
});
|
||||
|
||||
it("does not expose an archive action while descendant classification is pending", () => {
|
||||
getSubtaskCountMock.mockReturnValue(new Promise(() => undefined));
|
||||
|
||||
renderConfirmation();
|
||||
|
||||
expect(screen.queryByTestId(CONFIRM_TEST_ID)).toBeNull();
|
||||
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a disabled anchored loading surface while desktop classification is pending", () => {
|
||||
pointerState.isFinePointer = true;
|
||||
getSubtaskCountMock.mockReturnValue(new Promise(() => undefined));
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
renderConfirmation(vi.fn(), onOpenChange);
|
||||
|
||||
expect(screen.getByTestId("task-archive-confirm-popover")).toBeTruthy();
|
||||
expect(screen.getByText("Loading…")).toBeTruthy();
|
||||
expect(screen.getByTestId(CONFIRM_TEST_ID).hasAttribute("disabled")).toBe(true);
|
||||
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("uses touch-sized local actions after a resolved zero-descendant result", async () => {
|
||||
getSubtaskCountMock.mockResolvedValue({ count: 0 });
|
||||
|
||||
renderConfirmation();
|
||||
|
||||
const confirmation = await screen.findByTestId("task-archive-inline-confirmation");
|
||||
expect(confirmation).toBeTruthy();
|
||||
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||
expect(screen.getByTestId(CONFIRM_TEST_ID).className).toContain("h-11");
|
||||
});
|
||||
|
||||
it("keeps descendants on the cascade dialog branch", async () => {
|
||||
getSubtaskCountMock.mockResolvedValue({ count: 2 });
|
||||
|
||||
renderConfirmation();
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(screen.getByTestId("archive-cascade-checkbox")).toBeTruthy();
|
||||
expect(screen.queryByTestId("task-archive-inline-confirmation")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an unknown classification on the safe dialog branch", async () => {
|
||||
getSubtaskCountMock.mockRejectedValue(new Error("classification unavailable"));
|
||||
|
||||
renderConfirmation();
|
||||
|
||||
expect(await screen.findByRole("alertdialog")).toBeTruthy();
|
||||
expect(screen.queryByTestId("archive-cascade-checkbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes the local surface before invoking the archive callback", async () => {
|
||||
getSubtaskCountMock.mockResolvedValue({ count: 0 });
|
||||
let closedBeforeConfirm = false;
|
||||
const onConfirm = vi.fn(() => {
|
||||
closedBeforeConfirm = screen.queryByTestId("task-archive-inline-confirmation") === null;
|
||||
});
|
||||
|
||||
renderConfirmation(onConfirm);
|
||||
fireEvent.click(await screen.findByTestId(CONFIRM_TEST_ID));
|
||||
|
||||
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce());
|
||||
expect(closedBeforeConfirm).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconLoader } from "@tabler/icons-react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
|
||||
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { useSubtaskCountState, type SubtaskCountResult } from "@/hooks/use-subtask-count";
|
||||
import { useTaskInFlight } from "@/hooks/use-task-in-flight";
|
||||
import { getCleanupSummary } from "./task-cleanup-summary";
|
||||
import { StillWorkingWarning } from "./task-still-working-warning";
|
||||
import { TaskArchiveConfirmDialog } from "./task-archive-confirm-dialog";
|
||||
|
||||
type ArchiveConfirmValues = { cascade: boolean };
|
||||
|
||||
const ARCHIVE_LABEL_KEY = "task:archive";
|
||||
const ARCHIVE_INLINE_CONFIRMATION_TEST_ID = "task-archive-inline-confirmation";
|
||||
const DEFAULT_CONFIRM_TEST_ID = "archive-task-confirm";
|
||||
|
||||
export type TaskArchiveConfirmationProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
taskTitle?: string;
|
||||
taskId?: string;
|
||||
taskIds?: string[];
|
||||
isBulkOperation?: boolean;
|
||||
count?: number;
|
||||
isArchiving?: boolean;
|
||||
isInFlight?: boolean;
|
||||
executorType?: string | null;
|
||||
executorTypes?: Array<string | null | undefined>;
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
focusBoundaryRef?: RefObject<HTMLElement | null>;
|
||||
onConfirm: (values: ArchiveConfirmValues) => void | Promise<void>;
|
||||
confirmTestId?: string;
|
||||
/** Render a simple confirmation inside an existing action surface. */
|
||||
inline?: boolean;
|
||||
};
|
||||
|
||||
function ArchiveDescription({
|
||||
taskTitle,
|
||||
cleanupLines,
|
||||
taskIsInFlight,
|
||||
}: {
|
||||
taskTitle?: string;
|
||||
cleanupLines: string[];
|
||||
taskIsInFlight: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className="block space-y-2">
|
||||
<span className="block">{t("task:archiveTaskConfirm", { taskTitle })}</span>
|
||||
{cleanupLines.map((line) => (
|
||||
<span key={line} className="block">
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
{taskIsInFlight && <StillWorkingWarning />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ArchiveConfirmCopy({
|
||||
taskTitle,
|
||||
executorType,
|
||||
isInFlight,
|
||||
isArchiving,
|
||||
onCancel,
|
||||
onClose,
|
||||
onConfirm,
|
||||
confirmTestId,
|
||||
}: {
|
||||
taskTitle?: string;
|
||||
executorType?: string | null;
|
||||
isInFlight: boolean;
|
||||
isArchiving?: boolean;
|
||||
onCancel: () => void;
|
||||
onClose?: () => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
confirmTestId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const description = (
|
||||
<ArchiveDescription
|
||||
taskTitle={taskTitle}
|
||||
cleanupLines={getCleanupSummary(executorType).lines}
|
||||
taskIsInFlight={isInFlight}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<InlineConfirmActions
|
||||
density="touch"
|
||||
disabled={isArchiving}
|
||||
testId={ARCHIVE_INLINE_CONFIRMATION_TEST_ID}
|
||||
ariaLabel={t("task:archiveTaskTitle")}
|
||||
description={description}
|
||||
cancelLabel={t("common:cancel")}
|
||||
confirmLabel={t(ARCHIVE_LABEL_KEY)}
|
||||
confirmAriaLabel={t(ARCHIVE_LABEL_KEY)}
|
||||
confirmTestId={confirmTestId}
|
||||
onCancel={onCancel}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ArchiveConfirmPopover({
|
||||
open,
|
||||
anchorRef,
|
||||
focusReturnRef,
|
||||
focusBoundaryRef,
|
||||
taskTitle,
|
||||
executorType,
|
||||
isInFlight,
|
||||
isArchiving,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
confirmTestId,
|
||||
}: {
|
||||
open: boolean;
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
focusBoundaryRef?: RefObject<HTMLElement | null>;
|
||||
taskTitle?: string;
|
||||
executorType?: string | null;
|
||||
isInFlight: boolean;
|
||||
isArchiving?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
confirmTestId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ActionConfirmPopover
|
||||
open={open}
|
||||
disabled={isArchiving}
|
||||
anchorRef={anchorRef}
|
||||
focusReturnRef={focusReturnRef}
|
||||
focusBoundaryRef={focusBoundaryRef ?? anchorRef}
|
||||
title={t("task:archiveTaskTitle")}
|
||||
description={
|
||||
<ArchiveDescription
|
||||
taskTitle={taskTitle}
|
||||
cleanupLines={getCleanupSummary(executorType).lines}
|
||||
taskIsInFlight={isInFlight}
|
||||
/>
|
||||
}
|
||||
cancelLabel={t("common:cancel")}
|
||||
confirmLabel={t(ARCHIVE_LABEL_KEY)}
|
||||
confirmAriaLabel={t(ARCHIVE_LABEL_KEY)}
|
||||
confirmTestId={confirmTestId}
|
||||
testId="task-archive-confirm-popover"
|
||||
onOpenChange={onOpenChange}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ArchiveClassifyingPopover({
|
||||
anchorRef,
|
||||
focusReturnRef,
|
||||
focusBoundaryRef,
|
||||
onOpenChange,
|
||||
confirmTestId,
|
||||
}: {
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
focusBoundaryRef?: RefObject<HTMLElement | null>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
confirmTestId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ActionConfirmPopover
|
||||
open
|
||||
anchorRef={anchorRef}
|
||||
focusReturnRef={focusReturnRef}
|
||||
focusBoundaryRef={focusBoundaryRef ?? anchorRef}
|
||||
title={t("task:archiveTaskTitle")}
|
||||
description={
|
||||
<span className="flex items-center gap-2">
|
||||
<IconLoader className="h-4 w-4 animate-spin" />
|
||||
{t("common:loading")}
|
||||
</span>
|
||||
}
|
||||
cancelLabel={t("common:cancel")}
|
||||
confirmLabel={t(ARCHIVE_LABEL_KEY)}
|
||||
confirmAriaLabel={t(ARCHIVE_LABEL_KEY)}
|
||||
confirmTestId={confirmTestId}
|
||||
confirmDisabled
|
||||
testId="task-archive-confirm-popover"
|
||||
onOpenChange={onOpenChange}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onConfirm={() => undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ArchiveDialogProps = Pick<
|
||||
TaskArchiveConfirmationProps,
|
||||
| "onOpenChange"
|
||||
| "taskTitle"
|
||||
| "isBulkOperation"
|
||||
| "count"
|
||||
| "isArchiving"
|
||||
| "taskId"
|
||||
| "taskIds"
|
||||
| "isInFlight"
|
||||
| "executorType"
|
||||
| "executorTypes"
|
||||
| "onConfirm"
|
||||
| "confirmTestId"
|
||||
> & {
|
||||
subtaskClassification: SubtaskCountResult;
|
||||
};
|
||||
|
||||
function ArchiveDialog({ subtaskClassification, ...props }: ArchiveDialogProps) {
|
||||
return <TaskArchiveConfirmDialog open {...props} subtaskClassification={subtaskClassification} />;
|
||||
}
|
||||
|
||||
type ArchiveConfirmationContentProps = ArchiveDialogProps & {
|
||||
confirmTaskArchive: boolean;
|
||||
isFinePointer: boolean;
|
||||
taskIsInFlight: boolean;
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
focusBoundaryRef?: RefObject<HTMLElement | null>;
|
||||
inline: boolean;
|
||||
};
|
||||
|
||||
function ArchiveConfirmationContent({
|
||||
confirmTaskArchive,
|
||||
isFinePointer,
|
||||
taskIsInFlight,
|
||||
anchorRef,
|
||||
focusReturnRef,
|
||||
focusBoundaryRef,
|
||||
inline,
|
||||
subtaskClassification,
|
||||
...dialogProps
|
||||
}: ArchiveConfirmationContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const shouldUseDialog =
|
||||
!confirmTaskArchive ||
|
||||
dialogProps.isBulkOperation ||
|
||||
subtaskClassification.status === "error" ||
|
||||
subtaskClassification.total > 0;
|
||||
|
||||
if (shouldUseDialog) {
|
||||
return <ArchiveDialog {...dialogProps} subtaskClassification={subtaskClassification} />;
|
||||
}
|
||||
|
||||
if (subtaskClassification.status !== "resolved") {
|
||||
if (inline) {
|
||||
return (
|
||||
<span data-testid="task-archive-classifying" className="text-xs text-muted-foreground">
|
||||
{t("common:loading")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!isFinePointer) return null;
|
||||
return (
|
||||
<ArchiveClassifyingPopover
|
||||
anchorRef={anchorRef}
|
||||
focusReturnRef={focusReturnRef}
|
||||
focusBoundaryRef={focusBoundaryRef}
|
||||
onOpenChange={dialogProps.onOpenChange}
|
||||
confirmTestId={dialogProps.confirmTestId ?? DEFAULT_CONFIRM_TEST_ID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const confirm = () => dialogProps.onConfirm({ cascade: false });
|
||||
if (inline || !isFinePointer) {
|
||||
return (
|
||||
<ArchiveConfirmCopy
|
||||
taskTitle={dialogProps.taskTitle}
|
||||
executorType={dialogProps.executorType}
|
||||
isInFlight={taskIsInFlight}
|
||||
isArchiving={dialogProps.isArchiving}
|
||||
onCancel={() => dialogProps.onOpenChange(false)}
|
||||
onClose={() => dialogProps.onOpenChange(false)}
|
||||
onConfirm={confirm}
|
||||
confirmTestId={dialogProps.confirmTestId ?? DEFAULT_CONFIRM_TEST_ID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ArchiveConfirmPopover
|
||||
open
|
||||
anchorRef={anchorRef}
|
||||
focusReturnRef={focusReturnRef}
|
||||
focusBoundaryRef={focusBoundaryRef}
|
||||
taskTitle={dialogProps.taskTitle}
|
||||
executorType={dialogProps.executorType}
|
||||
isInFlight={taskIsInFlight}
|
||||
isArchiving={dialogProps.isArchiving}
|
||||
onOpenChange={dialogProps.onOpenChange}
|
||||
onConfirm={confirm}
|
||||
confirmTestId={dialogProps.confirmTestId ?? DEFAULT_CONFIRM_TEST_ID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskArchiveConfirmation({
|
||||
open,
|
||||
onOpenChange,
|
||||
taskTitle,
|
||||
taskId,
|
||||
taskIds,
|
||||
isBulkOperation = false,
|
||||
count,
|
||||
isArchiving,
|
||||
isInFlight,
|
||||
executorType,
|
||||
executorTypes,
|
||||
anchorRef,
|
||||
focusReturnRef,
|
||||
focusBoundaryRef,
|
||||
onConfirm,
|
||||
confirmTestId = DEFAULT_CONFIRM_TEST_ID,
|
||||
inline = false,
|
||||
}: TaskArchiveConfirmationProps) {
|
||||
const { isFinePointer } = useResponsiveBreakpoint();
|
||||
const confirmTaskArchive = useAppStore((state) => state.userSettings?.confirmTaskArchive ?? true);
|
||||
const classification = useSubtaskCountState(open && confirmTaskArchive, taskId, taskIds);
|
||||
const storeInFlight = useTaskInFlight(taskId, taskIds, open && confirmTaskArchive);
|
||||
const taskIsInFlight = Boolean(isInFlight) || storeInFlight;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<ArchiveConfirmationContent
|
||||
confirmTaskArchive={confirmTaskArchive}
|
||||
isFinePointer={isFinePointer}
|
||||
taskIsInFlight={taskIsInFlight}
|
||||
anchorRef={anchorRef}
|
||||
focusReturnRef={focusReturnRef}
|
||||
focusBoundaryRef={focusBoundaryRef}
|
||||
inline={inline}
|
||||
onOpenChange={onOpenChange}
|
||||
taskTitle={taskTitle}
|
||||
isBulkOperation={isBulkOperation}
|
||||
count={count}
|
||||
isArchiving={isArchiving}
|
||||
taskId={taskId}
|
||||
taskIds={taskIds}
|
||||
isInFlight={isInFlight}
|
||||
executorType={executorType}
|
||||
executorTypes={executorTypes}
|
||||
onConfirm={onConfirm}
|
||||
confirmTestId={confirmTestId}
|
||||
subtaskClassification={classification}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { memo, type ReactNode } from "react";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconCircleCheck,
|
||||
@@ -70,6 +70,7 @@ type TaskItemProps = {
|
||||
lastActivityAt?: string;
|
||||
showActivityTime?: boolean;
|
||||
menuOpen?: boolean;
|
||||
archiveConfirmation?: ReactNode;
|
||||
isDeleting?: boolean;
|
||||
taskId?: string;
|
||||
/** Drives the `task-row-metadata` plugin slot's `workflowStepId`. */
|
||||
@@ -433,120 +434,110 @@ function TaskItemContent({
|
||||
);
|
||||
}
|
||||
|
||||
// The row keeps its state and accessibility affordances together for keyboard
|
||||
// selection, so this small exception avoids splitting that interaction across
|
||||
// multiple components.
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
export const TaskItem = memo(function TaskItem({
|
||||
title,
|
||||
autopilot,
|
||||
state,
|
||||
sessionState,
|
||||
foregroundActivity,
|
||||
isArchived,
|
||||
isSelected = false,
|
||||
isMultiSelected = false,
|
||||
onClick,
|
||||
onSelect,
|
||||
function TaskItemActions({
|
||||
archiveConfirmation,
|
||||
diffStats,
|
||||
comparisonUnavailable,
|
||||
isRemoteExecutor,
|
||||
remoteExecutorType,
|
||||
remoteExecutorName,
|
||||
updatedAt,
|
||||
lastActivityAt,
|
||||
showActivityTime = false,
|
||||
menuOpen = false,
|
||||
isDeleting,
|
||||
taskId,
|
||||
workflowStepId,
|
||||
primarySessionId,
|
||||
hasPendingClarification,
|
||||
hasPendingPermission,
|
||||
interrupted,
|
||||
isSubTask,
|
||||
depth,
|
||||
subtaskCount,
|
||||
subtasksCollapsed,
|
||||
onToggleSubtasks,
|
||||
repositoryPath,
|
||||
showRepository = true,
|
||||
prInfo,
|
||||
queuedCount,
|
||||
wipQueue,
|
||||
issueInfo,
|
||||
isPinned,
|
||||
agentErrorMessage,
|
||||
isOnLastWorkflowStep = false,
|
||||
}: TaskItemProps) {
|
||||
const effectiveMenuOpen = menuOpen || isDeleting === true;
|
||||
const hasDiffStats = !!diffStats && (diffStats.additions > 0 || diffStats.deletions > 0);
|
||||
const taskColor = useTaskColor(taskId);
|
||||
const indent = computeRowIndent(resolveRowDepth(depth, isSubTask));
|
||||
hasDiffStats,
|
||||
menuOpen,
|
||||
effectiveMenuOpen,
|
||||
}: {
|
||||
archiveConfirmation?: ReactNode;
|
||||
diffStats?: DiffStats;
|
||||
hasDiffStats: boolean;
|
||||
menuOpen: boolean;
|
||||
effectiveMenuOpen: boolean;
|
||||
}) {
|
||||
if (archiveConfirmation) {
|
||||
return (
|
||||
<div className="min-w-0 basis-full flex items-center justify-end">{archiveConfirmation}</div>
|
||||
);
|
||||
}
|
||||
if (hasDiffStats) {
|
||||
return (
|
||||
<div className="mobile-task-actions-with-stats group/actions relative shrink-0 self-center flex items-center">
|
||||
<DiffStatsRight diffStats={diffStats!} menuOpen={effectiveMenuOpen} />
|
||||
<div className="mobile-task-actions-slot absolute inset-0 flex items-center justify-end">
|
||||
<TaskMenuButton visible={effectiveMenuOpen} expanded={menuOpen} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <TaskMenuButton visible={effectiveMenuOpen} expanded={menuOpen} rowFocus />;
|
||||
}
|
||||
|
||||
export const TaskItem = memo(function TaskItem(props: TaskItemProps) {
|
||||
const isSelected = props.isSelected ?? false;
|
||||
const isMultiSelected = props.isMultiSelected ?? false;
|
||||
const menuOpen = props.menuOpen ?? false;
|
||||
const effectiveMenuOpen = menuOpen || props.isDeleting === true;
|
||||
const hasDiffStats =
|
||||
!!props.diffStats && (props.diffStats.additions > 0 || props.diffStats.deletions > 0);
|
||||
const taskColor = useTaskColor(props.taskId);
|
||||
const indent = computeRowIndent(resolveRowDepth(props.depth, props.isSubTask));
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid="sidebar-task-item"
|
||||
data-task-row-id={taskId}
|
||||
data-task-row-id={props.taskId}
|
||||
{...taskItemStateAttrs(isSelected, isMultiSelected)}
|
||||
onClick={taskItemRowClick(onSelect, onClick)}
|
||||
onKeyDown={(e) => handleTaskItemKeyDown(e, onSelect, onClick)}
|
||||
onClick={taskItemRowClick(props.onSelect, props.onClick)}
|
||||
onKeyDown={(e) => handleTaskItemKeyDown(e, props.onSelect, props.onClick)}
|
||||
style={indent.depth > 0 ? { paddingLeft: indent.paddingLeftPx } : undefined}
|
||||
className={taskItemRowClassName(isSelected, isMultiSelected, indent.depth === 0)}
|
||||
className={cn(
|
||||
taskItemRowClassName(isSelected, isMultiSelected, indent.depth === 0),
|
||||
props.archiveConfirmation && "flex-wrap",
|
||||
)}
|
||||
>
|
||||
<SelectionBar isSelected={isSelected} color={taskColor} />
|
||||
<RowConnector depth={indent.depth} leftPx={indent.connectorLeftPx} />
|
||||
<TaskStateIcon
|
||||
sessionState={sessionState}
|
||||
state={state}
|
||||
foregroundActivity={foregroundActivity}
|
||||
isInProgress={computeIsInProgress(state, sessionState)}
|
||||
hasPendingClarification={hasPendingClarification}
|
||||
hasPendingPermission={hasPendingPermission}
|
||||
interrupted={interrupted}
|
||||
isOnLastWorkflowStep={isOnLastWorkflowStep}
|
||||
sessionState={props.sessionState}
|
||||
state={props.state}
|
||||
foregroundActivity={props.foregroundActivity}
|
||||
isInProgress={computeIsInProgress(props.state, props.sessionState)}
|
||||
hasPendingClarification={props.hasPendingClarification}
|
||||
hasPendingPermission={props.hasPendingPermission}
|
||||
interrupted={props.interrupted}
|
||||
isOnLastWorkflowStep={props.isOnLastWorkflowStep ?? false}
|
||||
/>
|
||||
<TaskItemContent
|
||||
title={title}
|
||||
autopilot={autopilot}
|
||||
taskId={taskId}
|
||||
workflowStepId={workflowStepId}
|
||||
isRemoteExecutor={isRemoteExecutor}
|
||||
remoteExecutorType={remoteExecutorType}
|
||||
remoteExecutorName={remoteExecutorName}
|
||||
primarySessionId={primarySessionId}
|
||||
isArchived={isArchived}
|
||||
isPinned={isPinned}
|
||||
repositoryPath={repositoryPath}
|
||||
showRepository={showRepository}
|
||||
updatedAt={updatedAt}
|
||||
lastActivityAt={lastActivityAt}
|
||||
showActivityTime={showActivityTime}
|
||||
prInfo={prInfo}
|
||||
queuedCount={queuedCount}
|
||||
wipQueue={wipQueue}
|
||||
issueInfo={issueInfo}
|
||||
agentErrorMessage={agentErrorMessage}
|
||||
comparisonUnavailable={comparisonUnavailable}
|
||||
title={props.title}
|
||||
autopilot={props.autopilot}
|
||||
taskId={props.taskId}
|
||||
workflowStepId={props.workflowStepId}
|
||||
isRemoteExecutor={props.isRemoteExecutor}
|
||||
remoteExecutorType={props.remoteExecutorType}
|
||||
remoteExecutorName={props.remoteExecutorName}
|
||||
primarySessionId={props.primarySessionId}
|
||||
isArchived={props.isArchived}
|
||||
isPinned={props.isPinned}
|
||||
repositoryPath={props.repositoryPath}
|
||||
showRepository={props.showRepository ?? true}
|
||||
updatedAt={props.updatedAt}
|
||||
lastActivityAt={props.lastActivityAt}
|
||||
showActivityTime={props.showActivityTime ?? false}
|
||||
prInfo={props.prInfo}
|
||||
queuedCount={props.queuedCount}
|
||||
wipQueue={props.wipQueue}
|
||||
issueInfo={props.issueInfo}
|
||||
agentErrorMessage={props.agentErrorMessage}
|
||||
comparisonUnavailable={props.comparisonUnavailable}
|
||||
/>
|
||||
{hasDiffStats ? (
|
||||
<div className="mobile-task-actions-with-stats group/actions relative shrink-0 self-center flex items-center">
|
||||
<DiffStatsRight diffStats={diffStats!} menuOpen={effectiveMenuOpen} />
|
||||
<div className="mobile-task-actions-slot absolute inset-0 flex items-center justify-end">
|
||||
<TaskMenuButton visible={effectiveMenuOpen} expanded={menuOpen} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TaskMenuButton visible={effectiveMenuOpen} expanded={menuOpen} rowFocus />
|
||||
)}
|
||||
{!!subtaskCount && subtaskCount > 0 && !!onToggleSubtasks && (
|
||||
<TaskItemActions
|
||||
archiveConfirmation={props.archiveConfirmation}
|
||||
diffStats={props.diffStats}
|
||||
hasDiffStats={hasDiffStats}
|
||||
menuOpen={menuOpen}
|
||||
effectiveMenuOpen={effectiveMenuOpen}
|
||||
/>
|
||||
{!!props.subtaskCount && props.subtaskCount > 0 && !!props.onToggleSubtasks && (
|
||||
<SubtaskToggle
|
||||
taskId={taskId}
|
||||
count={subtaskCount!}
|
||||
collapsed={!!subtasksCollapsed}
|
||||
onToggle={onToggleSubtasks!}
|
||||
taskId={props.taskId}
|
||||
count={props.subtaskCount}
|
||||
collapsed={!!props.subtasksCollapsed}
|
||||
onToggle={props.onToggleSubtasks}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -65,6 +65,8 @@ export function buildTaskSwitcherProps(args: {
|
||||
onNestTask: args.handleNestTask,
|
||||
pinnedTaskIds: args.pinnedTaskIds,
|
||||
deletingTaskId: args.sidebarActions.deletingTaskId,
|
||||
archivingTaskId: args.sidebarActions.archivingTaskId,
|
||||
isArchiving: args.sidebarActions.isArchiving,
|
||||
isLoading: args.isLoadingWorkflow,
|
||||
loadError: args.archivedError ? args.archivedLoadErrorLabel : null,
|
||||
onRetryLoad: args.retryArchivedTasks,
|
||||
|
||||
@@ -199,23 +199,8 @@ function useArchiveActions(store: StoreApi) {
|
||||
const [archivingTaskId, setArchivingTaskId] = useState<string | null>(null);
|
||||
const [isArchiving, setIsArchiving] = useState(false);
|
||||
|
||||
const handleArchiveTask = useCallback(
|
||||
(taskId: string) => {
|
||||
const state = store.getState();
|
||||
const task = findSidebarTask(state, taskId);
|
||||
setArchivingTask({
|
||||
id: taskId,
|
||||
title: task?.title ?? t("task:thisTask"),
|
||||
executorType: task?.primaryExecutorType,
|
||||
});
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
const handleArchiveConfirm = useCallback(
|
||||
async (opts: { cascade: boolean }) => {
|
||||
if (!archivingTask) return;
|
||||
const taskId = archivingTask.id;
|
||||
const runArchive = useCallback(
|
||||
async (taskId: string, opts: { cascade?: boolean }) => {
|
||||
setIsArchiving(true);
|
||||
setArchivingTaskId(taskId);
|
||||
try {
|
||||
@@ -228,7 +213,32 @@ function useArchiveActions(store: StoreApi) {
|
||||
setArchivingTask((current) => (current?.id === taskId ? null : current));
|
||||
}
|
||||
},
|
||||
[archivingTask, archiveAndSwitch],
|
||||
[archiveAndSwitch],
|
||||
);
|
||||
|
||||
const handleArchiveTask = useCallback(
|
||||
(taskId: string, opts?: { cascade?: boolean }) => {
|
||||
if (opts) {
|
||||
void runArchive(taskId, opts);
|
||||
return;
|
||||
}
|
||||
const state = store.getState();
|
||||
const task = findSidebarTask(state, taskId);
|
||||
setArchivingTask({
|
||||
id: taskId,
|
||||
title: task?.title ?? t("task:thisTask"),
|
||||
executorType: task?.primaryExecutorType,
|
||||
});
|
||||
},
|
||||
[runArchive, store, t],
|
||||
);
|
||||
|
||||
const handleArchiveConfirm = useCallback(
|
||||
async (opts: { cascade: boolean }) => {
|
||||
if (!archivingTask) return;
|
||||
await runArchive(archivingTask.id, opts);
|
||||
},
|
||||
[archivingTask, runArchive],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { TaskArchiveConfirmation } from "./task-archive-confirmation";
|
||||
import type { TaskSwitcherItem } from "./task-switcher-types";
|
||||
|
||||
type TaskSwitcherArchiveConfirmationOptions = {
|
||||
task: TaskSwitcherItem;
|
||||
onArchiveTask?: (taskId: string, opts?: { cascade?: boolean }) => void;
|
||||
isArchiving?: boolean;
|
||||
closeMenu: () => void;
|
||||
};
|
||||
|
||||
export function useTaskSwitcherArchiveConfirmation({
|
||||
task,
|
||||
onArchiveTask,
|
||||
isArchiving,
|
||||
closeMenu,
|
||||
}: TaskSwitcherArchiveConfirmationOptions) {
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
const archiveAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const openTimerRef = useRef<number | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (openTimerRef.current !== null) window.clearTimeout(openTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const requestArchive = onArchiveTask
|
||||
? () => {
|
||||
closeMenu();
|
||||
openTimerRef.current = window.setTimeout(() => {
|
||||
openTimerRef.current = null;
|
||||
setArchiveOpen(true);
|
||||
}, 0);
|
||||
}
|
||||
: undefined;
|
||||
const archiveConfirmation =
|
||||
onArchiveTask && archiveOpen ? (
|
||||
<TaskArchiveConfirmation
|
||||
open
|
||||
anchorRef={archiveAnchorRef}
|
||||
focusReturnRef={archiveAnchorRef}
|
||||
taskId={task.id}
|
||||
taskTitle={task.title}
|
||||
executorType={task.remoteExecutorType}
|
||||
isArchiving={isArchiving}
|
||||
isInFlight={task.foregroundActivity !== null && task.foregroundActivity !== undefined}
|
||||
onOpenChange={setArchiveOpen}
|
||||
onConfirm={({ cascade }) => onArchiveTask(task.id, { cascade })}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return { archiveOpen, archiveAnchorRef, requestArchive, archiveConfirmation };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { pluginRegistry } from "@/lib/plugins/registry";
|
||||
@@ -19,6 +20,10 @@ function task(overrides: Partial<TaskSwitcherItem> = {}): TaskSwitcherItem {
|
||||
return { id: "task-1", title: "Task 1", state: "IN_PROGRESS", ...overrides };
|
||||
}
|
||||
|
||||
function ArchiveAwareRow({ archiveConfirmation }: { archiveConfirmation?: ReactNode }) {
|
||||
return <div data-testid="task-row">Task 1{archiveConfirmation}</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for the dnd-kit drag handle that wraps the row. In the real tree
|
||||
* the handle's sensor listeners (`onMouseDown`, `onTouchStart`, `onPointerDown`)
|
||||
@@ -43,7 +48,7 @@ function renderWithDragHandle(overrides: Partial<TaskSwitcherItem> = {}) {
|
||||
onClick={onClick}
|
||||
>
|
||||
<TaskItemWithContextMenu task={task(overrides)} onArchiveTask={onArchiveTask}>
|
||||
<div data-testid="task-row">Task 1</div>
|
||||
<ArchiveAwareRow />
|
||||
</TaskItemWithContextMenu>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
@@ -180,8 +185,13 @@ describe("TaskItemWithContextMenu — pointer containment", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /archive/i }));
|
||||
|
||||
expect(onArchiveTask).toHaveBeenCalledTimes(1);
|
||||
expect(onArchiveTask).toHaveBeenCalledWith("task-1");
|
||||
expect(onArchiveTask).not.toHaveBeenCalled();
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: /^Archive$/ }));
|
||||
await waitFor(() => {
|
||||
expect(onArchiveTask).toHaveBeenCalledTimes(1);
|
||||
expect(onArchiveTask).toHaveBeenCalledWith("task-1", { cascade: false });
|
||||
});
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { cloneElement, isValidElement, useRef, useState } from "react";
|
||||
import { cloneElement, isValidElement, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IconCopy,
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "./task-switcher-action-items";
|
||||
import type { StepDef, TaskSwitcherItem } from "./task-switcher-types";
|
||||
import { TaskPluginPrimaryMenuItems } from "./task-switcher-plugin-menu-items";
|
||||
import { useTaskSwitcherArchiveConfirmation } from "./task-switcher-archive-confirmation";
|
||||
export type { StepDef } from "./task-switcher-types";
|
||||
export { createTaskLinkSelectAction } from "./task-switcher-link-menu";
|
||||
|
||||
@@ -45,10 +46,10 @@ type ContextMenuProps = TaskLinkHandlers & {
|
||||
workflows?: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId?: Record<string, StepDef[]>;
|
||||
steps?: StepDef[];
|
||||
children: React.ReactElement<{ menuOpen?: boolean }>;
|
||||
children: React.ReactElement<{ menuOpen?: boolean; archiveConfirmation?: ReactNode }>;
|
||||
onEditTask?: (task: TaskSwitcherItem) => void;
|
||||
onRenameTask?: (taskId: string, currentTitle: string) => void;
|
||||
onArchiveTask?: (taskId: string) => void;
|
||||
onArchiveTask?: (taskId: string, opts?: { cascade?: boolean }) => void;
|
||||
onCreateSubtask?: (taskId: string, taskTitle: string) => void;
|
||||
onDeleteTask?: (taskId: string) => void;
|
||||
onDetachTask?: (taskId: string) => void;
|
||||
@@ -57,6 +58,7 @@ type ContextMenuProps = TaskLinkHandlers & {
|
||||
isPinned?: boolean;
|
||||
pinnedTaskIds?: string[];
|
||||
isDeleting?: boolean;
|
||||
isArchiving?: boolean;
|
||||
/** Active multi-selection; when this task is part of it, actions apply to the whole set. */
|
||||
selectedTaskIds?: Set<string>;
|
||||
onBulkArchive?: (taskIds: string[]) => void;
|
||||
@@ -154,37 +156,10 @@ function useMenuTouchDragCancel(onOpenChange: (open: boolean) => void) {
|
||||
};
|
||||
}
|
||||
|
||||
export function TaskItemWithContextMenu({
|
||||
task,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
steps,
|
||||
children,
|
||||
onEditTask,
|
||||
onRenameTask,
|
||||
onArchiveTask,
|
||||
onCreateSubtask,
|
||||
onDeleteTask,
|
||||
onDetachTask,
|
||||
onLinkPullRequest,
|
||||
onLinkIssue,
|
||||
onLinkMergeRequest,
|
||||
onLinkJiraTicket,
|
||||
onLinkLinearIssue,
|
||||
onLinkSentryIssue,
|
||||
onMoveToStep,
|
||||
onTogglePin,
|
||||
isPinned,
|
||||
pinnedTaskIds,
|
||||
isDeleting,
|
||||
selectedTaskIds,
|
||||
onBulkArchive,
|
||||
onBulkDelete,
|
||||
onBulkPin,
|
||||
onBulkMove,
|
||||
onClearSelection,
|
||||
isMixedWorkflowSelection,
|
||||
}: ContextMenuProps) {
|
||||
// This component coordinates the context menu and drag cancellation. Archive
|
||||
// state lives in its focused adapter so unavailable actions stay unavailable.
|
||||
export function TaskItemWithContextMenu(props: ContextMenuProps) {
|
||||
const { children, ...menuProps } = props;
|
||||
const [contextOpen, setContextOpen] = useState(false);
|
||||
const [menuKey, setMenuKey] = useState(0);
|
||||
const moveTasks = useTaskWorkflowMove();
|
||||
@@ -193,11 +168,23 @@ export function TaskItemWithContextMenu({
|
||||
setMenuKey((k) => k + 1);
|
||||
};
|
||||
const { handleOpenChange, triggerProps } = useMenuTouchDragCancel(setContextOpen);
|
||||
const archive = useTaskSwitcherArchiveConfirmation({
|
||||
task: menuProps.task,
|
||||
onArchiveTask: menuProps.onArchiveTask,
|
||||
isArchiving: menuProps.isArchiving,
|
||||
closeMenu,
|
||||
});
|
||||
|
||||
return (
|
||||
<ContextMenu key={menuKey} onOpenChange={handleOpenChange}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div {...triggerProps}>{cloneWithMenuOpen(children, contextOpen)}</div>
|
||||
<div ref={archive.archiveAnchorRef} tabIndex={-1} {...triggerProps}>
|
||||
{cloneWithMenuOpen(
|
||||
children,
|
||||
contextOpen,
|
||||
archive.archiveOpen ? archive.archiveConfirmation : undefined,
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent
|
||||
className="w-48"
|
||||
@@ -214,34 +201,8 @@ export function TaskItemWithContextMenu({
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<TaskContextMenuItems
|
||||
task={task}
|
||||
workflows={workflows}
|
||||
stepsByWorkflowId={stepsByWorkflowId}
|
||||
steps={steps}
|
||||
onEditTask={onEditTask}
|
||||
onRenameTask={onRenameTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onCreateSubtask={onCreateSubtask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onDetachTask={onDetachTask}
|
||||
onLinkPullRequest={onLinkPullRequest}
|
||||
onLinkIssue={onLinkIssue}
|
||||
onLinkMergeRequest={onLinkMergeRequest}
|
||||
onLinkJiraTicket={onLinkJiraTicket}
|
||||
onLinkLinearIssue={onLinkLinearIssue}
|
||||
onLinkSentryIssue={onLinkSentryIssue}
|
||||
onMoveToStep={onMoveToStep}
|
||||
onTogglePin={onTogglePin}
|
||||
isPinned={isPinned}
|
||||
pinnedTaskIds={pinnedTaskIds}
|
||||
isDeleting={isDeleting}
|
||||
selectedTaskIds={selectedTaskIds}
|
||||
onBulkArchive={onBulkArchive}
|
||||
onBulkDelete={onBulkDelete}
|
||||
onBulkPin={onBulkPin}
|
||||
onBulkMove={onBulkMove}
|
||||
onClearSelection={onClearSelection}
|
||||
isMixedWorkflowSelection={isMixedWorkflowSelection}
|
||||
{...menuProps}
|
||||
onArchiveTask={archive.requestArchive}
|
||||
closeMenu={closeMenu}
|
||||
moveTasks={moveTasks}
|
||||
/>
|
||||
@@ -293,6 +254,7 @@ function SingleSelectionMenuItems({
|
||||
onTogglePin,
|
||||
isPinned,
|
||||
isDeleting,
|
||||
isArchiving,
|
||||
onBulkArchive,
|
||||
onBulkMove,
|
||||
onClearSelection,
|
||||
@@ -329,7 +291,7 @@ function SingleSelectionMenuItems({
|
||||
taskId={task.id}
|
||||
actingIds={actingIds}
|
||||
actingOnSelection={actingOnSelection}
|
||||
disabled={isDeleting}
|
||||
disabled={isDeleting || isArchiving}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onBulkArchive={onBulkArchive}
|
||||
/>
|
||||
@@ -459,10 +421,11 @@ function BulkSelectionMenuItems({
|
||||
}
|
||||
|
||||
function cloneWithMenuOpen(
|
||||
children: React.ReactElement<{ menuOpen?: boolean }>,
|
||||
children: React.ReactElement<{ menuOpen?: boolean; archiveConfirmation?: ReactNode }>,
|
||||
menuOpen: boolean,
|
||||
archiveConfirmation?: ReactNode,
|
||||
): React.ReactNode {
|
||||
if (isValidElement(children)) return cloneElement(children, { menuOpen });
|
||||
if (isValidElement(children)) return cloneElement(children, { menuOpen, archiveConfirmation });
|
||||
return children;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { StepDef, TaskLinkHandler, TaskSwitcherItem } from "./task-switcher-types";
|
||||
import { TaskItem } from "./task-item";
|
||||
import { TaskItemWithContextMenu } from "./task-switcher-context-menu";
|
||||
@@ -27,7 +28,7 @@ export type TaskRowProps = {
|
||||
onSelectTask: (taskId: string) => void;
|
||||
onEditTask?: (task: TaskSwitcherItem) => void;
|
||||
onRenameTask?: (taskId: string, currentTitle: string) => void;
|
||||
onArchiveTask?: (taskId: string) => void;
|
||||
onArchiveTask?: (taskId: string, opts?: { cascade?: boolean }) => void;
|
||||
onCreateSubtask?: (taskId: string, taskTitle: string) => void;
|
||||
onDeleteTask?: (taskId: string) => void;
|
||||
onDetachTask?: (taskId: string) => void;
|
||||
@@ -42,6 +43,8 @@ export type TaskRowProps = {
|
||||
isPinned?: boolean;
|
||||
pinnedTaskIds?: string[];
|
||||
deletingTaskId?: string | null;
|
||||
archivingTaskId?: string | null;
|
||||
isArchiving?: boolean;
|
||||
selectedTaskIds?: Set<string>;
|
||||
onToggleSelectTask?: (taskId: string) => void;
|
||||
onSelectTaskRange?: (taskId: string) => void;
|
||||
@@ -76,6 +79,7 @@ function getContextMenuProps(props: TaskRowProps, isArchived: boolean) {
|
||||
isPinned: isArchived ? false : props.isPinned,
|
||||
pinnedTaskIds: props.pinnedTaskIds,
|
||||
isDeleting: props.deletingTaskId === props.task.id,
|
||||
isArchiving: props.isArchiving && props.archivingTaskId === props.task.id,
|
||||
selectedTaskIds: archiveAware(props.selectedTaskIds, isArchived),
|
||||
onBulkArchive: archiveAware(props.onBulkArchive, isArchived),
|
||||
onBulkDelete: props.onBulkDelete,
|
||||
@@ -103,7 +107,7 @@ type TaskRowItemProps = Pick<
|
||||
| "deletingTaskId"
|
||||
| "isPinned"
|
||||
| "stepsByWorkflowId"
|
||||
> & { isArchived: boolean };
|
||||
> & { isArchived: boolean; archiveConfirmation?: ReactNode };
|
||||
|
||||
function TaskRowItem({
|
||||
task,
|
||||
@@ -122,6 +126,7 @@ function TaskRowItem({
|
||||
isPinned,
|
||||
stepsByWorkflowId,
|
||||
isArchived,
|
||||
archiveConfirmation,
|
||||
}: TaskRowItemProps) {
|
||||
const taskSteps = task.workflowId ? stepsByWorkflowId?.[task.workflowId] : undefined;
|
||||
const isSelected = task.id === selectedTaskId || task.id === activeTaskId;
|
||||
@@ -181,6 +186,7 @@ function TaskRowItem({
|
||||
onClick={() => onSelectTask(task.id)}
|
||||
isDeleting={isDeleting}
|
||||
isPinned={isArchived ? false : isPinned}
|
||||
archiveConfirmation={archiveConfirmation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export type TaskSwitcherProps = {
|
||||
onSelectTask: (taskId: string) => void;
|
||||
onEditTask?: (task: TaskSwitcherItem) => void;
|
||||
onRenameTask?: (taskId: string, currentTitle: string) => void;
|
||||
onArchiveTask?: (taskId: string) => void;
|
||||
onArchiveTask?: (taskId: string, opts?: { cascade?: boolean }) => void;
|
||||
onCreateSubtask?: (taskId: string, taskTitle: string) => void;
|
||||
onDeleteTask?: (taskId: string) => void;
|
||||
onDetachTask?: (taskId: string) => void;
|
||||
@@ -88,6 +88,8 @@ export type TaskSwitcherProps = {
|
||||
onNestTask?: (taskId: string, parentTaskId: string) => void;
|
||||
pinnedTaskIds?: string[];
|
||||
deletingTaskId?: string | null;
|
||||
archivingTaskId?: string | null;
|
||||
isArchiving?: boolean;
|
||||
isLoading?: boolean;
|
||||
loadError?: string | null;
|
||||
onRetryLoad?: () => void;
|
||||
|
||||
@@ -48,6 +48,8 @@ function buildTaskRowProps(props: TaskSwitcherProps): TaskRowBaseProps {
|
||||
onTogglePin: props.onTogglePin,
|
||||
pinnedTaskIds: props.pinnedTaskIds,
|
||||
deletingTaskId: props.deletingTaskId,
|
||||
archivingTaskId: props.archivingTaskId,
|
||||
isArchiving: props.isArchiving,
|
||||
selectedTaskIds: props.selectedTaskIds,
|
||||
onToggleSelectTask: props.onToggleSelectTask,
|
||||
onSelectTaskRange: props.onSelectTaskRange,
|
||||
|
||||
@@ -685,7 +685,7 @@ export class SessionPage {
|
||||
/**
|
||||
* Archive a task via the sidebar context menu.
|
||||
* Hovers to reveal the menu trigger, opens it, clicks "Archive",
|
||||
* and confirms the archive dialog.
|
||||
* and confirms the local archive surface or cascade dialog.
|
||||
*/
|
||||
async archiveTaskInSidebar(title: string, options: { cascade?: boolean } = {}): Promise<void> {
|
||||
await this.openSidebarMenuAndClick(title, "Archive");
|
||||
@@ -693,11 +693,7 @@ export class SessionPage {
|
||||
const cascadeCheckbox = this.page.getByTestId("archive-cascade-checkbox");
|
||||
await cascadeCheckbox.click();
|
||||
}
|
||||
// Confirm the archive dialog
|
||||
const confirmButton = this.page
|
||||
.getByRole("alertdialog")
|
||||
.getByRole("button", { name: "Archive" });
|
||||
await confirmButton.click();
|
||||
await this.page.getByTestId("archive-task-confirm").click();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -454,17 +454,23 @@ test.describe("Chat status bar", () => {
|
||||
|
||||
const urlBeforeArchive = testPage.url();
|
||||
|
||||
// Cancelling the confirmation dialog must not archive anything
|
||||
// A simple task stays on the banner's local confirmation surface. Cancelling
|
||||
// it must not archive anything.
|
||||
await session.prMergedArchiveButton().click();
|
||||
await expect(session.prMergedArchiveConfirmButton()).toBeVisible({ timeout: 10_000 });
|
||||
await testPage.getByRole("alertdialog").getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(session.prMergedArchiveConfirmButton()).toBeEnabled({ timeout: 10_000 });
|
||||
const localConfirmation = testPage.getByTestId("task-archive-confirm-popover");
|
||||
await expect(localConfirmation).toBeVisible();
|
||||
await expect(testPage.getByRole("alertdialog")).not.toBeVisible();
|
||||
await localConfirmation.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(session.prMergedArchiveConfirmButton()).not.toBeVisible();
|
||||
await expect(session.prMergedBanner()).toBeVisible();
|
||||
expect(testPage.url()).toBe(urlBeforeArchive);
|
||||
|
||||
// Click archive in the PR merged banner, then confirm in the dialog
|
||||
// Click archive in the PR merged banner, then confirm locally.
|
||||
await session.prMergedArchiveButton().click();
|
||||
await expect(session.prMergedArchiveConfirmButton()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(session.prMergedArchiveConfirmButton()).toBeEnabled({ timeout: 10_000 });
|
||||
await session.prMergedArchiveConfirmButton().click();
|
||||
|
||||
// Should switch to task B
|
||||
|
||||
@@ -3,7 +3,7 @@ import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
const TASK_VISIBLE_TIMEOUT = 10_000;
|
||||
|
||||
// Regression: clicking Delete / Archive must show the confirm dialog, not navigate to the task.
|
||||
// Regression: clicking Delete / Archive must show the local confirmation, not navigate to the task.
|
||||
test.describe("Kanban card actions menu — delete/archive does not navigate", () => {
|
||||
test("clicking Delete in card dropdown shows confirm dialog and does not navigate", async ({
|
||||
testPage,
|
||||
@@ -67,22 +67,10 @@ test.describe("Kanban card actions menu — delete/archive does not navigate", (
|
||||
await expect(archiveItem).toBeVisible();
|
||||
await archiveItem.click();
|
||||
|
||||
const dialog = testPage.getByRole("alertdialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText("Card Menu Archive Task");
|
||||
const dialogBox = await dialog.boundingBox();
|
||||
if (!dialogBox) throw new Error("archive confirmation dialog has no layout box");
|
||||
expect(dialogBox.width).toBeGreaterThanOrEqual(480);
|
||||
await expect(dialog).toHaveClass(/font-sans/);
|
||||
const archiveHeader = dialog.locator('[data-slot="alert-dialog-header"]');
|
||||
await expect
|
||||
.poll(async () =>
|
||||
archiveHeader.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return { justifyItems: style.justifyItems, textAlign: style.textAlign };
|
||||
}),
|
||||
)
|
||||
.toEqual({ justifyItems: "start", textAlign: "left" });
|
||||
const confirmation = testPage.getByTestId("task-archive-confirm-popover");
|
||||
await expect(confirmation).toBeVisible();
|
||||
await expect(confirmation).toContainText("Card Menu Archive Task");
|
||||
await expect(confirmation.getByTestId("archive-task-confirm")).toBeVisible();
|
||||
|
||||
expect(testPage.url()).toBe(startUrl);
|
||||
});
|
||||
@@ -175,9 +163,9 @@ test.describe("Kanban card actions menu — delete/archive in All Workflows view
|
||||
await kanban.openTaskActionsMenu(task.id);
|
||||
await testPage.getByRole("menuitem", { name: "Archive" }).click();
|
||||
|
||||
const dialog = testPage.getByRole("alertdialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Archive" }).click();
|
||||
const confirmation = testPage.getByTestId("task-archive-confirm-popover");
|
||||
await expect(confirmation).toBeVisible();
|
||||
await confirmation.getByTestId("archive-task-confirm").click();
|
||||
|
||||
await expect(kanban.taskCardByTitle("All-Wf Archive Task")).not.toBeVisible({
|
||||
timeout: TASK_VISIBLE_TIMEOUT,
|
||||
|
||||
@@ -25,8 +25,8 @@ test.describe("Kanban card archive — cascade subtasks toggle", () => {
|
||||
await kanban.openTaskActionsMenu(lonely.id);
|
||||
await testPage.getByRole("menuitem", { name: "Archive" }).click();
|
||||
|
||||
const dialog = testPage.getByRole("alertdialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
const confirmation = testPage.getByTestId("task-archive-confirm-popover");
|
||||
await expect(confirmation).toBeVisible();
|
||||
// No subtasks → no checkbox.
|
||||
await expect(testPage.getByTestId("archive-cascade-checkbox")).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -159,12 +159,12 @@ test.describe("Mobile sidebar task actions", () => {
|
||||
expect(cardBox.y).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("keeps the archive confirmation readable on a phone", async ({
|
||||
test("keeps the inline archive confirmation readable on a phone", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const task = await apiClient.seedTask(seedData.workspaceId, "Mobile archive dialog target", {
|
||||
const task = await apiClient.seedTask(seedData.workspaceId, "Mobile archive inline target", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
@@ -177,62 +177,31 @@ test.describe("Mobile sidebar task actions", () => {
|
||||
const drawer = testPage.getByRole("dialog", { name: "Tasks" });
|
||||
const taskRow = drawer
|
||||
.getByTestId("sidebar-task-item")
|
||||
.filter({ hasText: "Mobile archive dialog target" });
|
||||
.filter({ hasText: "Mobile archive inline target" });
|
||||
await taskRow.getByRole("button", { name: "Task actions" }).tap();
|
||||
await testPage.getByRole("menuitem", { name: "Archive", exact: true }).tap();
|
||||
|
||||
const dialog = testPage.getByRole("alertdialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toHaveClass(/font-sans/);
|
||||
const header = dialog.locator('[data-slot="alert-dialog-header"]');
|
||||
await expect
|
||||
.poll(async () =>
|
||||
header.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return { justifyItems: style.justifyItems, textAlign: style.textAlign };
|
||||
}),
|
||||
)
|
||||
.toEqual({ justifyItems: "start", textAlign: "left" });
|
||||
await expect(dialog.locator('[data-slot="alert-dialog-description"]')).toHaveClass(/text-sm/);
|
||||
await dialog.evaluate(async (element) => {
|
||||
await Promise.all(
|
||||
element
|
||||
.getAnimations({ subtree: true })
|
||||
.map((animation) => animation.finished.catch(() => undefined)),
|
||||
);
|
||||
});
|
||||
const confirmation = taskRow.getByTestId("task-archive-inline-confirmation");
|
||||
await expect(confirmation).toBeVisible();
|
||||
await expect(testPage.getByRole("alertdialog")).toHaveCount(0);
|
||||
await expect(confirmation).toContainText("Mobile archive inline target");
|
||||
|
||||
const [dialogBox, viewport] = await Promise.all([
|
||||
dialog.boundingBox(),
|
||||
testPage.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })),
|
||||
]);
|
||||
if (!dialogBox) throw new Error("archive confirmation dialog has no layout box");
|
||||
expect(dialogBox.width).toBeGreaterThanOrEqual(viewport.width - 40);
|
||||
expect(dialogBox.x).toBeGreaterThanOrEqual(8);
|
||||
expect(dialogBox.x + dialogBox.width).toBeLessThanOrEqual(viewport.width - 8);
|
||||
expect(dialogBox.y).toBeGreaterThanOrEqual(8);
|
||||
expect(dialogBox.y + dialogBox.height).toBeLessThanOrEqual(viewport.height - 8);
|
||||
for (const button of [
|
||||
confirmation.getByRole("button", { name: "Cancel" }),
|
||||
confirmation.getByTestId("archive-task-confirm"),
|
||||
]) {
|
||||
const box = await button.boundingBox();
|
||||
if (!box) throw new Error("inline archive action has no layout box");
|
||||
expect(box.width).toBeGreaterThanOrEqual(44);
|
||||
expect(box.height).toBeGreaterThanOrEqual(44);
|
||||
await expect(button).toBeInViewport();
|
||||
}
|
||||
|
||||
const typography = await dialog.evaluate((element) => {
|
||||
const description = element.querySelector('[data-slot="alert-dialog-description"]');
|
||||
const cancel = element.querySelector('[data-slot="alert-dialog-cancel"]');
|
||||
const action = element.querySelector('[data-slot="alert-dialog-action"]');
|
||||
return {
|
||||
dialogFontFamily: getComputedStyle(element).fontFamily,
|
||||
descriptionFontFamily: description ? getComputedStyle(description).fontFamily : "",
|
||||
descriptionFontSize: description ? getComputedStyle(description).fontSize : "",
|
||||
cancelFontFamily: cancel ? getComputedStyle(cancel).fontFamily : "",
|
||||
cancelFontSize: cancel ? getComputedStyle(cancel).fontSize : "",
|
||||
actionFontFamily: action ? getComputedStyle(action).fontFamily : "",
|
||||
actionFontSize: action ? getComputedStyle(action).fontSize : "",
|
||||
};
|
||||
});
|
||||
expect(typography.dialogFontFamily).toContain("Figtree");
|
||||
expect(typography.descriptionFontFamily).toBe(typography.dialogFontFamily);
|
||||
expect(typography.cancelFontFamily).toBe(typography.dialogFontFamily);
|
||||
expect(typography.descriptionFontSize).toBe(typography.cancelFontSize);
|
||||
expect(typography.actionFontFamily).toBe(typography.dialogFontFamily);
|
||||
expect(typography.actionFontSize).toBe(typography.descriptionFontSize);
|
||||
const pageWidth = await testPage.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
client: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(pageWidth.scroll).toBeLessThanOrEqual(pageWidth.client);
|
||||
});
|
||||
|
||||
test("keeps the tablet task switcher as a left-side sheet", async ({
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getSubtaskCount } from "@/lib/api";
|
||||
|
||||
export type SubtaskCountStatus = "idle" | "loading" | "resolved" | "error";
|
||||
|
||||
export type SubtaskCountResult = {
|
||||
status: SubtaskCountStatus;
|
||||
total: number;
|
||||
};
|
||||
|
||||
// useSubtaskCount fetches the subtask count for an archive / delete
|
||||
// confirmation dialog when it opens. Returns 0 while the request for
|
||||
// the current set of ids is still in flight (or fails, or no ids were
|
||||
// supplied) — in all of those cases the dialog's cascade checkbox
|
||||
// stays hidden.
|
||||
// the current set of ids is still in flight or fails — callers that need
|
||||
// to distinguish those safe states use useSubtaskCountState below.
|
||||
//
|
||||
// The stored count is keyed by a stable string of the requested ids
|
||||
// AND only returned while the dialog reports `open=true`. Closing the
|
||||
@@ -14,10 +20,15 @@ import { getSubtaskCount } from "@/lib/api";
|
||||
// a previous opening flashing before the new request resolves and
|
||||
// stops the bulk toolbar's per-render `[...selectedIds]` from fanning
|
||||
// out a new Promise.all on every render.
|
||||
export function useSubtaskCount(open: boolean, taskId?: string, taskIds?: string[]): number {
|
||||
export function useSubtaskCountState(
|
||||
open: boolean,
|
||||
taskId?: string,
|
||||
taskIds?: string[],
|
||||
): SubtaskCountResult {
|
||||
const idsKey = taskIds?.join(",") ?? taskId ?? "";
|
||||
const [result, setResult] = useState<{ key: string; total: number }>({
|
||||
const [result, setResult] = useState<{ key: string } & SubtaskCountResult>({
|
||||
key: "",
|
||||
status: "idle",
|
||||
total: 0,
|
||||
});
|
||||
useEffect(() => {
|
||||
@@ -25,25 +36,39 @@ export function useSubtaskCount(open: boolean, taskId?: string, taskIds?: string
|
||||
// Clear so the next open starts from a known-empty state —
|
||||
// this is what gates stale counts from leaking across separate
|
||||
// dialog openings for the same id.
|
||||
setResult({ key: "", total: 0 });
|
||||
setResult({ key: "", status: "idle", total: 0 });
|
||||
return;
|
||||
}
|
||||
if (!idsKey) {
|
||||
setResult({ key: idsKey, status: "error", total: 0 });
|
||||
return;
|
||||
}
|
||||
if (!idsKey) return;
|
||||
const ids = taskIds ?? (taskId ? [taskId] : []);
|
||||
let cancelled = false;
|
||||
// Per-id .catch already maps every failure to { count: 0 }, so
|
||||
// Promise.all never rejects — no outer .catch needed.
|
||||
Promise.all(ids.map((id) => getSubtaskCount(id).catch(() => ({ count: 0 })))).then(
|
||||
(results) => {
|
||||
setResult({ key: idsKey, status: "loading", total: 0 });
|
||||
Promise.all(ids.map((id) => getSubtaskCount(id)))
|
||||
.then((results) => {
|
||||
if (cancelled) return;
|
||||
setResult({ key: idsKey, total: results.reduce((sum, r) => sum + r.count, 0) });
|
||||
},
|
||||
);
|
||||
setResult({
|
||||
key: idsKey,
|
||||
status: "resolved",
|
||||
total: results.reduce((sum, r) => sum + r.count, 0),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setResult({ key: idsKey, status: "error", total: 0 });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// taskId / taskIds intentionally excluded — idsKey is their stable summary.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, idsKey]);
|
||||
return open && result.key === idsKey ? result.total : 0;
|
||||
if (!open || result.key !== idsKey) return { status: "idle", total: 0 };
|
||||
return { status: result.status, total: result.total };
|
||||
}
|
||||
|
||||
export function useSubtaskCount(open: boolean, taskId?: string, taskIds?: string[]): number {
|
||||
const result = useSubtaskCountState(open, taskId, taskIds);
|
||||
return result.status === "resolved" ? result.total : 0;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ describe("useTaskArchiveConfirm", () => {
|
||||
act(() => result.current.requestArchive());
|
||||
|
||||
expect(result.current.target).toEqual({
|
||||
id: "task-A",
|
||||
title: "Ship the palette",
|
||||
executorType: "docker",
|
||||
});
|
||||
@@ -74,6 +75,25 @@ describe("useTaskArchiveConfirm", () => {
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
|
||||
it("archives the requested task when the active task changes before confirmation", async () => {
|
||||
mocks.state.kanban.tasks = [
|
||||
{ id: "task-A", title: "Task A", primaryExecutorType: "docker" },
|
||||
{ id: "task-B", title: "Task B", primaryExecutorType: "local" },
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
({ taskId }: { taskId: string }) => useTaskArchiveConfirm(taskId),
|
||||
{ initialProps: { taskId: "task-A" } },
|
||||
);
|
||||
|
||||
act(() => result.current.requestArchive());
|
||||
rerender({ taskId: "task-B" });
|
||||
await act(async () => {
|
||||
await result.current.confirmArchive({ cascade: false });
|
||||
});
|
||||
|
||||
expect(mocks.archiveAndSwitch).toHaveBeenCalledWith("task-A", { cascade: false });
|
||||
});
|
||||
|
||||
it("toasts, logs the cause, and clears pending when archiving fails", async () => {
|
||||
const cause = new Error("boom");
|
||||
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
@@ -5,7 +5,11 @@ import { useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
|
||||
|
||||
export type ArchiveConfirmTarget = { title: string; executorType?: string | null };
|
||||
export type ArchiveConfirmTarget = {
|
||||
id: string;
|
||||
title: string;
|
||||
executorType?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Archive flow shared by every task-scoped archive entry point (the terminal-state
|
||||
@@ -28,6 +32,7 @@ export function useTaskArchiveConfirm(taskId: string | null) {
|
||||
const state = store.getState();
|
||||
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
|
||||
setTarget({
|
||||
id: taskId,
|
||||
title: task?.title ?? t("task:thisTask"),
|
||||
executorType: task?.primaryExecutorType,
|
||||
});
|
||||
@@ -37,10 +42,11 @@ export function useTaskArchiveConfirm(taskId: string | null) {
|
||||
|
||||
const confirmArchive = useCallback(
|
||||
async ({ cascade }: { cascade: boolean }) => {
|
||||
if (!taskId) return;
|
||||
const requestedTaskId = target?.id ?? taskId;
|
||||
if (!requestedTaskId) return;
|
||||
setIsPending(true);
|
||||
try {
|
||||
await archiveAndSwitch(taskId, { cascade });
|
||||
await archiveAndSwitch(requestedTaskId, { cascade });
|
||||
} catch (error) {
|
||||
// The toast is deliberately generic; keep the rejection reason in the
|
||||
// console so a failed archive is diagnosable from a user's devtools.
|
||||
@@ -50,7 +56,7 @@ export function useTaskArchiveConfirm(taskId: string | null) {
|
||||
setIsPending(false);
|
||||
}
|
||||
},
|
||||
[archiveAndSwitch, t, taskId, toast],
|
||||
[archiveAndSwitch, t, target?.id, taskId, toast],
|
||||
);
|
||||
|
||||
return { target, requestArchive, closeConfirm, confirmArchive, isPending };
|
||||
|
||||
@@ -23,6 +23,12 @@ export type CommandItem = {
|
||||
enterMode?: CommandPanelMode;
|
||||
/** Standard action — close panel and execute */
|
||||
action?: () => void;
|
||||
/** Keep the palette mounted while an action-owned confirmation is shown. */
|
||||
keepOpen?: boolean;
|
||||
/** Confirmation content rendered inside the open palette. */
|
||||
confirmation?: ReactNode;
|
||||
/** Clears action-owned confirmation state when the palette itself closes. */
|
||||
onConfirmationDismiss?: () => void;
|
||||
/** For 'input' mode: placeholder text for the input field */
|
||||
inputPlaceholder?: string;
|
||||
/** For 'input' mode: called with the input value when Enter is pressed */
|
||||
|
||||
Reference in New Issue
Block a user