feat(ui): add task archive confirmations (#2945)

This commit is contained in:
José Almeida
2026-08-23 14:56:11 +00:00
committed by GitHub
parent 7b4574bb13
commit 61faeb5b0b
38 changed files with 1306 additions and 411 deletions
+12 -5
View File
@@ -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}
+13 -5
View File
@@ -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);
});
});
+18 -3
View File
@@ -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
+1 -1
View File
@@ -454,7 +454,7 @@ function useCommandPanelHandlers({
return;
}
if (cmd.action) {
setOpen(false);
if (!cmd.keepOpen) setOpen(false);
cmd.action();
}
},
+24 -17
View File
@@ -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 });
+36 -10
View File
@@ -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}
/>
);
}
+89 -98
View File
@@ -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,
+2 -6
View File
@@ -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 ({
+40 -15
View File
@@ -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(() => {});
+10 -4
View File
@@ -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 };
+6
View File
@@ -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 */