diff --git a/apps/web/app/tasks/columns.tsx b/apps/web/app/tasks/columns.tsx index da2e8aee64..e26eedcfe2 100644 --- a/apps/web/app/tasks/columns.tsx +++ b/apps/web/app/tasks/columns.tsx @@ -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; ctx: ActionsC const isArchived = !!task.archived_at; const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showArchiveConfirm, setShowArchiveConfirm] = useState(false); + const archiveAnchorRef = useRef(null); + const { isFinePointer } = useResponsiveBreakpoint(); return ( -
- {!isArchived && ( +
+ {!isArchived && (!showArchiveConfirm || isFinePointer) && (
), - CommandDialog: ({ children, open }: { children: ReactNode; open: boolean }) => - open ?
{children}
: null, + CommandDialog: ({ + children, + open, + onOpenChange, + }: { + children: ReactNode; + open: boolean; + onOpenChange: (open: boolean) => void; + }) => + open ? ( +
+ + {children} +
+ ) : null, CommandEmpty: ({ children }: { children: ReactNode }) =>
{children}
, 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 { 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:
Confirm removal
, + }; + const regularCommand = { + id: "regular-command", + label: "Keep working", + group: ACTIONS_GROUP, + }; + + render( + , + ); + + 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:
Confirm removal
, + onConfirmationDismiss, + }; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss test dialog" })); + + expect(onConfirmationDismiss).toHaveBeenCalledOnce(); + expect(setOpen).toHaveBeenCalledWith(false); + }); +}); diff --git a/apps/web/components/command-panel-footer.tsx b/apps/web/components/command-panel-footer.tsx index a2d2ab14d2..1b39bd69b8 100644 --- a/apps/web/components/command-panel-footer.tsx +++ b/apps/web/components/command-panel-footer.tsx @@ -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 ( + {confirmationCommand && } {mode === MODE_COMMANDS && ( { + if (!nextOpen) dismissCommandConfirmation(props.commands); + setOpen(nextOpen); + }; + return ( 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 })} /> - onArchive?.(task, { cascade })} - /> & { @@ -549,6 +546,17 @@ function KanbanCardFrame({ onOpenChange={menu.setShowDetachConfirm} onConfirm={menu.handleDetachConfirm} /> + 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} /> ); diff --git a/apps/web/components/kanban/graph2-task-pipeline.tsx b/apps/web/components/kanban/graph2-task-pipeline.tsx index 9032561801..bfeb59e64e 100644 --- a/apps/web/components/kanban/graph2-task-pipeline.tsx +++ b/apps/web/components/kanban/graph2-task-pipeline.tsx @@ -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(null); return ( - <> +
); } diff --git a/apps/web/components/session-commands.test.tsx b/apps/web/components/session-commands.test.tsx index 45512e6328..7834e5d032 100644 --- a/apps/web/components/session-commands.test.tsx +++ b/apps/web/components/session-commands.test.tsx @@ -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[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 }); diff --git a/apps/web/components/session-commands.tsx b/apps/web/components/session-commands.tsx index 5c6e8c34b1..2ba6316d22 100644 --- a/apps/web/components/session-commands.tsx +++ b/apps/web/components/session-commands.tsx @@ -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: , 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; - archive: ReturnType; }; /** 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} /> - ); } +function useArchiveCommandConfirmation(archive: ReturnType) { + const archiveAnchorRef = useRef(null); + if (archive.target === null) return undefined; + + return ( + { + 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} /> ); } diff --git a/apps/web/components/task/chat/pr-archive-banners.test.tsx b/apps/web/components/task/chat/pr-archive-banners.test.tsx index 941aed8aa4..0eea4c5b7e 100644 --- a/apps/web/components/task/chat/pr-archive-banners.test.tsx +++ b/apps/web/components/task/chat/pr-archive-banners.test.tsx @@ -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(testId); + await waitFor(() => expect(screen.getByTestId(testId).disabled).toBe(false)); + return screen.getByTestId(testId); +} + beforeEach(() => { archiveAndSwitchMock.mockResolvedValue(undefined); mockGetSubtaskCount.mockResolvedValue({ count: 0 }); @@ -81,7 +87,7 @@ describe("PRMergedBanner", () => { render(); 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(); 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(); 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(); 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 }), diff --git a/apps/web/components/task/chat/pr-archive-banners.tsx b/apps/web/components/task/chat/pr-archive-banners.tsx index a91129c42c..31a88b737e 100644 --- a/apps/web/components/task/chat/pr-archive-banners.tsx +++ b/apps/web/components/task/chat/pr-archive-banners.tsx @@ -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(null); + const { isFinePointer } = useResponsiveBreakpoint(); return ( <> -
+
{icon} {text} - + {(!archive.target || isFinePointer) && ( + + )} + { + if (!open) archive.closeConfirm(); + }} + onConfirm={archive.confirmArchive} + confirmTestId={`${testIdPrefix}-archive-confirm`} + />
- ); } diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.ts index ce0f509f08..52628f4314 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.ts @@ -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[0]; type SheetCtx = Parameters[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((resolve) => { + resolveArchive = resolve; + }), + ); + const store = { + getState: () => ({}), + } as Parameters[0]; + const { result } = renderHook(() => + useSheetArchiveActions( + store, + archiveAndSwitch as Parameters[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); + }); +}); diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts index f5d7bc935f..5f353010a8 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts @@ -403,16 +403,80 @@ function useSheetNestTask() { return useNestTaskByDrag(); } +export function useSheetArchiveActions( + store: ReturnType, + archiveAndSwitch: ReturnType, +) { + 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(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, }; diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-props.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-props.ts index 50e598b461..8a8be7fbfa 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-props.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-props.ts @@ -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, diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index 592fa402cb..456a0bfa75 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -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; onDetachTask: (taskId: string) => Promise | 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} diff --git a/apps/web/components/task/task-archive-confirm-dialog.test.tsx b/apps/web/components/task/task-archive-confirm-dialog.test.tsx index 30ca6cc1f9..2241a07e57 100644 --- a/apps/web/components/task/task-archive-confirm-dialog.test.tsx +++ b/apps/web/components/task/task-archive-confirm-dialog.test.tsx @@ -126,6 +126,50 @@ describe("TaskArchiveConfirmDialog preference", () => { }); }); +describe("TaskArchiveConfirmDialog classification safety", () => { + it("disables archive while descendant classification is pending", () => { + const onConfirm = vi.fn(); + + renderDialog( + {}} + 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( + {}} + 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( diff --git a/apps/web/components/task/task-archive-confirm-dialog.tsx b/apps/web/components/task/task-archive-confirm-dialog.tsx index 3eae2d81a9..56ac43feda 100644 --- a/apps/web/components/task/task-archive-confirm-dialog.tsx +++ b/apps/web/components/task/task-archive-confirm-dialog.tsx @@ -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; 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")} { - if (isArchiving) return; + if (archiveDisabled) return; onConfirm({ cascade }); handleOpenChange(false); }} diff --git a/apps/web/components/task/task-archive-confirmation.test.tsx b/apps/web/components/task/task-archive-confirmation.test.tsx new file mode 100644 index 0000000000..f826873df6 --- /dev/null +++ b/apps/web/components/task/task-archive-confirmation.test.tsx @@ -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(null); + return ( + <> + + + + ); +} + +function renderConfirmation(onConfirm = vi.fn(), onOpenChange = vi.fn()) { + return render( + + + , + ); +} + +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); + }); +}); diff --git a/apps/web/components/task/task-archive-confirmation.tsx b/apps/web/components/task/task-archive-confirmation.tsx new file mode 100644 index 0000000000..5be1c9b692 --- /dev/null +++ b/apps/web/components/task/task-archive-confirmation.tsx @@ -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; + anchorRef: RefObject; + focusReturnRef?: RefObject; + focusBoundaryRef?: RefObject; + onConfirm: (values: ArchiveConfirmValues) => void | Promise; + 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 ( + + {t("task:archiveTaskConfirm", { taskTitle })} + {cleanupLines.map((line) => ( + + {line} + + ))} + {taskIsInFlight && } + + ); +} + +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; + confirmTestId: string; +}) { + const { t } = useTranslation(); + const description = ( + + ); + return ( + + ); +} + +function ArchiveConfirmPopover({ + open, + anchorRef, + focusReturnRef, + focusBoundaryRef, + taskTitle, + executorType, + isInFlight, + isArchiving, + onOpenChange, + onConfirm, + confirmTestId, +}: { + open: boolean; + anchorRef: RefObject; + focusReturnRef?: RefObject; + focusBoundaryRef?: RefObject; + taskTitle?: string; + executorType?: string | null; + isInFlight: boolean; + isArchiving?: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void | Promise; + confirmTestId: string; +}) { + const { t } = useTranslation(); + return ( + + } + 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; + focusReturnRef?: RefObject; + focusBoundaryRef?: RefObject; + onOpenChange: (open: boolean) => void; + confirmTestId: string; +}) { + const { t } = useTranslation(); + return ( + + + {t("common:loading")} + + } + 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 ; +} + +type ArchiveConfirmationContentProps = ArchiveDialogProps & { + confirmTaskArchive: boolean; + isFinePointer: boolean; + taskIsInFlight: boolean; + anchorRef: RefObject; + focusReturnRef?: RefObject; + focusBoundaryRef?: RefObject; + 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 ; + } + + if (subtaskClassification.status !== "resolved") { + if (inline) { + return ( + + {t("common:loading")} + + ); + } + if (!isFinePointer) return null; + return ( + + ); + } + + const confirm = () => dialogProps.onConfirm({ cascade: false }); + if (inline || !isFinePointer) { + return ( + dialogProps.onOpenChange(false)} + onClose={() => dialogProps.onOpenChange(false)} + onConfirm={confirm} + confirmTestId={dialogProps.confirmTestId ?? DEFAULT_CONFIRM_TEST_ID} + /> + ); + } + + return ( + + ); +} + +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 ( + + ); +} diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 589f6257fa..2e9d568fd3 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -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 ( +
{archiveConfirmation}
+ ); + } + if (hasDiffStats) { + return ( +
+ +
+ +
+
+ ); + } + return ; +} + +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 (
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", + )} > - {hasDiffStats ? ( -
- -
- -
-
- ) : ( - - )} - {!!subtaskCount && subtaskCount > 0 && !!onToggleSubtasks && ( + + {!!props.subtaskCount && props.subtaskCount > 0 && !!props.onToggleSubtasks && ( )}
diff --git a/apps/web/components/task/task-session-sidebar-switcher-props.ts b/apps/web/components/task/task-session-sidebar-switcher-props.ts index 4e5c31e2af..9533c8a471 100644 --- a/apps/web/components/task/task-session-sidebar-switcher-props.ts +++ b/apps/web/components/task/task-session-sidebar-switcher-props.ts @@ -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, diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 4c0373f698..7098e73011 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -199,23 +199,8 @@ function useArchiveActions(store: StoreApi) { const [archivingTaskId, setArchivingTaskId] = useState(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 { diff --git a/apps/web/components/task/task-switcher-archive-confirmation.tsx b/apps/web/components/task/task-switcher-archive-confirmation.tsx new file mode 100644 index 0000000000..928e3e1596 --- /dev/null +++ b/apps/web/components/task/task-switcher-archive-confirmation.tsx @@ -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(null); + const openTimerRef = useRef(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 ? ( + onArchiveTask(task.id, { cascade })} + /> + ) : undefined; + + return { archiveOpen, archiveAnchorRef, requestArchive, archiveConfirmation }; +} diff --git a/apps/web/components/task/task-switcher-context-menu.test.tsx b/apps/web/components/task/task-switcher-context-menu.test.tsx index fa4d52678f..fea5884843 100644 --- a/apps/web/components/task/task-switcher-context-menu.test.tsx +++ b/apps/web/components/task/task-switcher-context-menu.test.tsx @@ -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 { return { id: "task-1", title: "Task 1", state: "IN_PROGRESS", ...overrides }; } +function ArchiveAwareRow({ archiveConfirmation }: { archiveConfirmation?: ReactNode }) { + return
Task 1{archiveConfirmation}
; +} + /** * 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 = {}) { onClick={onClick} > -
Task 1
+
@@ -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(); }); }); diff --git a/apps/web/components/task/task-switcher-context-menu.tsx b/apps/web/components/task/task-switcher-context-menu.tsx index aecdba80f2..6ecafa7828 100644 --- a/apps/web/components/task/task-switcher-context-menu.tsx +++ b/apps/web/components/task/task-switcher-context-menu.tsx @@ -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; 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; 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 ( -
{cloneWithMenuOpen(children, contextOpen)}
+
+ {cloneWithMenuOpen( + children, + contextOpen, + archive.archiveOpen ? archive.archiveConfirmation : undefined, + )} +
event.stopPropagation()} > @@ -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; } diff --git a/apps/web/components/task/task-switcher-row.tsx b/apps/web/components/task/task-switcher-row.tsx index 7827b9fff4..f477c30081 100644 --- a/apps/web/components/task/task-switcher-row.tsx +++ b/apps/web/components/task/task-switcher-row.tsx @@ -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; 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} /> ); } diff --git a/apps/web/components/task/task-switcher-types.ts b/apps/web/components/task/task-switcher-types.ts index 1fa4accfe4..5944fc86fd 100644 --- a/apps/web/components/task/task-switcher-types.ts +++ b/apps/web/components/task/task-switcher-types.ts @@ -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; diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 4524fb3e28..a16f3cc77e 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -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, diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index d16cb7ea1f..abd6c4b8e6 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -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 { 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(); } /** diff --git a/apps/web/e2e/tests/chat/chat-status-bar.spec.ts b/apps/web/e2e/tests/chat/chat-status-bar.spec.ts index b3b13e7328..c8895bd627 100644 --- a/apps/web/e2e/tests/chat/chat-status-bar.spec.ts +++ b/apps/web/e2e/tests/chat/chat-status-bar.spec.ts @@ -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 diff --git a/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts b/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts index 3ae9f20a34..3f2876c5f2 100644 --- a/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts +++ b/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts @@ -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, diff --git a/apps/web/e2e/tests/kanban/cascade-subtasks-toggle.spec.ts b/apps/web/e2e/tests/kanban/cascade-subtasks-toggle.spec.ts index 199c955086..79a443ab35 100644 --- a/apps/web/e2e/tests/kanban/cascade-subtasks-toggle.spec.ts +++ b/apps/web/e2e/tests/kanban/cascade-subtasks-toggle.spec.ts @@ -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); }); diff --git a/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts index 22351403e7..0b726306b8 100644 --- a/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts +++ b/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts @@ -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 ({ diff --git a/apps/web/hooks/use-subtask-count.ts b/apps/web/hooks/use-subtask-count.ts index 68c5dda694..684afe7422 100644 --- a/apps/web/hooks/use-subtask-count.ts +++ b/apps/web/hooks/use-subtask-count.ts @@ -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; } diff --git a/apps/web/hooks/use-task-archive-confirm.test.ts b/apps/web/hooks/use-task-archive-confirm.test.ts index 366a329d16..132a4d750e 100644 --- a/apps/web/hooks/use-task-archive-confirm.test.ts +++ b/apps/web/hooks/use-task-archive-confirm.test.ts @@ -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(() => {}); diff --git a/apps/web/hooks/use-task-archive-confirm.ts b/apps/web/hooks/use-task-archive-confirm.ts index b35538f129..b22829fac1 100644 --- a/apps/web/hooks/use-task-archive-confirm.ts +++ b/apps/web/hooks/use-task-archive-confirm.ts @@ -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 }; diff --git a/apps/web/lib/commands/types.ts b/apps/web/lib/commands/types.ts index 1f64cce2ad..245ca261f0 100644 --- a/apps/web/lib/commands/types.ts +++ b/apps/web/lib/commands/types.ts @@ -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 */